Ctrl K
ComunidadGuías…

scripts/lib/deploy-config.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * What a deploy actually ships, as data.
 *
 * Two files describe a deployment. The tracked `wrangler.jsonc` owns the
 * STRUCTURE: bindings, queues, Durable Objects, migrations, compat flags — the
 * things the code in this commit depends on, which is why they must travel
 * with the code. The gitignored `wrangler.generated.<env>.json` owns the
 * OPERATOR FACTS the setup scripts discover or decide for one account: the
 * real D1 id, the custom-domain route, the verified sending domains, the
 * sender allow-list. Those are personal and do not belong in a public repo.
 *
 * Until this module existed the two were joined in two different ways. The
 * setup scripts copied the generated file over the build wholesale — structure
 * included, from a snapshot taken whenever the file was first rendered, so a
 * queue added to wrangler.jsonc after that snapshot would silently vanish from
 * the deploy. And `pnpm run deploy:dev` ignored the generated file entirely, so
 * a sending domain `setup:sending` had written there never reached the Worker.
 * Both were silent.
 *
 * The rule now, in one place and for every deploy path:
 *
 *   The generated file WINS, but only for the fields it owns (listed below).
 *   Everything else comes from the tracked config, and every structural field
 *   where the generated file disagrees is reported as ignored, not applied.
 *
 * Pure: no fs, no subprocess. The IO wrapper lives in `built-config.ts`.
 */

export type RouteEntry = { pattern: string; custom_domain?: boolean; [k: string]: unknown };

export type WranglerBlock = {
	name?: string;
	vars?: Record<string, unknown>;
	workers_dev?: boolean;
	routes?: RouteEntry[];
	send_email?: Array<{ name?: string; allowed_sender_addresses?: string[]; [k: string]: unknown }>;
	d1_databases?: Array<{
		binding: string;
		database_name?: string;
		database_id?: string;
		migrations_dir?: string;
		[k: string]: unknown;
	}>;
	queues?: {
		producers?: Array<{ binding: string; queue: string }>;
		consumers?: Array<{ queue: string; dead_letter_queue?: string }>;
	};
	[k: string]: unknown;
};

export type WranglerConfig = WranglerBlock & { env?: Record<string, WranglerBlock> };

/** Fields the generated file is allowed to set. Human-readable, for reports and docs. */
export const GENERATED_OWNED_FIELDS = [
	"vars.* (per variable; variables only in wrangler.jsonc are kept)",
	"d1_databases[].database_id (per binding already declared in wrangler.jsonc)",
	"send_email[].allowed_sender_addresses (per binding already declared in wrangler.jsonc)",
	"routes + workers_dev (together, only when the generated file declares routes — setup:domain)",
] as const;

/**
 * Keys a stale snapshot most plausibly disagrees on. Compared only to report
 * them as ignored; the tracked value is what deploys.
 */
const STRUCTURAL_KEYS = [
	"name",
	"main",
	"triggers",
	"durable_objects",
	"r2_buckets",
	"queues",
	"migrations",
	"compatibility_date",
	"compatibility_flags",
	"observability",
	"upload_source_maps",
] as const;

export function stripJsonc(input: string): string {
	return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}

/**
 * Keys wrangler does NOT inherit from the top level into a named env. Letting
 * them fall through would, for instance, overlay the production block's vars
 * onto a dev deploy whose generated env block happens to omit `vars`.
 */
const NON_INHERITABLE_KEYS = new Set([
	"vars",
	"d1_databases",
	"send_email",
	"queues",
	"durable_objects",
	"r2_buckets",
	"kv_namespaces",
	"services",
]);

/**
 * The block wrangler would use for `env` (undefined = top-level): the env
 * block, with inheritable top-level keys filling what it leaves out.
 */
export function effectiveBlock(config: WranglerConfig, env: string | undefined): WranglerBlock {
	const { env: envs, ...top } = config;
	if (!env) return top;
	const envBlock = envs?.[env];
	if (!envBlock) throw new Error(`no config block for env "${env}"`);
	const merged: WranglerBlock = {};
	for (const [key, value] of Object.entries(top)) {
		if (!NON_INHERITABLE_KEYS.has(key)) merged[key] = value;
	}
	for (const [key, value] of Object.entries(envBlock)) {
		if (value !== undefined) merged[key] = value;
	}
	return merged;
}

export type OverlayChange = { field: string; from: unknown; to: unknown };

export type OverlayResult = {
	/** The config that will be deployed. A new object; inputs are not mutated. */
	config: WranglerBlock;
	/** Generated-owned fields whose value differs from the base and was applied. */
	applied: OverlayChange[];
	/** Fields the generated file disagrees on but does not own; the base value was kept. */
	ignored: string[];
};

function same(a: unknown, b: unknown): boolean {
	return JSON.stringify(a) === JSON.stringify(b);
}

/**
 * Applies the generated-owned fields of `generated` onto `base`.
 *
 * `base` is what would deploy without the generated file: the built
 * `dist/server/wrangler.json` at deploy time, or the tracked block when a
 * caller (doctor, setup:sending) wants to know what a deploy WILL ship.
 * `tracked` is the tracked effective block, used only to decide which
 * structural disagreements to report — comparing against the build instead
 * would flag every path the Vite plugin normalises.
 */
