Skip to content

Commit

Permalink
Add support for publishing to Custom domains (#982)
Browse files Browse the repository at this point in the history
* Support "custom_domain" boolean in toml

Needed to re-write a "renderer" of the route to be logged out to the
user, but it's now supported in the type system

* Publish Custom Domains via /domains api

If a toml provides routes with the "custom_domain" flag, publish those
separately from normal routes, using the /domains api. As these are more
complex than simple routes, we ask the api to error eagerly. If it
errors on a conflict for existing custom domains or managed DNS records,
we prompt for confirmation to override the conflicts, and then retry
with updated parameters

* Add tests for publishing to Custom Domains

* Add changeset
  • Loading branch information
matthewdavidrodgers authored May 18, 2022
1 parent cc6e18b commit 6791703
Show file tree
Hide file tree
Showing 6 changed files with 501 additions and 52 deletions.
11 changes: 11 additions & 0 deletions .changeset/shy-eels-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"wrangler": minor
---

feature: add support for publishing to Custom Domains

With the release of Custom Domains for workers, users can publish directly to a custom domain on a route, rather than creating a dummy DNS record first and manually pointing the worker over - this adds the same support to wrangler.

Users declare routes as normal, but to indicate that a route should be treated as a custom domain, a user simply uses the object format in the toml file, but with a new key: custom_domain (i.e. `routes = [{ pattern = "api.example.com", custom_domain = true }]`)

When wrangler sees a route like this, it peels them off from the rest of the routes and publishes them separately, using the /domains api. This api is very defensive, erroring eagerly if there are conflicts in existing Custom Domains or managed DNS records. In the case of conflicts, wrangler prompts for confirmation, and then retries with parameters to indicate overriding is allowed.
8 changes: 4 additions & 4 deletions packages/wrangler/src/__tests__/configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,8 @@ describe("normalizeAndValidateConfig()", () => {
expect(diagnostics.hasWarnings()).toBe(false);
expect(diagnostics.renderErrors()).toMatchInlineSnapshot(`
"Processing wrangler configuration:
- Expected \\"route\\" to be either a string, or an object with shape { pattern, zone_id | zone_name }, but got 888.
- Expected \\"routes\\" to be an array of either strings or objects with the shape { pattern, zone_id | zone_name }, but these weren't valid: [
- Expected \\"route\\" to be either a string, or an object with shape { pattern, custom_domain, zone_id | zone_name }, but got 888.
- Expected \\"routes\\" to be an array of either strings or objects with the shape { pattern, custom_domain, zone_id | zone_name }, but these weren't valid: [
666,
777,
{
Expand Down Expand Up @@ -2229,8 +2229,8 @@ describe("normalizeAndValidateConfig()", () => {
"Processing wrangler configuration:
- \\"env.ENV1\\" environment configuration
- Expected \\"route\\" to be either a string, or an object with shape { pattern, zone_id | zone_name }, but got 888.
- Expected \\"routes\\" to be an array of either strings or objects with the shape { pattern, zone_id | zone_name }, but these weren't valid: [
- Expected \\"route\\" to be either a string, or an object with shape { pattern, custom_domain, zone_id | zone_name }, but got 888.
- Expected \\"routes\\" to be an array of either strings or objects with the shape { pattern, custom_domain, zone_id | zone_name }, but these weren't valid: [
666,
777
].
Expand Down
227 changes: 227 additions & 0 deletions packages/wrangler/src/__tests__/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
unsetMockFetchKVGetValues,
} from "./helpers/mock-cfetch";
import { mockConsoleMethods, normalizeSlashes } from "./helpers/mock-console";
import { mockConfirm } from "./helpers/mock-dialogs";
import { useMockIsTTY } from "./helpers/mock-istty";
import { mockKeyListRequest } from "./helpers/mock-kv";
import { mockOAuthFlow } from "./helpers/mock-oauth-flow";
Expand Down Expand Up @@ -645,6 +646,136 @@ describe("publish", () => {
await runWrangler("publish ./index --env dev --legacy-env false");
});

describe("custom domains", () => {
it("should publish routes marked with 'custom_domain' as seperate custom domains", async () => {
writeWranglerToml({
routes: [{ pattern: "api.example.com", custom_domain: true }],
});
writeWorkerSource();
mockUpdateWorkerRequest({ enabled: false });
mockUploadWorkerRequest({ expectedType: "esm" });
mockPublishCustomDomainsRequest({
publishFlags: {
override_scope: true,
override_existing_origin: false,
override_existing_dns_record: false,
},
domains: [{ hostname: "api.example.com" }],
});
await runWrangler("publish ./index");
expect(std.out).toContain("api.example.com (custom domain)");
});

it("should allow retrying if publish fails with a conflicting custom domain error", async () => {
writeWranglerToml({
routes: [{ pattern: "api.example.com", custom_domain: true }],
});
writeWorkerSource();
mockUpdateWorkerRequest({ enabled: false });
mockUploadWorkerRequest({ expectedType: "esm" });
mockPublishCustomDomainsRequestConflictWithoutOverride({
originConflicts: true,
domains: [{ hostname: "api.example.com" }],
});
mockConfirm({
text: `Custom Domains already exist for these domains: "api.example.com"\nUpdate them to point to this script instead?`,
result: true,
});
await runWrangler("publish ./index");
expect(std.out).toContain("api.example.com (custom domain)");
});

it("should allow retrying if publish fails with a conflicting DNS record error", async () => {
writeWranglerToml({
routes: [{ pattern: "api.example.com", custom_domain: true }],
});
writeWorkerSource();
mockUpdateWorkerRequest({ enabled: false });
mockUploadWorkerRequest({ expectedType: "esm" });
mockPublishCustomDomainsRequestConflictWithoutOverride({
dnsRecordConflicts: true,
domains: [{ hostname: "api.example.com" }],
});
mockConfirm({
text: `You already have conflicting DNS records for these domains: "api.example.com"\nUpdate them to point to this script instead?`,
result: true,
});
await runWrangler("publish ./index");
expect(std.out).toContain("api.example.com (custom domain)");
});

it("should allow retrying for conflicting custom domains and then again for conflicting dns", async () => {
writeWranglerToml({
routes: [{ pattern: "api.example.com", custom_domain: true }],
});
writeWorkerSource();
mockUpdateWorkerRequest({ enabled: false });
mockUploadWorkerRequest({ expectedType: "esm" });
mockPublishCustomDomainsRequestConflictWithoutOverride({
originConflicts: true,
dnsRecordConflicts: true,
domains: [{ hostname: "api.example.com" }],
});
mockConfirm(
{
text: `Custom Domains already exist for these domains: "api.example.com"\nUpdate them to point to this script instead?`,
result: true,
},
{
text: `You already have conflicting DNS records for these domains: "api.example.com"\nUpdate them to point to this script instead?`,
result: true,
}
);
await runWrangler("publish ./index");
expect(std.out).toContain("api.example.com (custom domain)");
});

it("should throw if an invalid custom domain is requested", async () => {
writeWranglerToml({
routes: [{ pattern: "*.example.com", custom_domain: true }],
});
writeWorkerSource();
await expect(
runWrangler("publish ./index")
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Cannot use \\"*.example.com\\" as a Custom Domain; wildcard operators (*) are not allowed"`
);

writeWranglerToml({
routes: [
{ pattern: "api.example.com/at/a/path", custom_domain: true },
],
});
writeWorkerSource();
await expect(
runWrangler("publish ./index")
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Cannot use \\"api.example.com/at/a/path\\" as a Custom Domain; paths are not allowed"`
);
});

it("should not retry publish on error if user does not confirm", async () => {
writeWranglerToml({
routes: [{ pattern: "api.example.com", custom_domain: true }],
});
writeWorkerSource();
mockUpdateWorkerRequest({ enabled: false });
mockUploadWorkerRequest({ expectedType: "esm" });
mockPublishCustomDomainsRequestConflictWithoutOverride({
dnsRecordConflicts: true,
domains: [{ hostname: "api.example.com" }],
});
mockConfirm({
text: `You already have conflicting DNS records for these domains: "api.example.com"\nUpdate them to point to this script instead?`,
result: false,
});
await runWrangler("publish ./index");
expect(std.out).toContain(
'Publishing to Custom Domain "api.example.com" was skipped, fix conflict and try again'
);
});
});

