scripts/setup-domain.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env tsx
/**
* `pnpm setup:domain` — attaches a custom domain to the chosen Worker env without editing the
* tracked Wrangler config. It renders a gitignored `wrangler.generated.<env>.json`, then deploys
* with a `routes[].custom_domain=true` entry so the Worker becomes reachable on a real hostname.
*
* SAFETY: dry-run by default. Review the exact hostname + deploy command first, then re-run with
* `--apply` once you are ready to mutate Cloudflare.
*
* Usage:
* pnpm setup:domain --env dev --hostname inbox-dev.example.com
* pnpm setup:domain --env dev --hostname inbox-dev.example.com --apply
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { BUILT_CONFIG_PATH, patchBuiltConfig } from "./lib/built-config";
type Route = {
pattern: string;
custom_domain?: boolean;
};
type WranglerBlock = {
name?: string;
vars?: Record<string, unknown>;
workers_dev?: boolean;
routes?: Route[];
triggers?: { crons?: string[] };
send_email?: Array<Record<string, unknown>>;
durable_objects?: Record<string, unknown>;
r2_buckets?: Array<Record<string, unknown>>;
queues?: Record<string, unknown>;
d1_databases?: Array<{
binding: string;
database_name?: string;
database_id?: string;
migrations_dir?: string;
}>;
migrations?: Array<Record<string, unknown>>;
compatibility_date?: string;
compatibility_flags?: string[];
observability?: Record<string, unknown>;
upload_source_maps?: boolean;
configPath?: string;
userConfigPath?: string;
[k: string]: unknown;
};
type WranglerConfig = WranglerBlock & { env?: Record<string, WranglerBlock> };
// Workers Custom Domains API record — see GET /accounts/{account_id}/workers/domains.
type WorkersCustomDomainRecord = {
id?: string;
zone_name?: string;
hostname?: string;
service?: string;
environment?: string;
};
function parseArgs(argv: string[]): Record<string, string> {
const args: Record<string, string> = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg?.startsWith("--")) continue;
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
if (!rawKey) continue;
if (inlineValue !== undefined) {
args[rawKey] = inlineValue;
continue;
}
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[rawKey] = "true";
continue;
}
args[rawKey] = next;
i += 1;
}
return args;
}
function stripJsonc(input: string): string {
return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
function wrangler(argv: string[], opts: { capture?: boolean } = {}): string {
return execFileSync("pnpm", ["wrangler", ...argv], {
encoding: "utf8",
stdio: opts.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "pipe"],
});
}
async function cfApi<T>(token: string, path: string): Promise<T> {
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
const payload = (await response.json()) as {
success?: boolean;
result?: T;
errors?: Array<{ code?: number; message?: string }>;
};
if (!response.ok || payload.success !== true || payload.result === undefined) {
const detail = (payload.errors ?? [])
.map((item) => `${item.message ?? "unknown error"}${item.code ? ` [${item.code}]` : ""}`)
.join("; ");
throw new Error(detail || `Cloudflare API request failed (${response.status})`);
}
return payload.result;
}
/** Best-effort account id lookup: env var first, then `wrangler whoami --json`. */
function resolveAccountId(): string | undefined {
const fromEnv = process.env.CLOUDFLARE_ACCOUNT_ID?.trim();
if (fromEnv) return fromEnv;
try {
const payload = JSON.parse(wrangler(["whoami", "--json"], { capture: true })) as {
account?: { id?: string };
accounts?: Array<{ id?: string }>;
};
return payload.account?.id ?? payload.accounts?.[0]?.id;
} catch {
return undefined;
}
}
/**
* Looks up whether `hostname` is already attached as a Workers Custom Domain, and to which
* Worker. Returns undefined when the check can't be performed (no CLOUDFLARE_API_TOKEN, or no
* resolvable account id) — the caller then falls back to the error-string guard around the
* `wrangler deploy` call, since re-attaching the same hostname to the same Worker is the common
* case that call already tolerates.
*/
async function findExistingCustomDomain(
targetHostname: string,
): Promise<WorkersCustomDomainRecord | undefined> {
const token = process.env.CLOUDFLARE_API_TOKEN?.trim();
if (!token) return undefined;
const accountId = resolveAccountId();
if (!accountId) return undefined;
try {
const records = await cfApi<WorkersCustomDomainRecord[]>(
token,
`/accounts/${accountId}/workers/domains?hostname=${encodeURIComponent(targetHostname)}`,
);
return records.find((record) => record.hostname?.toLowerCase() === targetHostname);
} catch {
return undefined;
}
}
const args = parseArgs(process.argv.slice(2));
const targetEnv = args.env;
const apply = args.apply === "true";
const envLabel = targetEnv ?? "production";
const hostname = args.hostname?.trim().toLowerCase();
if (!hostname) {
console.error("setup:domain: pass --hostname <app.example.com>.");
process.exit(1);
}
if (hostname.endsWith(".workers.dev")) {
console.error(
"setup:domain: pass a custom hostname on a zone you control, not a *.workers.dev URL.\n" +
"Reccado's supported public path is a custom domain: it is the canonical origin the\n" +
"auth issuer signs sessions for, and the zone the /api/auth/* rate-limit rule needs.",
);
process.exit(1);
}
const config = JSON.parse(stripJsonc(readFileSync("wrangler.jsonc", "utf8"))) as WranglerConfig;
const block = targetEnv ? config.env?.[targetEnv] : config;
if (!block) {
console.error(`setup:domain: no config block for env "${targetEnv}" in wrangler.jsonc.`);
process.exit(1);
}
const worker = block.name ?? config.name;
const d1Name = block.d1_databases?.find((entry) => entry.binding === "INDEX_DB")?.database_name;
if (!worker || !d1Name) {
console.error("setup:domain: could not resolve worker or INDEX_DB from wrangler.jsonc.");
process.exit(1);
}
const generatedConfigPath = `wrangler.generated.${envLabel}.json`;
const sourceConfigPath = existsSync(generatedConfigPath) ? generatedConfigPath : "wrangler.jsonc";
const builtConfigPath = BUILT_CONFIG_PATH;
function buildAppForDeploy(): void {
const displayPrefix = targetEnv ? `CLOUDFLARE_ENV=${targetEnv} ` : "";
console.log(`\n▸ Build app for custom-domain deploy\n $ ${displayPrefix}pnpm run build`);
if (!apply) return;
execFileSync("pnpm", ["run", "build"], {
stdio: "inherit",
env: targetEnv ? { ...process.env, CLOUDFLARE_ENV: targetEnv } : process.env,
});
}
function patchBuiltWranglerConfig(): void {
if (!apply) {
console.log(
`\n▸ Overlay generated config onto the build\n source: ${generatedConfigPath}\n target: ${builtConfigPath}\n → would apply the fields ${generatedConfigPath} owns (vars, D1 id, sender allow-list,\n custom-domain route); wrangler.jsonc keeps owning every binding and queue`,
);
return;
}
// Same overlay `pnpm run deploy[:dev]` uses, so the custom-domain deploy and
// every later routine deploy ship the same thing.
patchBuiltConfig(targetEnv);
}
console.log(
`\nReccado setup:domain — env: ${envLabel} · worker: ${worker}` +
`\nmode: ${apply ? "APPLY (mutating Cloudflare)" : "dry run (no changes)"}` +
`\nhostname: ${hostname}\n`,
);
console.log("▸ Check current custom-domain attachment");
if (apply) {
const existing = await findExistingCustomDomain(hostname);
if (!existing) {
console.log(" (no existing attachment found, or the check was skipped — see below)");
} else if (existing.service === worker) {
console.log(` (custom domain already attached to worker "${worker}" — continuing)`);
} else {
console.error(
`setup:domain: hostname "${hostname}" is already attached to a different Worker` +
` ("${existing.service ?? "unknown"}").\n` +
"Refusing to silently steal it. Detach it first (Cloudflare dashboard → Workers & Pages →" +
` Custom Domains, or run \`wrangler deploy\` for "${existing.service ?? "that worker"}"` +
" without this route), or choose a different hostname.",
);
process.exit(1);
}
if (!process.env.CLOUDFLARE_API_TOKEN?.trim()) {
console.log(
" (set CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID to check this up front next time;" +
" falling back to a post-deploy idempotency guard)",
);
}
} else {
console.log(" → would check via the Cloudflare API whether this hostname is attached elsewhere");
}
console.log("▸ Render generated Wrangler config with a custom-domain route");
console.log(` source: ${sourceConfigPath}`);
console.log(` target: ${generatedConfigPath}`);
let resolvedD1Id: string | undefined;
if (apply) {
try {
const list = JSON.parse(wrangler(["d1", "list", "--json"], { capture: true })) as Array<{
name: string;
uuid?: string;
database_id?: string;
}>;
const found = list.find((entry) => entry.name === d1Name);
resolvedD1Id = found?.uuid ?? found?.database_id;
} catch {
// handled by guard below
}
if (!resolvedD1Id) {
console.error(
` Could not resolve the id for D1 "${d1Name}" (check \`wrangler whoami\`). Aborting before deploy.`,
);
process.exit(1);
}
const full = JSON.parse(stripJsonc(readFileSync(sourceConfigPath, "utf8"))) as WranglerConfig;
const targetBlock = targetEnv ? full.env?.[targetEnv] : full;
if (!targetBlock) {
console.error(` Missing target env "${targetEnv}" while rendering ${generatedConfigPath}.`);
process.exit(1);
}
if (targetEnv) {
// Avoid inherited top-level routes/custom domains being re-attached to the env worker.
full.routes = [];
}
targetBlock.workers_dev = false;
targetBlock.routes = [{ pattern: hostname, custom_domain: true }];
const d1Entry = targetBlock.d1_databases?.find((entry) => entry.binding === "INDEX_DB");
if (d1Entry) d1Entry.database_id = resolvedD1Id;
writeFileSync(generatedConfigPath, `${JSON.stringify(full, null, 2)}\n`);
console.log(` Wrote ${generatedConfigPath} with routes=[${hostname}] and workers_dev=false.`);
} else {
console.log(
` → would write ${generatedConfigPath} with routes=[${hostname}] and workers_dev=false`,
);
if (targetEnv) {
console.log(" → would also clear inherited top-level routes in that generated config");
}
}
console.log(`\n▸ Deploy the Worker with the custom domain`);
buildAppForDeploy();
patchBuiltWranglerConfig();
console.log(` $ pnpm wrangler deploy --config ${builtConfigPath}`);
if (apply) {
try {
wrangler(["deploy", "--config", builtConfigPath]);
} catch (error) {
const stderr =
typeof (error as { stderr?: unknown })?.stderr === "string"
? (error as { stderr: string }).stderr
: "";
const haystack = `${error instanceof Error ? error.message : String(error)}\n${stderr}`;
if (/already exists|already attached|already created|duplicate|409/i.test(haystack)) {
console.log(" (custom domain already attached — continuing)");
} else {
if (stderr) console.error(stderr);
throw error;
}
}
}
console.log(`\n${"─".repeat(72)}`);
console.log("Next:");
console.log(`1. Set up the auth perimeter for https://${hostname}:`);
console.log(
` pnpm setup:auth${targetEnv ? ` --env ${targetEnv}` : ""} --url https://${hostname} [--apply]`,
);
console.log(`2. Verify the perimeter answers and the Worker is reachable:`);
console.log(
` pnpm doctor${targetEnv ? ` --env ${targetEnv}` : ""} --cloud --url https://${hostname}`,
);
console.log(`3. If Email Routing should deliver to this same install, wire the zone too:`);
console.log(
` pnpm setup:routing --domain <your-zone>${targetEnv ? ` --env ${targetEnv}` : ""}${apply ? "" : " [--apply]"}`,
);
if (!apply) {
console.log("\nDry run only. Re-run with --apply to create/update the custom domain route.\n");
}