export function applyGeneratedOverlay(opts: {
	base: WranglerBlock;
	generated: WranglerBlock;
	tracked?: WranglerBlock;
}): OverlayResult {
	const { generated } = opts;
	const config: WranglerBlock = structuredClone(opts.base);
	const applied: OverlayChange[] = [];
	const ignored: string[] = [];

	if (generated.vars) {
		const vars: Record<string, unknown> = { ...(config.vars ?? {}) };
		for (const [name, value] of Object.entries(generated.vars)) {
			if (value === undefined) continue;
			if (!same(vars[name], value)) {
				applied.push({ field: `vars.${name}`, from: vars[name], to: value });
				vars[name] = value;
			}
		}
		config.vars = vars;
	}

	for (const db of generated.d1_databases ?? []) {
		if (!db.database_id) continue;
		const target = config.d1_databases?.find((entry) => entry.binding === db.binding);
		if (!target) {
			ignored.push(`d1_databases[${db.binding}] (not declared in wrangler.jsonc)`);
			continue;
		}
		if (target.database_id !== db.database_id) {
			applied.push({
				field: `d1_databases[${db.binding}].database_id`,
				from: target.database_id,
				to: db.database_id,
			});
			target.database_id = db.database_id;
		}
	}

	for (const binding of generated.send_email ?? []) {
		if (binding.allowed_sender_addresses === undefined) continue;
		const target = config.send_email?.find((entry) => entry.name === binding.name);
		if (!target) {
			ignored.push(`send_email[${binding.name ?? "?"}] (not declared in wrangler.jsonc)`);
			continue;
		}
		if (!same(target.allowed_sender_addresses, binding.allowed_sender_addresses)) {
			applied.push({
				field: `send_email[${binding.name ?? "?"}].allowed_sender_addresses`,
				from: target.allowed_sender_addresses,
				to: binding.allowed_sender_addresses,
			});
			target.allowed_sender_addresses = [...binding.allowed_sender_addresses];
		}
	}

	// routes and workers_dev travel as a pair because setup:domain writes them as
	// one decision ("serve on this hostname, close workers.dev"). Without routes a
	// generated workers_dev is almost certainly a snapshot of an older tracked
	// value, and applying it would quietly reopen workers.dev.
	if (generated.routes && generated.routes.length > 0) {
		if (!same(config.routes, generated.routes)) {
			applied.push({ field: "routes", from: config.routes, to: generated.routes });
			config.routes = structuredClone(generated.routes);
		}
		const workersDev = generated.workers_dev ?? false;
		if (config.workers_dev !== workersDev) {
			applied.push({ field: "workers_dev", from: config.workers_dev, to: workersDev });
			config.workers_dev = workersDev;
		}
	} else if (
		generated.workers_dev !== undefined &&
		opts.tracked &&
		generated.workers_dev !== opts.tracked.workers_dev
	) {
		ignored.push("workers_dev (no routes in the generated file, so it is not setup:domain's)");
	}

	if (opts.tracked) {
		for (const key of STRUCTURAL_KEYS) {
			const value = generated[key];
			if (value !== undefined && !same(value, opts.tracked[key])) ignored.push(key);
		}
	}

	return { config, applied, ignored };
}

/**
 * Every generated-owned value that the final config does NOT carry. Empty means
 * the deploy ships exactly what the generated file says. Run on the file as
 * re-read from disk, so it checks what wrangler will read, not what we meant.
 */
export function findOverlayMismatches(final: WranglerBlock, generated: WranglerBlock): string[] {
	const out: string[] = [];
	for (const [name, value] of Object.entries(generated.vars ?? {})) {
		if (value === undefined) continue;
		if (!same(final.vars?.[name], value)) {
			out.push(
				`vars.${name}: generated=${JSON.stringify(value)} deployed=${JSON.stringify(final.vars?.[name])}`,
			);
		}
	}
	for (const db of generated.d1_databases ?? []) {
		if (!db.database_id) continue;
		const target = final.d1_databases?.find((entry) => entry.binding === db.binding);
		if (target && target.database_id !== db.database_id) {
			out.push(
				`d1_databases[${db.binding}].database_id: generated=${db.database_id} deployed=${target.database_id}`,
			);
		}
	}
	for (const binding of generated.send_email ?? []) {
		if (binding.allowed_sender_addresses === undefined) continue;
		const target = final.send_email?.find((entry) => entry.name === binding.name);
		if (target && !same(target.allowed_sender_addresses, binding.allowed_sender_addresses)) {
			out.push(`send_email[${binding.name ?? "?"}].allowed_sender_addresses differ`);
		}
	}
	if (generated.routes && generated.routes.length > 0 && !same(final.routes, generated.routes)) {
		out.push("routes differ");
	}
	return out;
}

/**
 * The vars a deploy of `env` will ship: tracked vars with the generated file's
 * applied on top. Callers that reason about "the deployed config" (doctor,
 * setup:sending's union) use this so they agree with the deploy path.
 */
export function deployedVars(
	tracked: WranglerConfig,
	generated: WranglerConfig | undefined,
	env: string | undefined,
): Record<string, unknown> {
	const trackedBlock = effectiveBlock(tracked, env);
	if (!generated) return { ...(trackedBlock.vars ?? {}) };
	const generatedBlock = effectiveBlock(generated, env);
	return applyGeneratedOverlay({ base: trackedBlock, generated: generatedBlock }).config.vars ?? {};
}

/** Comma-separated domain list, lowercased and de-duplicated, as the Worker parses it. */
export function parseDomainList(raw: unknown): string[] {
	if (typeof raw !== "string") return [];
	return [
		...new Set(
			raw
				.split(",")
				.map((entry) => entry.trim().toLowerCase())
				.filter(Boolean),
		),
	];
}