it.todo("should error if it's a workers.dev route");
});

Expand Down Expand Up @@ -4922,6 +5053,102 @@ function mockPublishRoutesRequest({
);
}

function mockPublishCustomDomainsRequest({
publishFlags,
domains = [],
env = undefined,
legacyEnv = false,
}: {
publishFlags: {
override_scope: boolean;
override_existing_origin: boolean;
override_existing_dns_record: boolean;
};
domains: Array<
{ hostname: string } & ({ zone_id?: string } | { zone_name?: string })
>;
env?: string | undefined;
legacyEnv?: boolean | undefined;
}) {
const servicesOrScripts = env && !legacyEnv ? "services" : "scripts";
const environment = env && !legacyEnv ? "/environments/:envName" : "";

setMockResponse(
`/accounts/:accountId/workers/${servicesOrScripts}/:scriptName${environment}/domains`,
"PUT",
([_url, accountId, scriptName, envName], { body }) => {
expect(accountId).toEqual("some-account-id");
expect(scriptName).toEqual(
legacyEnv && env ? `test-name-${env}` : "test-name"
);
if (!legacyEnv) {
expect(envName).toEqual(env);
}

expect(JSON.parse(body as string)).toEqual({
...publishFlags,
origins: domains,
});

return null;
}
);
}

function mockPublishCustomDomainsRequestConflictWithoutOverride({
domains = [],
originConflicts = false,
dnsRecordConflicts = false,
env = undefined,
legacyEnv = false,
}: {
originConflicts?: boolean;
dnsRecordConflicts?: boolean;
domains: Array<
{ hostname: string } & ({ zone_id?: string } | { zone_name?: string })
>;
env?: string | undefined;
legacyEnv?: boolean | undefined;
}) {
const servicesOrScripts = env && !legacyEnv ? "services" : "scripts";
const environment = env && !legacyEnv ? "/environments/:envName" : "";

setMockRawResponse(
`/accounts/:accountId/workers/${servicesOrScripts}/:scriptName${environment}/domains`,
"PUT",
([_url, accountId, scriptName, envName], { body }) => {
expect(accountId).toEqual("some-account-id");
expect(scriptName).toEqual(
legacyEnv && env ? `test-name-${env}` : "test-name"
);
if (!legacyEnv) {
expect(envName).toEqual(env);
}

const parsed = JSON.parse(body as string);
expect(parsed.origins).toEqual(domains);

if (originConflicts && !parsed.override_existing_origin) {
return createFetchResult(null, false, [
{
code: 100116,
message: `Cannot create Custom Domain "${domains[0].hostname}": Custom Domain already exists and points to a different script; retry and use option "override_existing_origin" to override`,
},
]);
}
if (dnsRecordConflicts && !parsed.override_existing_dns_record) {
return createFetchResult(null, false, [
{
code: 100117,
message: `Cannot create Custom Domain "${domains[0].hostname}": a DNS record already exists for this origin; retry and use option "override_existing_dns_record" to override`,
},
]);
}
return createFetchResult(null, true);
}
);
}

/** Create a mock handler for the request to get a list of all KV namespaces. */
function mockListKVNamespacesRequest(...namespaces: KVNamespaceInfo[]) {
setMockResponse(
Expand Down
34 changes: 20 additions & 14 deletions packages/wrangler/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ export interface Environment
extends EnvironmentInheritable,
EnvironmentNonInheritable {}

export type SimpleRoute = string;
export type ZoneIdRoute = {
pattern: string;
zone_id: string;
custom_domain?: boolean;
};
export type ZoneNameRoute = {
pattern: string;
zone_name: string;
custom_domain?: boolean;
};
export type CustomDomainRoute = { pattern: string; custom_domain: boolean };
export type Route =
| SimpleRoute
| ZoneIdRoute
| ZoneNameRoute
| CustomDomainRoute;

/**
* The `EnvironmentInheritable` interface declares all the configuration fields for an environment
* that can be inherited (and overridden) from the top-level environment.
Expand Down Expand Up @@ -74,13 +92,7 @@ interface EnvironmentInheritable {
*
* @inheritable
*/
routes:
| (
| string
| { pattern: string; zone_id: string }
| { pattern: string; zone_name: string }
)[]
| undefined;
routes: Route[] | undefined;

/**
* A route that your worker should be published to. Literally
Expand All @@ -91,13 +103,7 @@ interface EnvironmentInheritable {
*
* @inheritable
*/
route:
| (
| string
| { pattern: string; zone_id: string }
| { pattern: string; zone_name: string }
)
| undefined;
route: Route | undefined;

/**
* Path to a custom tsconfig
Expand Down
Loading

0 comments on commit 6791703

Please sign in to comment.