1import * as durableObjectsApi from "@distilled.cloud/cloudflare/durable-objects";
2import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
3import * as workers from "@distilled.cloud/cloudflare/workers";
4import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms";
5import * as Config from "effect/Config";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import { dotAlchemyDirectory } from "../../AlchemyContext.ts";
12import * as Predicate from "effect/Predicate";
13import * as Redacted from "effect/Redacted";
14import * as Schedule from "effect/Schedule";
15import * as Stream from "effect/Stream";
16import { isHttpClientError } from "effect/http/HttpClientError";
17import * as crypto from "node:crypto";
18import { Unowned } from "../../AdoptPolicy.ts";
19import * as Artifacts from "../../Artifacts.ts";
20import type { ScopedPlanStatusSession } from "../../Report.ts";
21import { havePropsChanged, isResolved, stripEffects } from "../../Diff.ts";
22import * as ProviderLayer from "../../Local/ProviderLayer.ts";
23import * as Provider from "../../Provider.ts";
24import { type ResourceBinding } from "../../Resource.ts";
25import { Stack } from "../../Stack.ts";
26import { cachedFunction } from "../../Util/cached-function.ts";
27import { initialCwd } from "../../Util/Node.ts";
28import { isRedactedMarker } from "../../RuntimeContext.ts";
29import { sha256Object } from "../../Util/sha256.ts";
30import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
31import { localRuntimeServices } from "../LocalRuntime.ts";
32import { detachQueueConsumersOfScript } from "../Queues/Consumer.ts";
33import { CloudflareLogs } from "../Logs.ts";
34import {
35 resolveZoneId,
36 type Reference as ZoneReference,
37} from "../Zone/lookup.ts";
38import {
39 getAssetsPathPrefix,
40 mergeAssetsConfigFiles,
41 readAssets,
42 readAssetsConfigFiles,
43 uploadAssets,
44} from "./Assets.ts";
45import { getCompatibility } from "./Compatibility.ts";
46import { isDurableObjectExport } from "./DurableObject.ts";
47import { LocalWorkerProvider } from "./LocalWorkerProvider.ts";
48import { makeSourceContext, resolveSource } from "./Source.ts";
49import { assertCloudflareTelemetryCompatibility } from "./Telemetry.ts";
50import {
51 isSelfUrl,
52 Worker,
53 type WorkerProps,
54 type WorkerRouteConfig,
55 type WorkerVersionAffinity,
56} from "./Worker.ts";
57import {
58 getCacheBinding,
59 getCronBindings,
60 isContainerDecl,
61 resolveObservability,
62} from "./WorkerAsyncBindings.ts";
63import {
64 type WireWorkerBinding,
65 type WorkerBinding,
66 type WorkerSettingsBinding,
67 withoutDevOnlyBindings,
68} from "./WorkerBinding.ts";
69import { readPrebuiltWorkerBundle } from "./Sources/Prebuilt.ts";
70import { isPythonMain, readPythonWorkerBundle } from "./Sources/Python.ts";
71import { WorkerBundle } from "./Sources/Rolldown.ts";
72import { isWorkerLoader } from "./WorkerLoader.ts";
73import { createWorkerName } from "./WorkerName.ts";
74class MissingDurableObjects extends Data.TaggedError("MissingDurableObjects")<{
75 scriptName: string;
76 expected: string[];
77}> {}
78
79/**
80 * A Durable Object class is being dropped from this Worker while a binding in
81 * the same deploy still references it on another script — the class moved
82 * cross-script, but its namespace (and every stored object in it) still lives
83 * on this Worker. Cloudflare rejects a single upload that both deletes the
84 * class and ships a binding referencing it, and silently deleting would
85 * destroy the namespace's data irreversibly, so the deploy fails before any
86 * upload.
87 *
88 * Moving a Durable Object class between Workers is always declared: set
89 * `transferredFrom` on the Durable Object at its **new host** — naming the
90 * former host by Worker logical id (same stack) or physical script name — and
91 * Alchemy performs the data-preserving `transferred_classes` migration on the
92 * new host's deploy; this deploy then converges on its own. To abandon the
93 * data instead, remove the binding entirely in one deploy (which deletes the
94 * class and its data), then add the cross-script binding in a second deploy.
95 */
96export class DurableObjectTransferRequired extends Data.TaggedError(
97 "DurableObjectTransferRequired",
98)<{
99 scriptName: string;
100 className: string;
101 targetScriptName: string | undefined;
102}> {
103 override get message() {
104 return (
105 `Durable Object class '${this.className}' still lives on Worker '${this.scriptName}' but this deploy re-binds it as a cross-script reference` +
106 (this.targetScriptName ? ` to '${this.targetScriptName}'` : "") +
107 ". Durable Object data does NOT move with the class automatically. " +
108 `To move the data, set transferredFrom: "${this.scriptName}" (the former host's script name, or its Worker logical id for same-stack moves) on the Durable Object declaration in its new host Worker. ` +
109 "To abandon the data, remove the binding entirely in one deploy before re-adding it as a cross-script reference."
110 );
111 }
112}
113
114/**
115 * More than one script matches the `transferredFrom` declaration of a Durable
116 * Object (e.g. an orphaned script left behind by a `name` prop change still
117 * carries the same alchemy tags, or the host history lists several scripts
118 * that each still hold a same-class namespace). Alchemy refuses to guess
119 * which namespace's data to move — narrow the declaration to the exact
120 * physical script name that holds the data.
121 */
122export class AmbiguousDurableObjectTransfer extends Data.TaggedError(
123 "AmbiguousDurableObjectTransfer",
124)<{
125 scriptName: string;
126 logicalId: string;
127 className: string;
128 sources: string[];
129}> {
130 override get message() {
131 return (
132 `Durable Object '${this.logicalId}' (class '${this.className}') is new to Worker '${this.scriptName}' and multiple scripts match its transferredFrom declaration: ${this.sources.join(", ")}. ` +
133 `Narrow transferredFrom to the exact physical script name that holds the data.`
134 );
135 }
136}
137
138/**
139 * Resolve the Workers for Platforms dispatch-namespace *name* from a resolved
140 * `namespace` prop or persisted attribute. The engine resolves a passed
141 * {@link DispatchNamespace} resource to its Attributes object (see
142 * `Input.Resolve` / Plan.ts), so the value is either the namespace name
143 * string, that attributes object, or `undefined` for a regular Worker.
144 *
145 * @internal
146 */
147export const resolveNamespaceName = (
148 namespace: unknown,
149): string | undefined => {
150 if (namespace == null) return undefined;
151 if (typeof namespace === "string") return namespace;
152 return (namespace as { name?: string }).name;
153};
154
155/**
156 * Resolve a Worker's `tailConsumers` / `streamingTailConsumers` prop into
157 * the wire-shape consumer list
158 * (`[{ service }]`). The engine resolves a passed {@link Worker} to its
159 * Attributes object — possibly stables-only during planning, but
160 * `workerName` is always a stable — so each entry is either a script-name
161 * string or that attributes object. Whole-resource entries are reduced to
162 * the script name alone so hashing/diffing never sees the consumer's
163 * per-deploy fields (`hash`, `url`, ...), mirroring
164 * {@link resolveVersionParentName}.
165 *
166 * An empty array resolves to `[]` (explicitly detach every consumer);
167 * `undefined`/absent resolves to `undefined`.
168 *
169 * This is also the seam for local emulation: the local provider lowers this
170 * same resolved list into workerd's `Worker.tails` / `Worker.streamingTails`
171 * service designators (`RuntimeWorker.tails` / `RuntimeWorker.streamingTails`).
172 *
173 * @internal
174 */
175export const resolveTailConsumers = (
176 tailConsumers: WorkerProps["tailConsumers" | "streamingTailConsumers"],
177): { service: string }[] | undefined => {
178 if (tailConsumers == null) return undefined;
179 return tailConsumers.flatMap((consumer) => {
180 const service =
181 typeof consumer === "string"
182 ? consumer
183 : (consumer as { workerName?: unknown }).workerName;
184 return typeof service === "string" ? [{ service }] : [];
185 });
186};
187
188/**
189 * A Worker's `version` configuration is invalid — a prop that can't be
190 * combined with `version.parent` (script-level settings belong to the
191 * parent), a locally-hosted Durable Object / Workflow class on a version
192 * worker, an out-of-range `traffic`, or a gradual rollout that requires
193 * changes the versions API can't carry (assets, DO migrations).
194 */
195export class WorkerVersionConfigError extends Data.TaggedError(
196 "WorkerVersionConfigError",
197)<{
198 message: string;
199}> {}
200
201/**
202 * Resolve the parent script *name* from a resolved `version.parent` prop or
203 * persisted props. The engine resolves a passed {@link Worker} (or
204 * `Worker.ref(...)`) to its Attributes object — possibly stables-only during
205 * planning, but `workerName` is always a stable — so the value is either the
206 * script name string, that attributes object, or `undefined`.
207 *
208 * @internal
209 */
210export const resolveVersionParentName = (
211 version: WorkerProps["version"],
212): string | undefined => {
213 const parent = version?.parent;
214 if (parent == null) return undefined;
215 if (typeof parent === "string") return parent;
216 const workerName = (parent as { workerName?: unknown }).workerName;
217 return typeof workerName === "string" ? workerName : undefined;
218};
219
220/**
221 * A Worker's `preview` configuration is invalid — combined with
222 * `version.parent`, missing `preview.of`, a bad Preview name, or a
223 * script-level setting that belongs to the parent.
224 */
225export class WorkerPreviewConfigError extends Data.TaggedError(
226 "WorkerPreviewConfigError",
227)<{
228 message: string;
229}> {}
230
231/**
232 * Resolve the parent script *name* from a resolved `preview.of` prop.
233 *
234 * @internal
235 */
236export const resolvePreviewParentName = (
237 preview: WorkerProps["preview"],
238): string | undefined => {
239 const parent = preview?.of;
240 if (parent == null) return undefined;
241 if (typeof parent === "string") return parent;
242 const workerName = (parent as { workerName?: unknown }).workerName;
243 return typeof workerName === "string" ? workerName : undefined;
244};
245
246const toPreviewWireKey = (key: string) =>
247 key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
248
249const toPreviewWire = (value: unknown): unknown => {
250 if (Array.isArray(value)) return value.map(toPreviewWire);
251 if (value !== null && typeof value === "object") {
252 return Object.fromEntries(
253 Object.entries(value as Record<string, unknown>).map(([k, v]) => [
254 toPreviewWireKey(k),
255 toPreviewWire(v),
256 ]),
257 );
258 }
259 return value;
260};
261
262/** Convert Alchemy's binding array into wrangler's Preview `env` map. */
263const bindingsToPreviewEnv = (
264 bindings: WorkerBinding[],
265): Record<string, unknown> => {
266 const env: Record<string, unknown> = {};
267 for (const binding of bindings) {
268 const { name, ...rest } = binding as unknown as {
269 name?: unknown;
270 } & Record<string, unknown>;
271 if (typeof name !== "string") continue;
272 env[name] = toPreviewWire(rest);
273 }
274 return env;
275};
276
277/**
278 * The traffic percentage a *self-owned* Worker's new version should receive,
279 * or `undefined` for the default full cutover. Only a `version` prop without
280 * a `parent` participates — version workers handle traffic separately.
281 *
282 * @internal
283 */
284const getSelfRolloutTraffic = (news: WorkerProps): number | undefined => {
285 if (!news.version || news.version.parent != null) return undefined;
286 const traffic = news.version.traffic;
287 return traffic === undefined || traffic >= 100 ? undefined : traffic;
288};
289
290const validateTraffic = (traffic: number | undefined) =>
291 traffic !== undefined &&
292 (!Number.isFinite(traffic) || traffic < 0 || traffic > 100)
293 ? Effect.fail(
294 new WorkerVersionConfigError({
295 message: `version.traffic must be a percentage between 0 and 100, got ${traffic}`,
296 }),
297 )
298 : Effect.void;
299
300/** The request header Cloudflare hashes to pin a request to a version. */
301const AFFINITY_HEADER = "Cloudflare-Workers-Version-Key";
302
303/**
304 * `version.affinity` normalized to a single key source plus the optional
305 * IP fallback.
306 *
307 * @internal exported for unit testing.
308 */
309export interface ResolvedVersionAffinity {
310 source:
311 | { kind: "cookie" | "header"; name: string }
312 | { kind: "ip" }
313 | { kind: "key"; expression: string };
314 ipFallback: boolean;
315}
316
317// Cookie / header names are interpolated into a double-quoted Rules-language
318// string literal — restrict them to the token characters real-world names
319// use so a name can never terminate the literal or smuggle expression text.
320const AFFINITY_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
321
322/**
323 * Validate `version.affinity` and normalize it to its key source: exactly
324 * one of `cookie` / `header` / `key`, or `ip: true` alone; `ip` combines
325 * with `cookie` / `header` as the absent-source fallback.
326 *
327 * @internal exported for unit testing.
328 */
329export const resolveVersionAffinity = (
330 affinity: WorkerVersionAffinity,
331): Effect.Effect<ResolvedVersionAffinity, WorkerVersionConfigError> =>
332 Effect.gen(function* () {
333 const declared = [
334 ...(affinity.cookie !== undefined ? ["cookie"] : []),
335 ...(affinity.header !== undefined ? ["header"] : []),
336 ...(affinity.key !== undefined ? ["key"] : []),
337 ];
338 if (declared.length > 1) {
339 return yield* Effect.fail(
340 new WorkerVersionConfigError({
341 message: `version.affinity accepts exactly one key source, got ${declared.join(" and ")}. Combine sources with a raw \`key\` expression instead.`,
342 }),
343 );
344 }
345 if (declared.length === 0 && affinity.ip !== true) {
346 return yield* Effect.fail(
347 new WorkerVersionConfigError({
348 message:
349 "version.affinity requires a key source: set `cookie`, `header`, `key`, or `ip: true`.",
350 }),
351 );
352 }
353 if (affinity.key !== undefined && affinity.ip === true) {
354 return yield* Effect.fail(
355 new WorkerVersionConfigError({
356 message:
357 "version.affinity: `ip` is the fallback for an absent `cookie`/`header` — a raw `key` expression has no absence condition to fall back from. Fold `ip.src` into the expression instead.",
358 }),
359 );
360 }
361 for (const [prop, name] of [
362 ["cookie", affinity.cookie],
363 ["header", affinity.header],
364 ] as const) {
365 if (name !== undefined && !AFFINITY_NAME_PATTERN.test(name)) {
366 return yield* Effect.fail(
367 new WorkerVersionConfigError({
368 message: `version.affinity.${prop} '${name}' is not a valid ${prop} name: expected only letters, digits, '_', '.', and '-'.`,
369 }),
370 );
371 }
372 }
373 const source: ResolvedVersionAffinity["source"] =
374 affinity.cookie !== undefined
375 ? { kind: "cookie", name: affinity.cookie }
376 : affinity.header !== undefined
377 ? // Rules-language header map keys are lowercase.
378 { kind: "header", name: affinity.header.toLowerCase() }
379 : affinity.key !== undefined
380 ? { kind: "key", expression: affinity.key }
381 : { kind: "ip" };
382 return {
383 source,
384 ipFallback:
385 affinity.ip === true &&
386 (source.kind === "cookie" || source.kind === "header"),
387 };
388 });
389
390/** A hostname a Worker serves on within one zone. */
391interface AffinityZoneHost {
392 host: string;
393 /** `true` when `host` came from a route pattern containing `*`. */
394 wildcard: boolean;
395}
396
397/**
398 * The `http.host` clause scoping a zone's affinity rules to the Worker's
399 * own hostnames, so unrelated zone traffic — and other Workers' rollouts
400 * on the same zone — never get this Worker's version key.
401 *
402 * @internal exported for unit testing.
403 */
404export const affinityHostExpression = (
405 hosts: readonly AffinityZoneHost[],
406): string => {
407 const dedupe = (values: string[]) => [...new Set(values)].sort();
408 const exact = dedupe(hosts.filter((h) => !h.wildcard).map((h) => h.host));
409 const wild = dedupe(hosts.filter((h) => h.wildcard).map((h) => h.host));
410 const clauses = [
411 ...(exact.length === 1
412 ? [`http.host eq "${exact[0]}"`]
413 : exact.length > 1
414 ? [`http.host in {${exact.map((h) => `"${h}"`).join(" ")}}`]
415 : []),
416 ...wild.map((h) => `http.host wildcard "${h}"`),
417 ];
418 return clauses.length === 1 ? clauses[0] : `(${clauses.join(" or ")})`;
419};
420
421interface AffinityRuleSpec {
422 description: string;
423 expression: string;
424 /** Rules-language expression producing the header value. */
425 value: string;
426}
427
428const affinityRulePrefix = (scriptName: string) =>
429 `alchemy:worker:${scriptName}:affinity`;
430
431/**
432 * The transform rules pinning one zone's traffic: a primary rule filling
433 * the version-key header from the configured source, plus — for
434 * `cookie`/`header` sources with `ip: true` — a fallback rule keying
435 * requests that lack the source by client IP.
436 *
437 * @internal exported for unit testing.
438 */
439export const buildAffinityZoneRules = (
440 scriptName: string,
441 affinity: ResolvedVersionAffinity,
442 hosts: readonly AffinityZoneHost[],
443): AffinityRuleSpec[] => {
444 const prefix = affinityRulePrefix(scriptName);
445 const hostExpr = affinityHostExpression(hosts);
446 const { source } = affinity;
447 if (source.kind === "ip" || source.kind === "key") {
448 return [
449 {
450 description: `${prefix}:key`,
451 expression: hostExpr,
452 value: source.kind === "ip" ? "to_string(ip.src)" : source.expression,
453 },
454 ];
455 }
456 const field =
457 source.kind === "cookie"
458 ? `http.request.cookies["${source.name}"]`
459 : `http.request.headers["${source.name}"]`;
460 return [
461 {
462 description: `${prefix}:key`,
463 expression: `${hostExpr} and len(${field}) > 0`,
464 value: `${field}[0]`,
465 },
466 ...(affinity.ipFallback
467 ? [
468 {
469 // An absent cookie/header is a *missing* value in the Rules
470 // language, and every comparison on missing evaluates false —
471 // `len(field) == 0` can never match. `not (len(field) > 0)`
472 // is the complement that does: missing → false → `not` → true.
473 description: `${prefix}:ip`,
474 expression: `${hostExpr} and not (len(${field}) > 0)`,
475 value: "to_string(ip.src)",
476 },
477 ]
478 : []),
479 ];
480};
481
482/**
483 * A Worker's `domain` configuration is invalid — a hostname appears in more
484 * than one role (name/aliases/redirects), or a redirect targets itself.
485 */
486export class WorkerDomainConfigError extends Data.TaggedError(
487 "WorkerDomainConfigError",
488)<{
489 message: string;
490}> {}
491
492/**
493 * The resolved shape of `WorkerProps.workersDev`: `enabled` drives the
494 * stable `<name>.<account>.workers.dev` URL, `previewsEnabled` the
495 * per-version preview URLs. The two toggles are independent on the
496 * Cloudflare API.
497 *
498 * @internal exported for unit testing.
499 */
500export interface ResolvedWorkersDev {
501 enabled: boolean;
502 previewsEnabled: boolean;
503}
504
505/**
506 * Resolve the `workersDev` prop to its full shape. `true` / omitted means
507 * "default workers.dev behavior" (stable URL + version previews), `false`
508 * disables both, and the object form fills unset toggles with `true`.
509 *
510 * @internal exported for unit testing.
511 */
512export const resolveWorkersDev = (
513 workersDev: WorkerProps["workersDev"],
514): ResolvedWorkersDev => {
515 if (workersDev === undefined || workersDev === true) {
516 return { enabled: true, previewsEnabled: true };
517 }
518 if (workersDev === false) {
519 return { enabled: false, previewsEnabled: false };
520 }
521 return {
522 enabled: workersDev.enabled ?? true,
523 previewsEnabled: workersDev.previewsEnabled ?? true,
524 };
525};
526
527/**
528 * The resolved shape of `WorkerProps.domain`: the canonical hostname plus
529 * alias and redirect hostname lists, all punycode-normalized and
530 * de-duplicated.
531 *
532 * @internal exported for unit testing.
533 */
534export interface ResolvedWorkerDomain {
535 name: string;
536 aliases: string[];
537 redirects: string[];
538 /** Pinned zone from props, when the caller set zoneId / zone / zoneName. */
539 zone?: ZoneReference;
540 /** Serve Worker Previews on this custom domain. */
541 previews?: boolean;
542}
543
544const isZoneReference = (value: unknown): value is ZoneReference => {
545 if (typeof value === "string") return true;
546 if (typeof value !== "object" || value === null) return false;
547 const candidate = value as { zoneId?: unknown; name?: unknown };
548 return (
549 typeof candidate.zoneId === "string" &&
550 (candidate.name === undefined || typeof candidate.name === "string")
551 );
552};
553
554/** Collapse Worker.domain zone pin fields to one {@link ZoneReference}. */
555export const resolveWorkerDomainZone = (
556 config:
557 | {
558 readonly zoneId?: unknown;
559 readonly zoneName?: unknown;
560 readonly zone?: unknown;
561 }
562 | undefined,
563): ZoneReference | undefined => {
564 if (config === undefined) return undefined;
565 if (typeof config.zoneId === "string") return config.zoneId;
566 if (isZoneReference(config.zone)) return config.zone;
567 if (typeof config.zoneName === "string") return config.zoneName;
568 return undefined;
569};
570
571/** Whether an existing attachment must move to satisfy an explicit zone pin. */
572export const shouldRecreateWorkerDomainAttachment = (
573 liveZoneId: string,
574 desiredZoneId: string | undefined,
575): boolean => desiredZoneId !== undefined && liveZoneId !== desiredZoneId;
576
577// After deleting a custom-domain attachment, Cloudflare can briefly retain
578// ownership of the hostname and reject the replacement with code 100116.
579const workerDomainConflictSchedule = Schedule.max([
580 Schedule.spaced("2 seconds"),
581 Schedule.recurs(8),
582]);
583
584// Convert non-ASCII hostnames (emoji, IDN, etc.) to punycode so the
585// Cloudflare API receives the form it stores domains in. `new URL(...)`
586// does IDNA via WHATWG URL parsing — `📦.alchemy.run` → `xn--5z8h.alchemy.run`.
587const toPunycode = (hostname: string): string => {
588 try {
589 return new URL(`https://${hostname}`).hostname;
590 } catch {
591 return hostname;
592 }
593};
594
595/**
596 * Resolve the `domain` prop to its full shape — a bare string is shorthand
597 * for `{ name }`. Hostnames are punycode-normalized and de-duplicated;
598 * a hostname may only play one role, so aliases/redirects that repeat the
599 * canonical name (or each other) fail with a typed error.
600 *
601 * @internal exported for unit testing.
602 */
603export const resolveWorkerDomain = (
604 // `string[]` is the pre-redesign prop shape — it can still reach us from
605 // persisted `olds` written by older providers (read's classification).
606 domain: WorkerProps["domain"] | string[],
607): Effect.Effect<ResolvedWorkerDomain | undefined, WorkerDomainConfigError> =>
608 Effect.gen(function* () {
609 if (domain === undefined || domain === null) return undefined;
610 // Legacy array form: the first hostname was the primary custom domain
611 // (`url = domains[0]` back then), the rest map to aliases. A legacy
612 // empty array was the explicit detach-all — no domain.
613 const config =
614 typeof domain === "string"
615 ? { name: domain }
616 : Array.isArray(domain)
617 ? domain.length > 0
618 ? { name: domain[0], aliases: domain.slice(1) }
619 : undefined
620 : domain;
621 if (config === undefined) return undefined;
622 const name = toPunycode(config.name);
623 const aliases = Array.from(new Set((config.aliases ?? []).map(toPunycode)));
624 const redirects = Array.from(
625 new Set((config.redirects ?? []).map(toPunycode)),
626 );
627 const overlap = [
628 ...aliases.filter((h) => h === name),
629 ...redirects.filter((h) => h === name || aliases.includes(h)),
630 ];
631 if (overlap.length > 0) {
632 return yield* Effect.fail(
633 new WorkerDomainConfigError({
634 message: `Each hostname may play only one role in a Worker's domain config; ${[...new Set(overlap)].map((h) => `'${h}'`).join(", ")} appears in more than one of name/aliases/redirects.`,
635 }),
636 );
637 }
638 const zone = resolveWorkerDomainZone(config);
639 const previews =
640 "previews" in config && config.previews === true ? true : undefined;
641 return {
642 name,
643 aliases,
644 redirects,
645 ...(zone === undefined ? {} : { zone }),
646 ...(previews === undefined ? {} : { previews }),
647 };
648 });
649
650const isWorkersDevHostname = (hostname: string) =>
651 hostname.endsWith(".workers.dev");
652
653// Hostnames that only appear in local-dev state (the dev server's
654// localhost/LAN URLs), never as attachable custom domains.
655const isLocalDevHostname = (hostname: string) =>
656 hostname === "localhost" ||
657 hostname === "::1" ||
658 /^\d+\.\d+\.\d+\.\d+$/.test(hostname);
659
660const urlHostname = (url: string): string => {
661 try {
662 return new URL(url).hostname;
663 } catch {
664 return url;
665 }
666};
667
668/** The `rules` payload shape of a zone phase-entrypoint PUT. */
669type PutZoneRedirectRules = rulesets.PutPhasForZoneRequest["rules"];
670
671/**
672 * The *custom domain* hostnames recorded in a Worker's persisted legacy
673 * `domains` state — minus the workers.dev (stable or preview) and local-dev
674 * entries that shared the list in older formats.
675 *
676 * @internal exported for unit testing.
677 */
678export const stateCustomDomains = (
679 domains: readonly unknown[] | undefined,
680): string[] =>
681 normalizeStateDomains(domains).filter(
682 (hostname) =>
683 !isWorkersDevHostname(hostname) && !isLocalDevHostname(hostname),
684 );
685
686/**
687 * The Worker's persisted domain configuration: the `domain` attribute for
688 * state written by the current format, else re-derived from the legacy
689 * `domains` list (first custom hostname = canonical name, rest = aliases;
690 * legacy state had no redirects).
691 *
692 * @internal exported for unit testing.
693 */
694export const stateWorkerDomain = (
695 output: object | undefined,
696): ResolvedWorkerDomain | undefined => {
697 const state = output as
698 | {
699 domain?: {
700 name?: unknown;
701 aliases?: unknown[];
702 redirects?: unknown[];
703 zone?: ZoneReference;
704 zoneId?: unknown;
705 zoneName?: unknown;
706 previews?: unknown;
707 } | null;
708 domains?: unknown[];
709 }
710 | undefined;
711 const domain = state?.domain;
712 if (domain && typeof domain.name === "string") {
713 const zone = resolveWorkerDomainZone(domain);
714 return {
715 name: domain.name,
716 aliases: (domain.aliases ?? []).filter(
717 (h): h is string => typeof h === "string",
718 ),
719 redirects: (domain.redirects ?? []).filter(
720 (h): h is string => typeof h === "string",
721 ),
722 ...(zone === undefined ? {} : { zone }),
723 ...(domain.previews === true ? { previews: true } : {}),
724 };
725 }
726 const legacy = stateCustomDomains(state?.domains);
727 return legacy.length > 0
728 ? { name: legacy[0], aliases: legacy.slice(1), redirects: [] }
729 : undefined;
730};
731
732// Workers for Platforms "user workers" live inside a dispatch namespace and
733// use a parallel family of script endpoints (`/workers/dispatch/namespaces/
734// :namespace/scripts/...`). The request/response shapes are identical to the
735// account-level Workers API for everything the provider touches, so these
736// helpers route by `dispatchNamespace` and the call sites stay agnostic.
737
738/**
739 * Read a script's combined settings, routing to the dispatch-namespace
740 * endpoint when `dispatchNamespace` is set. The two response shapes are
741 * structurally identical for the fields the provider consumes (`bindings`,
742 * `tags`, `logpush`), so the WFP response is surfaced as the workers shape.
743 *
744 * @internal
745 */
746const getScriptSettings = (
747 accountId: string,
748 scriptName: string,
749 dispatchNamespace: string | undefined,
750) =>
751 // `Effect.gen` (rather than a ternary) so the two branches unify into a
752 // single `Effect<Settings, WorkersErr | WfpErr>` instead of a *union* of
753 // Effects, which `.pipe`/`catchTag` at the call sites can't consume.
754 Effect.gen(function* () {
755 if (dispatchNamespace) {
756 const settings = yield* wfp.getDispatchNamespaceScriptSetting({
757 accountId,
758 dispatchNamespace,
759 scriptName,
760 });
761 // The dispatch-namespace settings response is structurally identical to
762 // the account-level one for the fields the provider reads.
763 return settings as unknown as workers.GetScriptScriptAndVersionSettingResponse;
764 }
765 return yield* workers.getScriptScriptAndVersionSetting({
766 accountId,
767 scriptName,
768 });
769 });
770
771/**
772 * Deploy-time binding validation rejects an upload whose bindings
773 * reference a resource Cloudflare can't see (each resource type has
774 * its own typed not-found error, verified against the live API).
775 * Every bound resource is provisioned before the Worker deploys —
776 * dependency order for KV/R2/D1/queues/etc., a pre-created stub
777 * (which exports the Durable Object classes) for circular
778 * Worker↔Worker references — so a not-found here is either
779 * propagation lag on a just-created resource (a Secrets Store secret
780 * still `pending`, a stub script not yet in the registry) that
781 * retrying converges, or a genuine misconfiguration that keeps
782 * failing and surfaces as the typed error once the bounded budget is
783 * exhausted.
784 */
785const isBindingTargetNotFound = (
786 e:
787 | Effect.Error<ReturnType<typeof workers.putScript>>
788 | Effect.Error<ReturnType<typeof wfp.putDispatchNamespaceScript>>
789 | Effect.Error<ReturnType<typeof workers.createScriptVersion>>,
790): boolean =>
791 e._tag === "SecretsStoreBindingNotFound" ||
792 e._tag === "KVNamespaceNotFound" ||
793 e._tag === "R2BucketNotFound" ||
794 e._tag === "D1DatabaseNotFound" ||
795 e._tag === "QueueNotFound" ||
796 e._tag === "ServiceBindingNotFound" ||
797 e._tag === "DurableObjectClassNotFound" ||
798 e._tag === "HyperdriveConfigNotFound" ||
799 e._tag === "VectorizeIndexNotFound" ||
800 e._tag === "DispatchNamespaceNotFound" ||
801 e._tag === "MtlsCertificateNotFound";
802
803const bindingTargetNotFoundRetrySchedule = () =>
804 Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]);
805
806/**
807 * Script PUT is an idempotent upsert, so a pure transport failure (the
808 * request died before any response — e.g. Cloudflare closing a keep-alive
809 * socket that idled while slow upstream resources provisioned earlier in the
810 * deploy) is safe to replay. Errors that carry a response are real API
811 * verdicts and are NOT retried here.
812 */
813const isScriptPutTransportError = (e: { _tag?: string }): boolean =>
814 isHttpClientError(e) && e.reason._tag === "TransportError";
815
816const retryableScriptPut = (
817 e: Parameters<typeof isBindingTargetNotFound>[0],
818): boolean => isBindingTargetNotFound(e) || isScriptPutTransportError(e);
819
820/**
821 * Upsert a Worker script, routing to the dispatch-namespace endpoint when
822 * `dispatchNamespace` is set. The metadata/files contract is identical, and
823 * both endpoints run the same binding validation (see
824 * {@link isBindingTargetNotFound}), so both get the same bounded retry.
825 *
826 * @internal
827 */
828/**
829 * A cached `workerId` attribute usable as the immutable script ID — rules
830 * out the legacy shape (older releases persisted the script *name*), the
831 * `dev:`-marked local identity, and the precreate stub's provisional `""`.
832 */
833const cachedWorkerId = (
834 value: string | undefined,
835 scriptName: string,
836): string | undefined =>
837 value !== undefined &&
838 value !== "" &&
839 value !== scriptName &&
840 !value.startsWith("dev:")
841 ? value
842 : undefined;
843
844export class WorkerIdNotFound extends Data.TaggedError("WorkerIdNotFound")<{
845 scriptName: string;
846 message: string;
847}> {}
848
849/**
850 * Resolve a script's immutable Worker ID (carried as `tag` on Cloudflare's
851 * wire) by script name. Neither the settings endpoints nor GET /content
852 * expose it, so scan the account's script listing lazily and stop at the
853 * first match. The listing is eventually consistent, so a missing entry is
854 * retried briefly before failing.
855 */
856const findWorkerId = (accountId: string, scriptName: string) =>
857 workers.listScripts.items({ accountId }).pipe(
858 Stream.filter((script) => script.id === scriptName),
859 Stream.runHead,
860 Effect.map(Option.getOrUndefined),
861 Effect.flatMap((script) =>
862 script?.tag != null
863 ? Effect.succeed(script.tag)
864 : Effect.fail(
865 new WorkerIdNotFound({
866 scriptName,
867 message: `Cloudflare Worker: could not resolve the immutable ID of script '${scriptName}' from the account listing`,
868 }),
869 ),
870 ),
871 Effect.retry({
872 while: (e) => e._tag === "WorkerIdNotFound",
873 schedule: Schedule.max([Schedule.spaced(2000), Schedule.recurs(3)]),
874 }),
875 );
876
877const putWorkerScript = (params: {
878 accountId: string;
879 scriptName: string;
880 dispatchNamespace: string | undefined;
881 metadata: workers.PutScriptRequest["metadata"];
882 files: workers.PutScriptRequest["files"];
883}) =>
884 Effect.gen(function* () {
885 if (params.dispatchNamespace) {
886 return yield* wfp
887 .putDispatchNamespaceScript({
888 accountId: params.accountId,
889 dispatchNamespace: params.dispatchNamespace,
890 scriptName: params.scriptName,
891 metadata: params.metadata,
892 files: params.files,
893 })
894 .pipe(
895 Effect.retry({
896 while: retryableScriptPut,
897 schedule: bindingTargetNotFoundRetrySchedule(),
898 }),
899 );
900 }
901 return yield* workers
902 .putScript({
903 accountId: params.accountId,
904 scriptName: params.scriptName,
905 metadata: params.metadata,
906 files: params.files,
907 })
908 .pipe(
909 Effect.retry({
910 while: retryableScriptPut,
911 schedule: bindingTargetNotFoundRetrySchedule(),
912 }),
913 );
914 });
915
916/**
917 * Delete a Worker script, routing to the dispatch-namespace endpoint when
918 * `dispatchNamespace` is set.
919 *
920 * @internal
921 */
922const deleteWorkerScript = (
923 accountId: string,
924 scriptName: string,
925 dispatchNamespace: string | undefined,
926) =>
927 Effect.gen(function* () {
928 if (dispatchNamespace) {
929 return yield* wfp.deleteDispatchNamespaceScript({
930 accountId,
931 dispatchNamespace,
932 scriptName,
933 force: true,
934 });
935 }
936 return yield* workers
937 .deleteScript({ accountId, scriptName, force: true })
938 .pipe(
939 // The script is still registered as a queue consumer (even with
940 // `force`). Normally the sibling Consumer resource detaches first,
941 // but state loss (e.g. a consumer row rewritten by a pre-stamping
942 // dev run) can strand a live consumer pointing at this script with
943 // nothing left to delete it. The script is going away, so any
944 // consumer wiring pointing at it is dead — detach and retry.
945 Effect.catchTag("QueueConsumerConflict", () =>
946 detachQueueConsumersOfScript(accountId, scriptName).pipe(
947 Effect.andThen(
948 workers.deleteScript({ accountId, scriptName, force: true }),
949 ),
950 ),
951 ),
952 );
953 });
954
955/**
956 * Normalize a Worker's persisted *legacy* `domains` state to bare
957 * hostnames. Alchemy <= beta.44 stored each custom domain as a
958 * `{ id, hostname, zoneId }` object; beta.45+ stored `https://<hostname>`
959 * URL strings (with the workers.dev URL mixed in); current state stores the
960 * `domain` config object instead of a `domains` list. All legacy
961 * generations coerce to hostnames so the diff never throws on older state
962 * (#546). Entries that fit no generation are dropped rather than turned
963 * into a bogus hostname that would skew the diff.
964 *
965 * @internal exported for unit testing.
966 */
967export const normalizeStateDomains = (
968 domains: readonly unknown[] | undefined,
969): string[] =>
970 (domains ?? []).flatMap((u) => {
971 if (typeof u === "string") {
972 if (u.includes("://")) {
973 try {
974 return [new URL(u).hostname];
975 } catch {
976 return [];
977 }
978 }
979 return u.length > 0 ? [u] : [];
980 }
981 const hostname = (u as { hostname?: unknown } | null)?.hostname;
982 return typeof hostname === "string" ? [hostname] : [];
983 });
984
985/**
986 * Custom domains Alchemy is responsible for on this Worker — either declared
987 * on props (`domain`) or already persisted as non-`workers.dev` URLs in state.
988 * Used by `read` to skip `listDomains` when the surface is unmanaged (#926).
989 *
990 * @internal exported for unit testing.
991 */
992export const shouldObserveWorkerDomains = (
993 olds: Pick<WorkerProps, "domain"> | undefined,
994 output: object | undefined,
995): boolean =>
996 olds?.domain !== undefined || stateWorkerDomain(output) !== undefined;
997
998/**
999 * Zone routes Alchemy is responsible for on this Worker. Used by `read` to
1000 * skip account-wide zone/route fan-out when the surface is unmanaged (#926).
1001 *
1002 * @internal exported for unit testing.
1003 */
1004export const shouldObserveWorkerRoutes = (
1005 olds: Pick<WorkerProps, "routes"> | undefined,
1006 output: Pick<Worker["Attributes"], "routes"> | undefined,
1007): boolean => olds?.routes !== undefined || (output?.routes?.length ?? 0) > 0;
1008
1009/**
1010 * Cron triggers Alchemy is responsible for on this Worker. Used by `read` to
1011 * skip `getScriptSchedule` when the surface is unmanaged (#926). Effect-native
1012 * `cron()` bindings persist into `output.crons` after the first reconcile, so
1013 * subsequent reads still observe them.
1014 *
1015 * @internal exported for unit testing.
1016 */
1017export const shouldObserveWorkerCrons = (
1018 olds: Pick<WorkerProps, "crons"> | undefined,
1019 output: Pick<Worker["Attributes"], "crons"> | undefined,
1020): boolean => olds?.crons !== undefined || (output?.crons?.length ?? 0) > 0;
1021
1022/**
1023 * Optional override for the account's stable `workers.dev` subdomain
1024 * (`<subdomain>` in `https://<script>.<subdomain>.workers.dev`). When set,
1025 * Worker URL construction skips `GET /accounts/{id}/workers/subdomain`.
1026 */
1027const CLOUDFLARE_WORKERS_SUBDOMAIN = Config.String(
1028 "CLOUDFLARE_WORKERS_SUBDOMAIN",
1029).pipe(
1030 Config.map((value) => value.trim()),
1031 Config.option,
1032);
1033
1034const WORKERS_SUBDOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
1035
1036/**
1037 * Max concurrent `GET /zones/{id}/workers/routes` calls when observing the
1038 * routes attached to a Worker. Route listing is per-zone, so this fans out
1039 * with the number of zones the Worker has routes in; unbounded fan-out here
1040 * is what trips Cloudflare account-level 429 / code 971 throttling (#926).
1041 */
1042const WORKER_ROUTE_LIST_CONCURRENCY = 8;
1043
1044type MetadataHashValue =
1045 | string
1046 | number
1047 | boolean
1048 | null
1049 | undefined
1050 | readonly MetadataHashValue[]
1051 | { readonly [key: string]: MetadataHashValue };
1052
1053/**
1054 * Deeply materialize an arbitrary value into a JSON-stable shape for hashing:
1055 * unwrap `Redacted` secrets by value, ISO-stringify `Date`s, keep plain
1056 * objects/arrays/primitives, and drop Effects, functions, `undefined`, and
1057 * class instances (which don't round-trip through `JSON.stringify`). Redacted
1058 * values contribute by value, not by reference identity, so two
1059 * independently-constructed secrets with the same contents hash identically.
1060 *
1061 * Effects are dropped, never executed: evaluating one here may require
1062 * plan-phase context that is not available inside lifecycle operations.
1063 */
1064const resolveMetadataHashValue = (
1065 value: unknown,
1066): Effect.Effect<MetadataHashValue> =>
1067 Effect.gen(function* () {
1068 if (Effect.isEffect(value)) {
1069 return undefined;
1070 }
1071 const resolved = Redacted.isRedacted(value) ? Redacted.value(value) : value;
1072
1073 if (
1074 resolved === null ||
1075 Predicate.isString(resolved) ||
1076 Predicate.isNumber(resolved) ||
1077 Predicate.isBoolean(resolved)
1078 ) {
1079 return resolved;
1080 }
1081 if (resolved === undefined || Predicate.isFunction(resolved)) {
1082 return undefined;
1083 }
1084 if (Predicate.isDate(resolved)) {
1085 return resolved.toISOString();
1086 }
1087 if (Array.isArray(resolved)) {
1088 return yield* Effect.all(
1089 resolved.map((item) => resolveMetadataHashValue(item)),
1090 { concurrency: "unbounded" },
1091 );
1092 }
1093 if (Predicate.isObject(resolved)) {
1094 // Only plain objects round-trip predictably. A class instance would
1095 // serialize to `{}` (or throw), so drop it rather than hash a lie.
1096 const prototype = Object.getPrototypeOf(resolved);
1097 if (prototype !== Object.prototype && prototype !== null) {
1098 return undefined;
1099 }
1100 const entries = yield* Effect.all(
1101 Object.entries(resolved).map(([key, nested]) =>
1102 resolveMetadataHashValue(nested).pipe(
1103 Effect.map(
1104 (materializedNested) => [key, materializedNested] as const,
1105 ),
1106 ),
1107 ),
1108 { concurrency: "unbounded" },
1109 );
1110 return Object.fromEntries(
1111 entries.filter(([, nested]) => nested !== undefined),
1112 );
1113 }
1114 return undefined;
1115 });
1116
1117/**
1118 * The deploy-time metadata surface of a Worker whose changes must trigger an
1119 * update but that never touch the bundle/vite/asset-content hashes:
1120 * compatibility, env/bindings, asset routing config, cache, limits, logpush,
1121 * observability, placement, subdomain, and tags. See #745.
1122 */
1123interface WorkerMetadataHashInput {
1124 readonly props: WorkerProps;
1125 readonly bindings: readonly ResourceBinding<Worker["Binding"]>[];
1126 readonly accountId: string;
1127 readonly stack: { readonly name: string; readonly stage: string };
1128 /**
1129 * The resolved URL a `Worker.URL` (`self_url`) binding lowers into.
1130 * Included in the hash so external URL changes (an account-subdomain
1131 * rename, a custom-domain reorder) redeploy the Worker with the fresh
1132 * value even though props and binding data are unchanged.
1133 */
1134 readonly selfUrl?: string;
1135}
1136
1137// The asset router config the resource declares (htmlHandling,
1138// notFoundHandling, ...), minus the local `directory` path (machine-specific,
1139// would break hash stability across machines) and the precomputed `hash`
1140// (already compared via `output.hash.assets`). A bare string `assets` is just
1141// a directory path, so it contributes nothing here.
1142const workerAssetConfigForHash = (assets: WorkerProps["assets"]) => {
1143 if (!assets || typeof assets === "string") {
1144 return undefined;
1145 }
1146 const { directory: _directory, ...config } = assets;
1147 if (Predicate.hasProperty(config, "hash")) {
1148 const { hash: _hash, ...configWithoutHash } = config;
1149 return configWithoutHash;
1150 }
1151 return config;
1152};
1153
1154/**
1155 * Hash a Worker's deploy-time metadata surface so metadata-only edits are
1156 * detected by the diff (#745). Previously the update decision compared only
1157 * the bundle/vite/asset-content hashes, so a change to e.g. a compatibility
1158 * flag or observability config planned as a noop and silently never deployed.
1159 */
1160const resolveWorkerMetadataHash = ({
1161 props,
1162 bindings,
1163 accountId,
1164 stack,
1165 selfUrl,
1166}: WorkerMetadataHashInput): Effect.Effect<string> =>
1167 resolveMetadataHashValue({
1168 accountId,
1169 selfUrl,
1170 stack: { name: stack.name, stage: stack.stage },
1171 compatibility: getCompatibility(props),
1172 // Every `env` entry is lowered into binding data by
1173 // `bindWorkerAsyncBindings`, including literals and VITE_ values. Hash only
1174 // that canonical wire representation. Resource-backed env values can be
1175 // materialized as different attribute projections between reconcile and a
1176 // later plan; hashing props.env as well would turn those irrelevant shape
1177 // differences into perpetual updates.
1178 bindings: bindings.map((binding) => ({
1179 sid: binding.sid,
1180 data: binding.data,
1181 })),
1182 assets: workerAssetConfigForHash(props.assets),
1183 cache: props.cache,
1184 limits: props.limits,
1185 logpush: props.logpush,
1186 observability: props.observability,
1187 placement: props.placement,
1188 // The source descriptor is plain JSON data; hashing it means
1189 // switching providers (or changing provider options) triggers an
1190 // update even when no hash slot the new source computes differs.
1191 source: props.source,
1192 tags: props.tags,
1193 // Reduce each consumer to its script name: a referenced Worker's other
1194 // attributes (hash, url, ...) change on every consumer deploy, which
1195 // would spuriously re-deploy this producer.
1196 tailConsumers: resolveTailConsumers(props.tailConsumers),
1197 streamingTailConsumers: resolveTailConsumers(props.streamingTailConsumers),
1198 workersDev: resolveWorkersDev(props.workersDev),
1199 // Reduce `version.parent` to the parent's script name: the resolved
1200 // parent is a full attributes object whose *other* fields (hash, url,
1201 // ...) change on every parent deploy, which would spuriously re-version
1202 // this Worker even though nothing about the version itself changed.
1203 version: props.version
1204 ? {
1205 parent: resolveVersionParentName(props.version),
1206 traffic: props.version.traffic,
1207 alias: props.version.alias,
1208 affinity: props.version.affinity,
1209 message: props.version.message,
1210 tag: props.version.tag,
1211 }
1212 : undefined,
1213 preview: props.preview
1214 ? {
1215 of: resolvePreviewParentName(props.preview),
1216 name: props.preview.name,
1217 message: props.preview.message,
1218 tag: props.preview.tag,
1219 }
1220 : undefined,
1221 }).pipe(Effect.flatMap((metadata) => sha256Object({ metadata })));
1222
1223export const WorkerProvider = () =>
1224 ProviderLayer.dual(Worker, {
1225 live: () => LiveWorkerProvider(),
1226 // The local runtime deps (workerd, WorkerProxy, LocalRuntimeState)
1227 // compose INTO the local variant so a live deploy only constructs them
1228 // if the local provider is actually demanded (e.g. deleting a local
1229 // dev worker's state row). See ProviderLayer.dual.
1230 local: () =>
1231 LocalWorkerProvider().pipe(Layer.provide(localRuntimeServices())),
1232 });
1233
1234export const LiveWorkerProvider = () =>
1235 Provider.effect(
1236 Worker,
1237 Effect.gen(function* () {
1238 const path = yield* Path.Path;
1239
1240 const bundler = yield* WorkerBundle;
1241 const stack = yield* Stack;
1242 const dotAlchemy = yield* dotAlchemyDirectory;
1243
1244 // const createScriptSubdomain = yield* workers.createScriptSubdomain;
1245 // const deleteScript = yield* workers.deleteScript;
1246 // const getScriptSubdomain = yield* workers.getScriptSubdomain;
1247 // const getScriptSchedule = yield* workers.getScriptSchedule;
1248 // const getScriptSettings = yield* workers.getScriptScriptAndVersionSetting;
1249 // const getSubdomain = yield* workers.getSubdomain;
1250 // const putScript = yield* workers.putScript;
1251 // const putScriptSchedule = yield* workers.putScriptSchedule;
1252 // const putDomain = yield* workers.putDomain;
1253 // const listDomains = yield* workers.listDomains;
1254 // const deleteDomain = yield* workers.deleteDomain;
1255 // const listZones = yield* zones.listZones;
1256 const telemetry = yield* CloudflareLogs;
1257
1258 // Account subdomain is invariant for the life of a provider layer —
1259 // memoize so N Workers (or repeated plan/deploy reads) don't each hit
1260 // GET /accounts/{id}/workers/subdomain and trip Cloudflare's 429/971
1261 // throttle (#926). `CLOUDFLARE_WORKERS_SUBDOMAIN` bypasses the lookup
1262 // entirely when the operator already knows the value.
1263 const getAccountSubdomain = yield* cachedFunction((accountId: string) =>
1264 Effect.gen(function* () {
1265 const configured = yield* CLOUDFLARE_WORKERS_SUBDOMAIN;
1266 if (Option.isSome(configured) && configured.value !== "") {
1267 if (!WORKERS_SUBDOMAIN_PATTERN.test(configured.value)) {
1268 return yield* Effect.die(
1269 new Error(
1270 `Invalid CLOUDFLARE_WORKERS_SUBDOMAIN "${configured.value}": ` +
1271 "expected a workers.dev subdomain label (lowercase letters, digits, hyphens)",
1272 ),
1273 );
1274 }
1275 return configured.value;
1276 }
1277 const result = yield* workers.getSubdomain({ accountId });
1278 return result.subdomain;
1279 }),
1280 );
1281
1282 // Converge the script's workers.dev settings via `POST /subdomain`.
1283 // The two toggles are independent on the Cloudflare API: `enabled`
1284 // drives the stable `<name>.<account>.workers.dev` URL and
1285 // `previews_enabled` the per-version preview URLs.
1286 const setWorkerSubdomain = Effect.fn(function* (
1287 name: string,
1288 desired: ResolvedWorkersDev,
1289 ) {
1290 const { accountId } = yield* yield* CloudflareEnvironment;
1291 return yield* workers.createScriptSubdomain({
1292 accountId,
1293 scriptName: name,
1294 enabled: desired.enabled,
1295 previewsEnabled: desired.previewsEnabled,
1296 });
1297 });
1298
1299 const normalizeCrons = (crons: string[] | undefined): string[] =>
1300 Array.from(new Set(crons ?? []));
1301
1302 const hasSelfUrlBinding = (
1303 bindings: readonly ResourceBinding<Worker["Binding"]>[],
1304 ) =>
1305 bindings.some((b) =>
1306 (b.data.bindings ?? []).some((item) => item.type === "self_url"),
1307 );
1308
1309 // Resolve the URL this Worker will be served at — the same formula that
1310 // produces the `url` attribute (first custom domain in user order, else
1311 // the workers.dev URL) — usable BEFORE the script upload, so `Worker.URL`
1312 // bindings can be lowered into plain_text bindings and VITE_*-prefixed
1313 // env entries can be inlined into the client bundle at build time.
1314 const resolveSelfUrl = Effect.fn(function* (
1315 name: string,
1316 props: WorkerProps,
1317 accountId: string,
1318 ) {
1319 const domain = yield* resolveWorkerDomain(props.domain);
1320 if (domain) {
1321 return `https://${domain.name}`;
1322 }
1323 if (!resolveWorkersDev(props.workersDev).enabled) {
1324 return yield* Effect.die(
1325 `Worker "${name}" binds its own URL (Worker.URL) but has none: the stable workers.dev URL is disabled (workersDev) and no custom domain is configured.`,
1326 );
1327 }
1328 return `https://${name}.${yield* getAccountSubdomain(accountId)}.workers.dev`;
1329 });
1330
1331 const getWorkerCrons = Effect.fn(function* (scriptName: string) {
1332 const { accountId } = yield* yield* CloudflareEnvironment;
1333 return yield* workers
1334 .getScriptSchedule({
1335 accountId,
1336 scriptName,
1337 })
1338 .pipe(
1339 Effect.map((response) =>
1340 normalizeCrons(
1341 response.schedules.map((schedule) => schedule.cron),
1342 ),
1343 ),
1344 Effect.catchTag("WorkerNotFound", () => Effect.succeed([])),
1345 );
1346 });
1347
1348 const reconcileCrons = (
1349 scriptName: string,
1350 desired: string[],
1351 previous: string[],
1352 session: ScopedPlanStatusSession,
1353 ) =>
1354 Effect.gen(function* () {
1355 const { accountId } = yield* yield* CloudflareEnvironment;
1356 const live = yield* getWorkerCrons(scriptName);
1357 const desiredSorted = [...desired].sort();
1358 const liveSorted = [...live].sort();
1359 const changed =
1360 desiredSorted.length !== liveSorted.length ||
1361 desiredSorted.some((cron, index) => cron !== liveSorted[index]);
1362
1363 if (!changed) return live;
1364
1365 if (desired.length > 0 || previous.length > 0 || live.length > 0) {
1366 yield* session.note(
1367 `Reconciling Cron Triggers (${desired.length}) ...`,
1368 { kind: "status" },
1369 );
1370 }
1371
1372 const result = yield* workers
1373 .putScriptSchedule({
1374 accountId,
1375 scriptName,
1376 body: desired.map((cron) => ({ cron })),
1377 })
1378 .pipe(
1379 Effect.retry({
1380 while: (error) => error._tag === "WorkerNotFound",
1381 schedule: Schedule.max([
1382 Schedule.exponential(200),
1383 Schedule.recurs(15),
1384 ]),
1385 }),
1386 );
1387 return normalizeCrons(
1388 result.schedules.map((schedule) => schedule.cron),
1389 );
1390 });
1391
1392 /**
1393 * Resolve a hostname to a Cloudflare zone id. An explicit pin
1394 * (`zoneId` / zone name / `{ zoneId }`) wins. Otherwise look the
1395 * hostname up with {@link resolveZoneId} (`GET /zones?name=` per
1396 * parent label) — never by listing the account's first page of zones.
1397 */
1398 const inferZoneIdForHostname = (
1399 hostname: string,
1400 zoneCache: Map<string, string>,
1401 zone?: ZoneReference,
1402 ) =>
1403 Effect.gen(function* () {
1404 const cacheKey =
1405 zone === undefined
1406 ? hostname
1407 : `${hostname}:${typeof zone === "string" ? zone : zone.zoneId}`;
1408 const cached = zoneCache.get(cacheKey);
1409 if (cached) return cached;
1410 const { accountId } = yield* yield* CloudflareEnvironment;
1411 const zoneId = yield* resolveZoneId({ accountId, zone, hostname });
1412 zoneCache.set(cacheKey, zoneId);
1413 return zoneId;
1414 });
1415
1416 const reconcileDomains = (
1417 scriptName: string,
1418 desired: string[],
1419 zone?: ZoneReference,
1420 previewsEnabled?: boolean,
1421 ) =>
1422 Effect.gen(function* () {
1423 const { accountId } = yield* yield* CloudflareEnvironment;
1424 // Always query the live state of domains attached to *this*
1425 // Worker rather than trusting `_previous` from local state.
1426 // State may have been wiped, populated by another machine, or
1427 // simply be out of date. Without this we PUT domains that are
1428 // already registered to this same Worker and Cloudflare
1429 // returns a confusing "hostname already in use" error.
1430 const liveAll = yield* workers
1431 .listDomains({
1432 accountId,
1433 service: scriptName,
1434 })
1435 .pipe(
1436 Effect.map((r) =>
1437 (r.result ?? []).flatMap((d) =>
1438 d.id && d.hostname && d.zoneId
1439 ? [
1440 {
1441 id: d.id,
1442 hostname: d.hostname,
1443 zoneId: d.zoneId,
1444 service: d.service ?? undefined,
1445 previewsEnabled: d.previewsEnabled === true,
1446 },
1447 ]
1448 : [],
1449 ),
1450 ),
1451 Effect.catch(() => Effect.succeed([])),
1452 );
1453
1454 const desiredSet = new Set(desired);
1455 const liveByHostname = new Map(liveAll.map((d) => [d.hostname, d]));
1456
1457 // Detach what's no longer wanted. Use the live list so we
1458 // don't try to delete domains we no longer track.
1459 const toRemove = liveAll.filter((d) => !desiredSet.has(d.hostname));
1460 yield* Effect.all(
1461 toRemove.map((d) =>
1462 workers
1463 .deleteDomain({ accountId, domainId: d.id })
1464 .pipe(Effect.catchTag("DomainNotFound", () => Effect.void)),
1465 ),
1466 { concurrency: "unbounded" },
1467 );
1468
1469 if (desired.length === 0) return [];
1470
1471 const zoneCache = new Map<string, string>();
1472
1473 // Attach `hostname` to this Worker. Skip the PUT entirely if
1474 // the hostname is already attached to *this* Worker — that's a
1475 // no-op for Cloudflare and avoids the "already in use" 409.
1476 // If it's attached to a *different* Worker, refuse with a
1477 // clear message rather than silently re-routing traffic.
1478 const attachDomain = Effect.fn(function* (hostname: string) {
1479 const live = liveByHostname.get(hostname);
1480 const desiredZoneId =
1481 zone === undefined
1482 ? undefined
1483 : yield* inferZoneIdForHostname(hostname, zoneCache, zone);
1484 if (
1485 live &&
1486 !shouldRecreateWorkerDomainAttachment(
1487 live.zoneId,
1488 desiredZoneId,
1489 ) &&
1490 live.previewsEnabled === (previewsEnabled === true)
1491 ) {
1492 return {
1493 hostname: live.hostname,
1494 id: live.id,
1495 zoneId: live.zoneId,
1496 };
1497 }
1498
1499 if (live) {
1500 // Cloudflare cannot change the zone of an existing custom
1501 // domain in place. Delete only this still-desired attachment,
1502 // then recreate it below with the explicitly requested zone.
1503 yield* workers
1504 .deleteDomain({ accountId, domainId: live.id })
1505 .pipe(Effect.catchTag("DomainNotFound", () => Effect.void));
1506 }
1507
1508 // Not attached to this Worker — but it could still belong
1509 // to another Worker. Check before we try to PUT so we can
1510 // emit a helpful error instead of the raw 409.
1511 const otherOwner = yield* workers
1512 .listDomains({
1513 accountId,
1514 hostname,
1515 })
1516 .pipe(
1517 Effect.map((r) =>
1518 (r.result ?? []).find(
1519 (d) => d.hostname === hostname && d.service !== scriptName,
1520 ),
1521 ),
1522 Effect.catch(() => Effect.succeed(undefined)),
1523 );
1524 if (otherOwner?.id) {
1525 return yield* Effect.die(
1526 new Error(
1527 `Cannot attach hostname '${hostname}' to Worker '${scriptName}': ` +
1528 `it is already attached to Worker '${otherOwner.service ?? "<unknown>"}'. ` +
1529 `Detach it from that Worker first, or pick a different hostname.`,
1530 ),
1531 );
1532 }
1533
1534 const zoneId =
1535 desiredZoneId ??
1536 (yield* inferZoneIdForHostname(hostname, zoneCache));
1537 // Same eventual-consistency window as `setWorkerSubdomain`:
1538 // PUT /accounts/.../workers/domains right after `putScript`
1539 // can return `WorkerNotFound` until Cloudflare's script
1540 // registry has propagated. Retry on that specific tag.
1541 const res = yield* workers
1542 .putDomain({
1543 accountId,
1544 hostname,
1545 service: scriptName,
1546 zoneId,
1547 // Private-beta field — only send it when the user opted
1548 // into custom-domain Previews. Omitting it keeps the
1549 // public PUT /workers/domains body unchanged for everyone
1550 // else.
1551 ...(previewsEnabled === true ? { previewsEnabled: true } : {}),
1552 })
1553 .pipe(
1554 Effect.retry({
1555 while: (error) => error._tag === "WorkerNotFound",
1556 schedule: Schedule.max([
1557 Schedule.exponential(200),
1558 Schedule.recurs(15),
1559 ]),
1560 }),
1561 Effect.retry({
1562 while: (error) => error._tag === "HostnameAlreadyInUse",
1563 schedule: workerDomainConflictSchedule,
1564 }),
1565 );
1566 return {
1567 hostname,
1568 id: res.id ?? "",
1569 zoneId: res.zoneId ?? zoneId,
1570 };
1571 });
1572
1573 const applied = yield* Effect.all(desired.map(attachDomain), {
1574 concurrency: "unbounded",
1575 });
1576 return applied;
1577 });
1578
1579 // The preview URL of the Worker's *current* version:
1580 // `https://<version-prefix>-<name>.<subdomain>.workers.dev`, where the
1581 // prefix is the first 8 characters of the version id (read from the
1582 // latest deployment's majority version). Resolves to `undefined` when
1583 // no deployment is visible yet — callers treat the preview URL as
1584 // best-effort. Only consulted in previews-only mode
1585 // (`workersDev: { enabled: false, previewsEnabled: true }`), where it
1586 // is the Worker's only workers.dev surface.
1587 const getPreviewUrl = Effect.fn(function* (
1588 scriptName: string,
1589 accountSubdomain: string,
1590 ) {
1591 const { accountId } = yield* yield* CloudflareEnvironment;
1592 const deployments = yield* workers
1593 .listScriptDeployments({ accountId, scriptName })
1594 .pipe(
1595 Effect.map((response) => response.deployments ?? []),
1596 Effect.catch(() => Effect.succeed([])),
1597 );
1598 const latest = [...deployments].sort((a, b) =>
1599 b.createdOn.localeCompare(a.createdOn),
1600 )[0];
1601 const version = latest?.versions?.reduce(
1602 (max, candidate) =>
1603 max === undefined || candidate.percentage > max.percentage
1604 ? candidate
1605 : max,
1606 undefined as { percentage: number; versionId: string } | undefined,
1607 );
1608 if (!version?.versionId) return undefined;
1609 return `https://${version.versionId.slice(0, 8)}-${scriptName}.${accountSubdomain}.workers.dev`;
1610 });
1611
1612 /**
1613 * Assemble every URL a deployed, script-owning Worker serves at, most
1614 * significant first — the first entry becomes the `url` attribute:
1615 *
1616 * 1. `https://<domain.name>` — the canonical custom domain
1617 * 2. aliases, in declared order
1618 * 3. the stable workers.dev URL (`workersDev.enabled`)
1619 * 4. preview URLs of the version this deploy uploaded (gradual
1620 * rollouts), or — in previews-only mode — the current version's
1621 * preview URL, which is then the only workers.dev surface
1622 *
1623 * Redirect hostnames never appear: they don't serve the Worker.
1624 * (Version workers don't use this — their `urls` are their preview
1625 * URLs, assembled in `putWorkerVersion`.)
1626 */
1627 const computeWorkerUrls = Effect.fn(function* (params: {
1628 scriptName: string;
1629 workersDev: ResolvedWorkersDev;
1630 domain: ResolvedWorkerDomain | undefined;
1631 /** the version uploaded by this deploy (self-rollout), if any */
1632 uploadedVersionId?: string;
1633 /** user-provided `version.alias` attached to the uploaded version */
1634 uploadedVersionAlias?: string;
1635 }) {
1636 const { workersDev, domain, scriptName } = params;
1637 const urls: string[] = [];
1638 if (domain) {
1639 urls.push(
1640 `https://${domain.name}`,
1641 ...domain.aliases.map((hostname) => `https://${hostname}`),
1642 );
1643 }
1644 if (workersDev.enabled || workersDev.previewsEnabled) {
1645 const { accountId } = yield* yield* CloudflareEnvironment;
1646 const accountSubdomain = yield* getAccountSubdomain(accountId);
1647 if (workersDev.enabled) {
1648 urls.push(`https://${scriptName}.${accountSubdomain}.workers.dev`);
1649 }
1650 if (workersDev.previewsEnabled) {
1651 if (params.uploadedVersionId !== undefined) {
1652 if (params.uploadedVersionAlias !== undefined) {
1653 urls.push(
1654 `https://${params.uploadedVersionAlias}-${scriptName}.${accountSubdomain}.workers.dev`,
1655 );
1656 }
1657 urls.push(
1658 `https://${params.uploadedVersionId.split("-")[0]}-${scriptName}.${accountSubdomain}.workers.dev`,
1659 );
1660 } else if (!workersDev.enabled) {
1661 const previewUrl = yield* getPreviewUrl(
1662 scriptName,
1663 accountSubdomain,
1664 );
1665 if (previewUrl) urls.push(previewUrl);
1666 }
1667 }
1668 }
1669 return urls;
1670 });
1671
1672 /**
1673 * Converge the redirect rules for a Worker's `domain.redirects` in
1674 * each affected zone's `http_request_dynamic_redirect` phase
1675 * entrypoint. Rules are identified by a
1676 * `alchemy:worker:<script>:redirect:<host>` description; only our own
1677 * rules are added/removed — every other rule in the shared entrypoint
1678 * ruleset passes through untouched. Zones that previously held our
1679 * rules but no longer should (removed redirects, removed domain) are
1680 * cleaned the same way.
1681 */
1682 const reconcileRedirectRules = Effect.fn(function* (params: {
1683 scriptName: string;
1684 domain: ResolvedWorkerDomain | undefined;
1685 /** hostname → zoneId for every currently-attached custom domain */
1686 zoneIdByHostname: ReadonlyMap<string, string>;
1687 /** redirect hostnames from previous state (for zone cleanup) */
1688 previousRedirects: readonly string[];
1689 }) {
1690 const prefix = `alchemy:worker:${params.scriptName}:redirect:`;
1691 const desired = params.domain?.redirects ?? [];
1692 const targetName = params.domain?.name;
1693 // Zones to touch: every zone hosting a desired redirect, plus every
1694 // zone that hosted one before (so removals converge). Hostnames
1695 // whose zone we can't resolve (e.g. the domain was already
1696 // detached) are skipped — their rules become unreachable dead
1697 // config at worst, never a failed destroy.
1698 const zoneIds = new Set<string>();
1699 for (const hostname of [...desired, ...params.previousRedirects]) {
1700 const zoneId = params.zoneIdByHostname.get(hostname);
1701 if (zoneId) zoneIds.add(zoneId);
1702 }
1703 if (zoneIds.size === 0) return;
1704 for (const zoneId of zoneIds) {
1705 const entrypoint = yield* rulesets
1706 .getPhasForZone({
1707 zoneId,
1708 rulesetPhase: "http_request_dynamic_redirect",
1709 })
1710 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1711 const existingRules = entrypoint?.rules ?? [];
1712 const ourDesired = desired
1713 .filter(
1714 (hostname) => params.zoneIdByHostname.get(hostname) === zoneId,
1715 )
1716 .map((hostname) => ({
1717 action: "redirect" as const,
1718 expression: `http.host eq "${hostname}"`,
1719 description: `${prefix}${hostname}`,
1720 enabled: true,
1721 actionParameters: {
1722 fromValue: {
1723 statusCode: 301,
1724 preserveQueryString: true,
1725 targetUrl: {
1726 expression: `concat("https://${targetName}", http.request.uri.path)`,
1727 },
1728 },
1729 },
1730 }));
1731 const isOurs = (rule: { description?: string | null }) =>
1732 (rule.description ?? "").startsWith(prefix);
1733 const ourExisting = existingRules.filter(isOurs);
1734 const converged =
1735 ourExisting.length === ourDesired.length &&
1736 ourDesired.every((rule) =>
1737 ourExisting.some(
1738 (existing) =>
1739 existing.description === rule.description &&
1740 existing.expression === rule.expression,
1741 ),
1742 );
1743 if (converged) continue;
1744 // Pass foreign rules through untouched (minus the read-only
1745 // fields the PUT schema doesn't accept) and replace our own set.
1746 const foreign = existingRules
1747 .filter((rule) => !isOurs(rule))
1748 .map((rule) => {
1749 const {
1750 lastUpdated: _lastUpdated,
1751 version: _version,
1752 ...rest
1753 } = rule as Record<string, unknown> & {
1754 lastUpdated?: string;
1755 version?: string;
1756 };
1757 return rest;
1758 });
1759 yield* rulesets.putPhasForZone({
1760 zoneId,
1761 rulesetPhase: "http_request_dynamic_redirect",
1762 rules: [...foreign, ...ourDesired] as PutZoneRedirectRules,
1763 });
1764 }
1765 });
1766
1767 /**
1768 * Converge the version-affinity transform rules (`version.affinity`)
1769 * in each serving zone's `http_request_late_transform` phase
1770 * entrypoint: `rewrite` rules that fill the
1771 * `Cloudflare-Workers-Version-Key` header from the configured source,
1772 * scoped to the Worker's hostnames in that zone. Rules are identified
1773 * by an `alchemy:worker:<script>:affinity` description prefix; only
1774 * our own rules are added/updated/removed — every other rule in the
1775 * shared entrypoint passes through untouched. Zones that previously
1776 * held our rules but no longer should (affinity removed, zone no
1777 * longer served) are cleaned the same way. Returns the zone ids now
1778 * holding rules, for persistence in `affinityZoneIds`.
1779 */
1780 const reconcileAffinityRules = Effect.fn(function* (params: {
1781 scriptName: string;
1782 /** `undefined` removes every rule (affinity removed / delete). */
1783 desired:
1784 | {
1785 affinity: ResolvedVersionAffinity;
1786 hostsByZone: ReadonlyMap<string, AffinityZoneHost[]>;
1787 }
1788 | undefined;
1789 /** zone ids holding rules per previous state (for cleanup). */
1790 previousZoneIds: readonly string[];
1791 }) {
1792 const prefix = affinityRulePrefix(params.scriptName);
1793 const zoneIds = new Set([
1794 ...(params.desired?.hostsByZone.keys() ?? []),
1795 ...params.previousZoneIds,
1796 ]);
1797 for (const zoneId of zoneIds) {
1798 const hosts = params.desired?.hostsByZone.get(zoneId);
1799 const ourDesired =
1800 params.desired !== undefined && hosts !== undefined
1801 ? buildAffinityZoneRules(
1802 params.scriptName,
1803 params.desired.affinity,
1804 hosts,
1805 )
1806 : [];
1807 const entrypoint = yield* rulesets
1808 .getPhasForZone({
1809 zoneId,
1810 rulesetPhase: "http_request_late_transform",
1811 })
1812 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1813 const existingRules = entrypoint?.rules ?? [];
1814 const isOurs = (rule: { description?: string | null }) =>
1815 (rule.description ?? "").startsWith(prefix);
1816 // The header-value expression of an existing rewrite rule, for
1817 // convergence comparison. `headers` is an untyped wire record:
1818 // `{ "<Header-Name>": { operation, expression | value } }`.
1819 const existingValue = (rule: (typeof existingRules)[number]) => {
1820 const headers =
1821 "actionParameters" in rule
1822 ? (
1823 rule.actionParameters as
1824 | { headers?: Record<string, unknown> | null }
1825 | null
1826 | undefined
1827 )?.headers
1828 : undefined;
1829 const header = headers?.[AFFINITY_HEADER] as
1830 | { expression?: unknown }
1831 | undefined;
1832 return typeof header?.expression === "string"
1833 ? header.expression
1834 : undefined;
1835 };
1836 const ourExisting = existingRules.filter(isOurs);
1837 const converged =
1838 ourExisting.length === ourDesired.length &&
1839 ourDesired.every((rule) =>
1840 ourExisting.some(
1841 (existing) =>
1842 existing.description === rule.description &&
1843 existing.expression === rule.expression &&
1844 existingValue(existing) === rule.value,
1845 ),
1846 );
1847 if (converged) continue;
1848 // Pass foreign rules through untouched (minus the read-only
1849 // fields the PUT schema doesn't accept) and replace our own set.
1850 const foreign = existingRules
1851 .filter((rule) => !isOurs(rule))
1852 .map((rule) => {
1853 const {
1854 lastUpdated: _lastUpdated,
1855 version: _version,
1856 ...rest
1857 } = rule as Record<string, unknown> & {
1858 lastUpdated?: string;
1859 version?: string;
1860 };
1861 return rest;
1862 });
1863 yield* rulesets.putPhasForZone({
1864 zoneId,
1865 rulesetPhase: "http_request_late_transform",
1866 rules: [
1867 ...foreign,
1868 ...ourDesired.map((rule) => ({
1869 action: "rewrite" as const,
1870 description: rule.description,
1871 enabled: true,
1872 expression: rule.expression,
1873 actionParameters: {
1874 headers: {
1875 [AFFINITY_HEADER]: {
1876 operation: "set",
1877 expression: rule.value,
1878 },
1879 },
1880 },
1881 })),
1882 ] as PutZoneRedirectRules,
1883 });
1884 }
1885 return [...(params.desired?.hostsByZone.keys() ?? [])].sort();
1886 });
1887
1888 type NormalizedWorkerRoute = {
1889 pattern: string;
1890 zoneId: string;
1891 };
1892
1893 const routeKey = (route: { pattern: string; zoneId: string }) =>
1894 `${route.zoneId}:${route.pattern}`;
1895
1896 // Derive a concrete hostname inside the zone from a route pattern so
1897 // zone inference can walk the DNS label hierarchy. A wildcard label
1898 // (`*.example.com/*`) is replaced with a stand-in label — only the
1899 // parent labels matter for finding the zone.
1900 const hostnameFromPattern = (pattern: string): string => {
1901 const hostPart = pattern.split("/")[0] ?? pattern;
1902 return hostPart.startsWith("*.")
1903 ? `routes.${hostPart.slice(2)}`
1904 : hostPart;
1905 };
1906
1907 // Resolve each route's zone to a concrete zone id: an explicit
1908 // `zoneId` wins, then `zone` / `zoneName` via `resolveZoneId`, and
1909 // finally inference from the pattern's hostname. Duplicate
1910 // `(zoneId, pattern)` pairs are dropped — Cloudflare enforces one
1911 // route per pattern per zone.
1912 const normalizeRoutes = (routes: WorkerRouteConfig[] | undefined) =>
1913 Effect.gen(function* () {
1914 if (!routes?.length) return [] as NormalizedWorkerRoute[];
1915 const { accountId } = yield* yield* CloudflareEnvironment;
1916 const zoneCache = new Map<string, string>();
1917 const normalized: NormalizedWorkerRoute[] = [];
1918 const seen = new Set<string>();
1919 for (const route of routes) {
1920 const pattern = route.pattern.trim();
1921 const zoneId = route.zoneId
1922 ? route.zoneId
1923 : route.zone || route.zoneName
1924 ? yield* resolveZoneId({
1925 accountId,
1926 zone: route.zone ?? route.zoneName!,
1927 hostname: hostnameFromPattern(pattern),
1928 })
1929 : yield* inferZoneIdForHostname(
1930 hostnameFromPattern(pattern),
1931 zoneCache,
1932 );
1933 const key = routeKey({ pattern, zoneId });
1934 if (seen.has(key)) continue;
1935 seen.add(key);
1936 normalized.push({ pattern, zoneId });
1937 }
1938 return normalized;
1939 });
1940
1941 // List the routes attached to `scriptName` across the given zones.
1942 // Routes without an id/pattern or owned by another script are
1943 // ignored. Zones the token can't read are skipped rather than
1944 // failing the whole listing.
1945 const listWorkerRoutesInZones = (
1946 scriptName: string,
1947 zoneIds: readonly string[],
1948 ) => {
1949 const uniqueZoneIds = Array.from(new Set(zoneIds));
1950 if (uniqueZoneIds.length === 0) {
1951 return Effect.succeed([] as Worker["Attributes"]["routes"]);
1952 }
1953
1954 const routesByZone = Effect.all(
1955 uniqueZoneIds.map((zoneId) =>
1956 workers.listRoutes({ zoneId }).pipe(
1957 Effect.map((response) =>
1958 (response.result ?? []).flatMap((route) =>
1959 route.id && route.pattern && route.script === scriptName
1960 ? [{ id: route.id, pattern: route.pattern, zoneId }]
1961 : [],
1962 ),
1963 ),
1964 Effect.catch(() => Effect.succeed([])),
1965 ),
1966 ),
1967 // Bounded: this issues one request per zone, so a Worker with routes
1968 // spread across many zones would otherwise burst the account's whole
1969 // API budget in a single tick (#926).
1970 { concurrency: WORKER_ROUTE_LIST_CONCURRENCY },
1971 );
1972
1973 return Effect.map(routesByZone, (routes) => routes.flat());
1974 };
1975
1976 // Observe the routes attached to `scriptName` in the zones Alchemy
1977 // already associates with this Worker.
1978 //
1979 // This used to enumerate every zone on the account and then list routes
1980 // in each one. Routes are zone-scoped with no account-level enumeration
1981 // API, so that costs O(zones) requests on *every* Worker read — on an
1982 // account with a few hundred zones a couple of plan/deploy cycles
1983 // exhausts Cloudflare's per-user API budget and unrelated calls start
1984 // coming back 429 / code 971 (#926).
1985 //
1986 // `reconcileRoutes` already scopes itself to the zones implied by
1987 // `desired ∪ previous` rather than sweeping the account, so `read` now
1988 // uses the same bounded zone set: the zones of the routes already
1989 // recorded in state. The blind spot this introduces — a route added out
1990 // of band in a zone this Worker has never had a route in — is the one
1991 // `reconcileRoutes` already has.
1992 const readWorkerRoutes = (
1993 scriptName: string,
1994 knownRoutes: Worker["Attributes"]["routes"] | undefined,
1995 ) =>
1996 listWorkerRoutesInZones(
1997 scriptName,
1998 (knownRoutes ?? []).map((route) => route.zoneId),
1999 );
2000
2001 // Converge the zone routes attached to `scriptName` to `desired`.
2002 // Observed cloud state (not `previous`) is the diff baseline —
2003 // `previous` only contributes zone ids so routes moved out of a zone
2004 // are still cleaned up after state loss or an interrupted apply.
2005 const reconcileRoutes = (
2006 scriptName: string,
2007 desired: NormalizedWorkerRoute[],
2008 previous: Worker["Attributes"]["routes"],
2009 ) =>
2010 Effect.gen(function* () {
2011 const zoneIds = Array.from(
2012 new Set([
2013 ...desired.map((route) => route.zoneId),
2014 ...previous.map((route) => route.zoneId),
2015 ]),
2016 );
2017 const liveAll = yield* listWorkerRoutesInZones(scriptName, zoneIds);
2018 const desiredKeys = new Set(desired.map(routeKey));
2019 const liveByKey = new Map(
2020 liveAll.map((route) => [routeKey(route), route]),
2021 );
2022
2023 const toRemove = liveAll.filter(
2024 (route) => !desiredKeys.has(routeKey(route)),
2025 );
2026 yield* Effect.all(
2027 toRemove.map((route) =>
2028 workers
2029 .deleteRoute({ zoneId: route.zoneId, routeId: route.id })
2030 .pipe(Effect.catchTag("RouteNotFound", () => Effect.void)),
2031 ),
2032 { concurrency: "unbounded" },
2033 );
2034
2035 if (desired.length === 0) return [];
2036
2037 const attachRoute = Effect.fn(function* (
2038 route: NormalizedWorkerRoute,
2039 ) {
2040 const existing = liveByKey.get(routeKey(route));
2041 if (existing) return existing;
2042
2043 const zoneRoutes = yield* workers
2044 .listRoutes({ zoneId: route.zoneId })
2045 .pipe(
2046 Effect.map((response) => response.result ?? []),
2047 Effect.catch(() => Effect.succeed([])),
2048 );
2049 const otherOwner = zoneRoutes.find(
2050 (candidate) =>
2051 candidate.pattern === route.pattern &&
2052 candidate.script &&
2053 candidate.script !== scriptName,
2054 );
2055 if (otherOwner) {
2056 return yield* Effect.die(
2057 new Error(
2058 `Cannot attach route '${route.pattern}' to Worker '${scriptName}': ` +
2059 `it is already attached to Worker '${otherOwner.script}'. ` +
2060 `Remove it from that Worker first, or pick a different pattern.`,
2061 ),
2062 );
2063 }
2064
2065 // A duplicate-pattern failure means another actor (or a crashed
2066 // previous reconcile) created the route between our observation
2067 // and now — re-list and converge if it points at this script.
2068 const created = yield* workers
2069 .createRoute({
2070 zoneId: route.zoneId,
2071 pattern: route.pattern,
2072 script: scriptName,
2073 })
2074 .pipe(
2075 // Same eventual-consistency window as `putDomain`: creating
2076 // a route right after `putScript` can race Cloudflare's
2077 // script registry, which rejects with code 10019 ("Cannot
2078 // configure a route for a Worker which does not exist") —
2079 // typed as `RouteScriptNotFound` via the createRoute patch.
2080 Effect.retry({
2081 while: (error) => error._tag === "RouteScriptNotFound",
2082 schedule: Schedule.max([
2083 Schedule.exponential(200),
2084 Schedule.recurs(15),
2085 ]),
2086 }),
2087 Effect.catchTag("InvalidRoute", (originalError) =>
2088 Effect.gen(function* () {
2089 const match = yield* workers
2090 .listRoutes({ zoneId: route.zoneId })
2091 .pipe(
2092 Effect.map((response) =>
2093 (response.result ?? []).find(
2094 (candidate) =>
2095 candidate.pattern === route.pattern &&
2096 candidate.script === scriptName,
2097 ),
2098 ),
2099 Effect.catch(() => Effect.succeed(undefined)),
2100 );
2101 if (!match?.id) {
2102 return yield* Effect.fail(originalError);
2103 }
2104 return { id: match.id, pattern: match.pattern };
2105 }),
2106 ),
2107 );
2108 return {
2109 id: created.id,
2110 pattern: created.pattern,
2111 zoneId: route.zoneId,
2112 };
2113 });
2114
2115 return yield* Effect.all(desired.map(attachRoute), {
2116 concurrency: "unbounded",
2117 });
2118 });
2119
2120 const createAlchemyWorkerTags = (id: string) => [
2121 `alchemy:stack:${stack.name}`,
2122 `alchemy:stage:${stack.stage}`,
2123 `alchemy:id:${id}`,
2124 ];
2125
2126 const hasAlchemyWorkerTags = (
2127 id: string,
2128 tags: readonly string[] | undefined,
2129 ) => {
2130 const actualTags = new Set(tags ?? []);
2131 return createAlchemyWorkerTags(id).every((tag) => actualTags.has(tag));
2132 };
2133
2134 /**
2135 * Resolve the source script of a declared Durable Object transfer for
2136 * a class that is new to `selfScriptName`. `sources` is the
2137 * `transferredFrom` host history: each entry names a former host either
2138 * by *physical script name* or by *Worker logical id* in this stack +
2139 * stage (matched via the `alchemy:id:`/stack/stage ownership tags).
2140 * The namespace is transferred from whichever listed host currently
2141 * holds a same-class namespace; when none does (fresh stage, or every
2142 * transfer already completed) this returns `undefined` and the caller
2143 * creates the class fresh — the declaration is inert and safe to keep.
2144 * More than one match (e.g. an orphaned script from a `name` change)
2145 * fails with {@link AmbiguousDurableObjectTransfer} rather than
2146 * guessing whose data to move.
2147 */
2148 const resolveTransferSource = Effect.fn(function* (params: {
2149 accountId: string;
2150 selfScriptName: string;
2151 logicalId: string;
2152 className: string;
2153 sources: readonly string[];
2154 observedNamespaces: readonly {
2155 id?: string | null;
2156 script: string;
2157 class: string;
2158 }[];
2159 }) {
2160 if (params.sources.length === 0) {
2161 return undefined;
2162 }
2163 const observedScripts = yield* workers.listScripts
2164 .items({ accountId: params.accountId })
2165 .pipe(Stream.runCollect);
2166 const logicalSources = new Set(
2167 observedScripts.flatMap((script) =>
2168 script.id != null &&
2169 params.sources.some((source) =>
2170 hasAlchemyWorkerTags(source, script.tags ?? []),
2171 )
2172 ? [script.id]
2173 : [],
2174 ),
2175 );
2176 // Direct names remain candidates even when both listings omit the script.
2177 const candidates = new Set([
2178 ...params.sources,
2179 ...logicalSources,
2180 ...params.observedNamespaces.flatMap((ns) =>
2181 ns.class === params.className ? [ns.script] : [],
2182 ),
2183 ]);
2184 const matched: string[] = [];
2185 for (const script of candidates) {
2186 if (script === params.selfScriptName) continue;
2187 // A same-class namespace alone does not identify a declared source.
2188 const knownSource =
2189 logicalSources.has(script) ||
2190 (params.sources.includes(script) &&
2191 (observedScripts.some((observed) => observed.id === script) ||
2192 params.observedNamespaces.some((ns) => ns.script === script)));
2193 const settings = yield* getScriptSettings(
2194 params.accountId,
2195 script,
2196 undefined,
2197 ).pipe(
2198 Effect.catchTag("WorkerNotFound", (error) =>
2199 knownSource ? Effect.fail(error) : Effect.succeed(undefined),
2200 ),
2201 Effect.catchTag("WorkerHasNoVersions", (error) =>
2202 knownSource || params.sources.includes(script)
2203 ? Effect.fail(error)
2204 : Effect.succeed(undefined),
2205 ),
2206 );
2207 if (
2208 settings === undefined ||
2209 (!params.sources.includes(script) &&
2210 !logicalSources.has(script) &&
2211 !params.sources.some((source) =>
2212 hasAlchemyWorkerTags(source, settings.tags ?? []),
2213 ))
2214 ) {
2215 continue;
2216 }
2217 const localBinding = settings.bindings?.find(
2218 (binding) =>
2219 binding.type === "durable_object_namespace" &&
2220 binding.className === params.className &&
2221 (binding.scriptName == null || binding.scriptName === script),
2222 );
2223 const namespaceId =
2224 localBinding?.type === "durable_object_namespace"
2225 ? (localBinding.namespaceId ?? undefined)
2226 : undefined;
2227 const findNamespace = (
2228 namespaces: typeof params.observedNamespaces,
2229 ) =>
2230 namespaces.find((ns) =>
2231 namespaceId === undefined
2232 ? ns.script === script && ns.class === params.className
2233 : ns.id === namespaceId,
2234 );
2235 let namespace = findNamespace(params.observedNamespaces);
2236 if (
2237 namespace === undefined &&
2238 (localBinding !== undefined ||
2239 Object.values(
2240 getDurableObjectTagMap(settings.tags ?? []),
2241 ).includes(params.className))
2242 ) {
2243 // A known source namespace must be located, not replaced with a fresh one.
2244 namespace = yield* listDurableObjectNamespaces(
2245 params.accountId,
2246 ).pipe(
2247 Effect.flatMap((namespaces) => {
2248 const namespace = findNamespace(namespaces);
2249 return namespace
2250 ? Effect.succeed(namespace)
2251 : Effect.fail(
2252 new MissingDurableObjects({
2253 scriptName: script,
2254 expected: [params.className],
2255 }),
2256 );
2257 }),
2258 Effect.retry({
2259 while: (error) => error._tag === "MissingDurableObjects",
2260 schedule: Schedule.spaced("2 seconds"),
2261 times: 5,
2262 }),
2263 );
2264 }
2265 if (
2266 namespace?.script === script &&
2267 namespace.class === params.className
2268 ) {
2269 matched.push(script);
2270 }
2271 }
2272 if (matched.length > 1) {
2273 return yield* Effect.fail(
2274 new AmbiguousDurableObjectTransfer({
2275 scriptName: params.selfScriptName,
2276 logicalId: params.logicalId,
2277 className: params.className,
2278 sources: matched,
2279 }),
2280 );
2281 }
2282 return matched[0];
2283 });
2284
2285 const getDurableObjects = (
2286 bindings: readonly WorkerSettingsBinding[] | null | undefined,
2287 ) => {
2288 const namespaces = Object.fromEntries(
2289 (bindings ?? []).flatMap((binding) =>
2290 binding.type === "durable_object_namespace" &&
2291 binding.className &&
2292 binding.namespaceId
2293 ? [[binding.className, binding.namespaceId]]
2294 : [],
2295 ),
2296 );
2297 return namespaces;
2298 };
2299
2300 const getExpectedDurableObjectClassNames = (
2301 bindings: readonly WorkerBinding[] | undefined,
2302 workerName: string,
2303 ) =>
2304 Array.from(
2305 new Set(
2306 bindings?.flatMap((binding) =>
2307 binding.type === "durable_object_namespace" &&
2308 binding.className &&
2309 (binding.scriptName === undefined ||
2310 binding.scriptName === workerName)
2311 ? [binding.className]
2312 : [],
2313 ) ?? [],
2314 ),
2315 );
2316
2317 const getWorkerSettingsWithDurableObjects = Effect.fn(function* (
2318 scriptName: string,
2319 expectedClassNames: readonly string[],
2320 dispatchNamespace?: string,
2321 ) {
2322 const { accountId } = yield* yield* CloudflareEnvironment;
2323 return yield* getScriptSettings(
2324 accountId,
2325 scriptName,
2326 dispatchNamespace,
2327 ).pipe(
2328 Effect.map((settings) => {
2329 const namespaces = getDurableObjects(settings.bindings);
2330 const missing = expectedClassNames.filter(
2331 (className) => !namespaces[className],
2332 );
2333 if (missing.length > 0) {
2334 return Effect.fail(
2335 new MissingDurableObjects({
2336 scriptName,
2337 expected: missing,
2338 }),
2339 );
2340 }
2341 return Effect.succeed({
2342 settings,
2343 durableObjectNamespaces: namespaces,
2344 });
2345 }),
2346 Effect.flatten,
2347 Effect.retry({
2348 // `MissingDurableObjects`: the DO bindings haven't
2349 // surfaced in the version settings yet. `WorkerHasNoVersions` /
2350 // `WorkerNotFound`: right after the first `putScript`, the
2351 // version-settings read can race the script registry — under a
2352 // busy account this read can briefly 404 with "has no versions"
2353 // (or the worker itself as not-yet-found) before the upload
2354 // propagates. All three are eventual-consistency blips.
2355 while: (error) =>
2356 error._tag === "MissingDurableObjects" ||
2357 error._tag === "WorkerHasNoVersions" ||
2358 error._tag === "WorkerNotFound" ||
2359 error._tag === "DispatchNamespaceScriptNotFound" ||
2360 error._tag === "DispatchNamespaceNotFound",
2361 schedule: Schedule.max([
2362 Schedule.exponential(100),
2363 Schedule.recurs(20),
2364 ]),
2365 }),
2366 );
2367 });
2368
2369 const prepareAssets = Effect.fn(function* (
2370 assets: WorkerProps["assets"],
2371 ) {
2372 if (!assets) {
2373 return undefined;
2374 }
2375
2376 if (typeof assets === "object" && "hash" in assets) {
2377 const { hash: _, ...config } = assets;
2378 return yield* readAssets(config);
2379 }
2380
2381 // Framework sources supply the directory; routing-only config has no files to read.
2382 if (typeof assets === "object" && assets.directory === undefined) {
2383 return undefined;
2384 }
2385
2386 // Handle string path or AssetsProps
2387 return yield* readAssets(
2388 typeof assets === "string" ? { directory: assets } : assets,
2389 );
2390 });
2391
2392 const prepareBundle = (id: string, fqn: string, props: WorkerProps) =>
2393 (isPythonMain(props.main)
2394 ? readPythonWorkerBundle({
2395 id,
2396 fqn,
2397 main: props.main,
2398 compatibility: getCompatibility(props),
2399 })
2400 : props.bundle === false
2401 ? readPrebuiltWorkerBundle({
2402 main: props.main!,
2403 rules: props.rules,
2404 })
2405 : bundler.build({
2406 id,
2407 main: props.main!,
2408 compatibility: getCompatibility(props),
2409 entry: props.isExternal
2410 ? {
2411 kind: "external",
2412 }
2413 : {
2414 kind: "effect",
2415 exports: props.exports ?? {},
2416 },
2417 stack: { name: stack.name, stage: stack.stage },
2418 extraOptions: props.build,
2419 })
2420 ).pipe(Artifacts.cached("build"));
2421
2422 const hashScript = (script: string) =>
2423 Effect.sync(() =>
2424 crypto.createHash("sha256").update(script).digest("hex"),
2425 );
2426
2427 const viteBuild = Effect.fn(function* (
2428 id: string,
2429 fqn: string,
2430 props: WorkerProps,
2431 selfUrl?: string,
2432 ) {
2433 const compatibility = getCompatibility(props);
2434 const Vite = yield* loadVite;
2435 const { clientDirectory, base, serverBundle, externalWorkspaces } =
2436 yield* Vite.viteBuild(
2437 props.vite?.rootDir,
2438 Object.fromEntries(
2439 (yield* Effect.all(
2440 Object.entries(props.env ?? {}).map(
2441 Effect.fn(function* ([key, value]) {
2442 return [
2443 key,
2444 typeof value === "string"
2445 ? value
2446 : Redacted.isRedacted(value) &&
2447 typeof Redacted.value(value) === "string"
2448 ? Redacted.value(value)
2449 : // `Worker.URL` (bare tag or called) — resolved to
2450 // this Worker's own URL. The bare tag is
2451 // Effect-shaped, so check before `Effect.isEffect`.
2452 isSelfUrl(value)
2453 ? selfUrl
2454 : // A `WorkerLoader` is a real Effect that also carries
2455 // the `~alchemy/Kind` marker — it is a binding, not a
2456 // runnable env value. Check it before `Effect.isEffect`
2457 // so we don't execute it as an inlined env entry.
2458 isWorkerLoader(value)
2459 ? undefined
2460 : // A `Cloudflare.Container` declaration is likewise
2461 // Effect-shaped but is a binding (DO namespace +
2462 // ContainerApplication) — yielding it would resolve
2463 // the started-instance tag, which only exists inside
2464 // a Durable Object (#997).
2465 isContainerDecl(value)
2466 ? undefined
2467 : Effect.isEffect(value)
2468 ? yield* value as any as Effect.Effect<any>
2469 : undefined,
2470 ];
2471 }),
2472 ),
2473 )).filter(([_, value]) => value !== undefined),
2474 ),
2475 {
2476 // A relative `vite.main` is documented to resolve from the Vite
2477 // root. The rolldown plugin resolves the worker entry with no
2478 // importer (i.e. against `process.cwd()`), which breaks when the
2479 // deploy runs from a different directory (e.g. a monorepo infra
2480 // package) — absolutize before handing it over (#796).
2481 main: props.vite?.main
2482 ? path.resolve(
2483 initialCwd,
2484 props.vite.rootDir ?? ".",
2485 props.vite.main,
2486 )
2487 : undefined,
2488 compatibilityDate: compatibility.date,
2489 compatibilityFlags: compatibility.flags,
2490 viteEnvironments: props.vite?.viteEnvironments,
2491 },
2492 fqn,
2493 );
2494 const [assets, bundle, input] = yield* Effect.all(
2495 [
2496 clientDirectory
2497 ? readAssets({
2498 ...(props.assets && typeof props.assets !== "string"
2499 ? props.assets
2500 : undefined),
2501 // `clientDirectory` from the build child is absolute;
2502 // the base only matters as a legacy fallback.
2503 directory: path.resolve(
2504 initialCwd,
2505 props.vite?.rootDir ?? ".",
2506 clientDirectory,
2507 ),
2508 // The resolved Vite `base` is what rewrote the URLs in
2509 // the emitted HTML, so it is the only prefix the
2510 // manifest can agree with.
2511 base,
2512 })
2513 : Effect.undefined,
2514 serverBundle,
2515 Vite.hashViteInput(
2516 props.vite?.rootDir,
2517 props.vite?.memo,
2518 externalWorkspaces,
2519 ),
2520 ],
2521 { concurrency: "unbounded" },
2522 );
2523 if (!assets && !bundle) {
2524 return yield* Effect.die(
2525 new Error("Vite build produced neither assets nor server output"),
2526 );
2527 }
2528 return {
2529 assets,
2530 bundle,
2531 input: input.hash,
2532 additionalWorkspaces: input.workspaces,
2533 };
2534 });
2535
2536 // Loaded lazily: `./Sources/Vite.ts` pulls in
2537 // `@alchemy.run/cloudflare-runtime/vite` (~0.5s), which is only
2538 // needed for vite-based workers at build time — not for every Worker
2539 // definition at module-load time.
2540 const loadVite = Effect.promise(() => import("./Sources/Vite.ts"));
2541
2542 const prepareAssetsAndBundle = (
2543 id: string,
2544 fqn: string,
2545 workerName: string,
2546 props: WorkerProps,
2547 opts: { skipAssetsRead?: boolean; selfUrl?: string } = {},
2548 ) =>
2549 Effect.gen(function* () {
2550 // External source provider (`props.source`): the provider is
2551 // self-contained — it supplies the bundle, optionally its own
2552 // assets (framework builds), and the hash slots it owns. The
2553 // props-level `assets` directory is still read here for sources
2554 // that don't own assets.
2555 if (props.source) {
2556 const source = yield* resolveSource(props);
2557 const ctx = makeSourceContext({
2558 dotAlchemy,
2559 id,
2560 fqn,
2561 workerName,
2562 props,
2563 compatibility: getCompatibility(props),
2564 stack: { name: stack.name, stage: stack.stage },
2565 });
2566 const [output, propsAssets] = yield* Effect.all(
2567 [
2568 source.build(ctx),
2569 source.ownsAssets || opts.skipAssetsRead
2570 ? Effect.undefined
2571 : prepareAssets(props.assets),
2572 ],
2573 { concurrency: "unbounded" },
2574 );
2575 return {
2576 assets: output.assets ?? propsAssets,
2577 bundle: output.bundle,
2578 input: output.hash.input,
2579 additionalWorkspaces: output.hash.additionalWorkspaces,
2580 };
2581 }
2582 if (props.script !== undefined) {
2583 const [assets, bundleHash] = yield* Effect.all(
2584 [
2585 opts.skipAssetsRead
2586 ? Effect.succeed(undefined)
2587 : prepareAssets(props.assets),
2588 hashScript(props.script),
2589 ],
2590 { concurrency: "unbounded" },
2591 );
2592 return {
2593 assets,
2594 bundle: {
2595 files: [{ path: "main.js", content: props.script }],
2596 hash: bundleHash,
2597 },
2598 input: undefined,
2599 additionalWorkspaces: undefined,
2600 };
2601 }
2602 if (props.vite) {
2603 return yield* viteBuild(id, fqn, props, opts.selfUrl);
2604 }
2605 // Assets-only Worker: no entry module at all. The script PUT goes
2606 // out with no modules and no main_module — Cloudflare's asset
2607 // layer serves every request and applies `notFoundHandling`
2608 // (including SPA fallback) itself, exactly like Wrangler's
2609 // assets-only deploys.
2610 if (props.main === undefined) {
2611 if (!props.assets) {
2612 return yield* Effect.die(
2613 new Error(
2614 `Worker "${id}" has no main, script, or assets. Provide an entry module (main / script) or an assets directory to deploy an assets-only Worker.`,
2615 ),
2616 );
2617 }
2618 return {
2619 assets: opts.skipAssetsRead
2620 ? undefined
2621 : yield* prepareAssets(props.assets),
2622 bundle: undefined,
2623 input: undefined,
2624 additionalWorkspaces: undefined,
2625 };
2626 }
2627 const [assets, bundle] = yield* Effect.all(
2628 [
2629 opts.skipAssetsRead
2630 ? Effect.succeed(undefined)
2631 : prepareAssets(props.assets),
2632 prepareBundle(id, fqn, props),
2633 ],
2634 { concurrency: "unbounded" },
2635 );
2636 return {
2637 assets,
2638 bundle,
2639 input: undefined,
2640 additionalWorkspaces: undefined,
2641 };
2642 }).pipe(
2643 Effect.map(({ assets, bundle, input, additionalWorkspaces }) => ({
2644 assets,
2645 bundle: {
2646 main: bundle?.files[0].path,
2647 files: bundle?.files.map(
2648 (file) =>
2649 new File([file.content as BlobPart], file.path, {
2650 type: contentTypeForModule(file.path),
2651 }),
2652 ),
2653 },
2654 hash: {
2655 assets: assets?.hash,
2656 bundle: bundle?.hash,
2657 input,
2658 additionalWorkspaces,
2659 } satisfies Worker["Attributes"]["hash"],
2660 })),
2661 );
2662
2663 const normalizePrebuiltAssets = (
2664 assets: WorkerProps["assets"],
2665 output: Worker["Attributes"] | undefined,
2666 ) => {
2667 // An explicitly-undefined `hash` (`{ directory, hash: maybe }`) is
2668 // the hash-less shape, not a supplied hash — the same rule
2669 // `assetsChanged` applies during diff. Without the `undefined` check
2670 // a greenfield create (`output === undefined`) compares `undefined
2671 // === undefined`, sets `skip`, and uploads a Worker with no assets.
2672 if (
2673 !Predicate.hasProperty(assets, "hash") ||
2674 assets.hash === undefined
2675 ) {
2676 return undefined;
2677 }
2678 // `base` shapes the uploaded manifest paths (see `readAssets`); it
2679 // is alchemy-only and must not leak into the API's asset config.
2680 const { directory, hash, base, ...config } = assets;
2681 // `base` re-keys the manifest without changing the build output, so
2682 // a caller-supplied hash alone would let the skip path carry a
2683 // stale root-keyed manifest forward across a `base` change. Salt
2684 // the stored/compared hash with the prefix; unprefixed deploys keep
2685 // their existing hashes.
2686 const pathPrefix = getAssetsPathPrefix(base);
2687 const effectiveHash = pathPrefix ? `${hash}#base=${pathPrefix}` : hash;
2688 return {
2689 directory,
2690 config,
2691 hash: effectiveHash,
2692 skip: effectiveHash === output?.hash?.assets,
2693 };
2694 };
2695
2696 /**
2697 * Merge `Worker["Binding"].env` from active bindings into `news.env`.
2698 * Stack-level `worker.bind({ env })` is how a later resource (webhook
2699 * secret, etc.) attaches env without the Worker init depending on it.
2700 */
2701 const secretTextFromEnvValue = (value: unknown): string | undefined => {
2702 if (Redacted.isRedacted(value)) {
2703 const inner = Redacted.value(value);
2704 return typeof inner === "string" ? inner : undefined;
2705 }
2706 if (isRedactedMarker(value) && typeof value.value === "string") {
2707 return value.value;
2708 }
2709 return undefined;
2710 };
2711
2712 const newsWithBoundEnv = (
2713 news: WorkerProps,
2714 bindings: readonly ResourceBinding<Worker["Binding"]>[],
2715 ): { news: WorkerProps; boundKeys: string[] } => {
2716 const bound: Record<string, any> = {};
2717 for (const binding of bindings) {
2718 if ((binding as { action?: string }).action === "delete") continue;
2719 if (binding.data?.env) Object.assign(bound, binding.data.env);
2720 }
2721 const boundKeys = Object.keys(bound);
2722 if (boundKeys.length === 0) return { news, boundKeys };
2723 return { news: { ...news, env: { ...news.env, ...bound } }, boundKeys };
2724 };
2725
2726 /**
2727 * Append the standard Alchemy runtime bindings plus the user's `env`
2728 * entries (routed by shape: `Redacted` → secret_text, string →
2729 * plain_text, everything else → json) to a metadata binding list.
2730 * Shared between the full script upload and the version upload.
2731 */
2732 const appendAlchemyAndEnvBindings = (
2733 metadataBindings: WorkerBinding[],
2734 news: WorkerProps,
2735 accountId: string,
2736 workerName: string,
2737 ) => {
2738 metadataBindings.push(
2739 {
2740 type: "plain_text",
2741 name: "ALCHEMY_PHASE",
2742 text: "runtime",
2743 },
2744 {
2745 type: "plain_text",
2746 name: "ALCHEMY_WORKER_NAME",
2747 text: workerName,
2748 },
2749 {
2750 type: "plain_text",
2751 name: "ALCHEMY_STACK_NAME",
2752 text: stack.name,
2753 },
2754 {
2755 type: "plain_text",
2756 name: "ALCHEMY_STAGE",
2757 text: stack.stage,
2758 },
2759 {
2760 type: "plain_text",
2761 name: "ALCHEMY_CLOUDFLARE_ACCOUNT_ID",
2762 text: accountId,
2763 },
2764 );
2765 // Add environment variables as metadata bindings
2766 if (news.env) {
2767 for (const [key, value] of Object.entries(news.env)) {
2768 if (value === undefined) continue;
2769 if (metadataBindings.some((b) => b.name === key)) continue;
2770 const secretText = secretTextFromEnvValue(value);
2771 if (secretText !== undefined) {
2772 metadataBindings.push({
2773 type: "secret_text",
2774 name: key,
2775 text: secretText,
2776 });
2777 } else if (typeof value === "string") {
2778 metadataBindings.push({
2779 type: "plain_text",
2780 name: key,
2781 text: value,
2782 });
2783 } else {
2784 metadataBindings.push({
2785 type: "json",
2786 name: key,
2787 json: value,
2788 });
2789 }
2790 }
2791 }
2792 };
2793
2794 /**
2795 * Create a deployment routing `traffic`% to `versionId`, with the
2796 * remainder staying on the currently-live version (the
2797 * highest-percentage version of the script's latest deployment).
2798 * `traffic >= 100` — or a script with no other live version to split
2799 * against — deploys the new version at 100%.
2800 */
2801 const deployVersionTraffic = Effect.fn(function* (params: {
2802 accountId: string;
2803 scriptName: string;
2804 versionId: string;
2805 traffic: number;
2806 message: string | undefined;
2807 }) {
2808 const { accountId, scriptName, versionId, traffic } = params;
2809 const split = yield* Effect.gen(function* () {
2810 if (traffic >= 100) {
2811 return [{ versionId, percentage: 100 }];
2812 }
2813 const { deployments } = yield* workers.listScriptDeployments({
2814 accountId,
2815 scriptName,
2816 });
2817 // Deployments are returned newest-first; the live version is the
2818 // highest-percentage version of the most recent deployment.
2819 const stable = deployments[0]?.versions
2820 .filter((v) => v.versionId !== versionId)
2821 .sort((a, b) => b.percentage - a.percentage)[0];
2822 if (!stable) {
2823 // Nothing to split against (first deployment of this script).
2824 return [{ versionId, percentage: 100 }];
2825 }
2826 return [
2827 { versionId, percentage: traffic },
2828 { versionId: stable.versionId, percentage: 100 - traffic },
2829 ];
2830 });
2831 const deployment = yield* workers.createScriptDeployment({
2832 accountId,
2833 scriptName,
2834 strategy: "percentage",
2835 versions: split,
2836 annotations: params.message
2837 ? { workersMessage: params.message }
2838 : undefined,
2839 });
2840 return deployment.id;
2841 });
2842
2843 /**
2844 * Resolve the preview-URL alias for a version worker: the
2845 * user-provided `version.alias` (validated against Cloudflare's
2846 * naming rules), or an alias derived from the stack, stage, and
2847 * logical id — stable across deploys, so the aliased preview URL
2848 * (`<alias>-<script>.<subdomain>.workers.dev`) is a durable link
2849 * that always points at the latest uploaded version. Returns
2850 * `undefined` when the parent's script name leaves no room in the
2851 * 63-character DNS label and no explicit alias was given.
2852 */
2853 const resolveVersionAlias = Effect.fn(function* (
2854 id: string,
2855 news: WorkerProps,
2856 parentName: string,
2857 ) {
2858 // `<alias>-<script name>` is a single DNS label (≤ 63 chars).
2859 const budget = 63 - parentName.length - 1;
2860 const userAlias = news.version?.alias;
2861 if (userAlias !== undefined) {
2862 if (!/^[a-z][a-z0-9-]*$/.test(userAlias)) {
2863 return yield* Effect.fail(
2864 new WorkerVersionConfigError({
2865 message: `version.alias '${userAlias}' is invalid: aliases must start with a lowercase letter and contain only lowercase letters, digits, and dashes.`,
2866 }),
2867 );
2868 }
2869 if (userAlias.length > budget) {
2870 return yield* Effect.fail(
2871 new WorkerVersionConfigError({
2872 message: `version.alias '${userAlias}' is too long: '<alias>-${parentName}' must fit in a 63-character DNS label, leaving ${Math.max(budget, 0)} characters for the alias.`,
2873 }),
2874 );
2875 }
2876 return userAlias;
2877 }
2878 if (budget < 4) {
2879 return undefined;
2880 }
2881 // Deterministic per (stack, stage, id) — survives updates AND
2882 // replacements so the aliased preview URL never moves. A short
2883 // hash disambiguates different stacks/stages whose readable
2884 // prefixes collide after truncation.
2885 const hash = yield* Effect.sync(() =>
2886 crypto
2887 .createHash("sha256")
2888 .update(`${stack.name}/${stack.stage}/${id}`)
2889 .digest("hex")
2890 .slice(0, 6),
2891 );
2892 const readable = `${stack.stage}-${id}`
2893 .toLowerCase()
2894 .replace(/[^a-z0-9-]+/g, "-")
2895 .replace(/^-+|-+$/g, "");
2896 const alias = [readable, hash]
2897 .filter((part) => part.length > 0)
2898 .join("-")
2899 .slice(0, budget)
2900 .replace(/-+$/, "");
2901 return /^[a-z]/.test(alias) ? alias : `v${alias}`.slice(0, budget);
2902 });
2903
2904 /**
2905 * The parent's workers.dev preview settings + account subdomain,
2906 * fetched once per version reconcile. Preview URLs (aliased and
2907 * versioned) only serve when the parent has previews enabled.
2908 */
2909 const resolveVersionPreviewContext = Effect.fn(function* (
2910 accountId: string,
2911 scriptName: string,
2912 ) {
2913 const subdomain = yield* workers
2914 .getScriptSubdomain({ accountId, scriptName })
2915 .pipe(
2916 Effect.orElseSucceed((): workers.GetScriptSubdomainResponse => ({
2917 enabled: false,
2918 previewsEnabled: false,
2919 })),
2920 );
2921 const accountSubdomain = yield* getAccountSubdomain(accountId);
2922 return {
2923 previewsEnabled: subdomain.previewsEnabled === true,
2924 accountSubdomain,
2925 };
2926 });
2927
2928 /**
2929 * Reject props that can't ride along on a *version worker*
2930 * (`version.parent` set). A version carries only code, bindings, and
2931 * compatibility settings; everything script-level belongs to the
2932 * parent and must not be mutated from a version resource. Locally
2933 * hosted Durable Object / Workflow classes are rejected because their
2934 * migrations would apply to the parent script when the version
2935 * deploys.
2936 */
2937 const validateVersionWorkerProps = Effect.fn(function* (
2938 news: WorkerProps,
2939 bindings: ResourceBinding<Worker["Binding"]>[],
2940 parentName: string,
2941 ) {
2942 const forbidden = (
2943 [
2944 ["name", news.name],
2945 ["namespace", news.namespace],
2946 ["crons", news.crons],
2947 ["tailConsumers", news.tailConsumers],
2948 ["streamingTailConsumers", news.streamingTailConsumers],
2949 ["domain", news.domain],
2950 ["routes", news.routes],
2951 ["tags", news.tags],
2952 ["logpush", news.logpush],
2953 ["observability", news.observability],
2954 ["placement", news.placement],
2955 ["limits", news.limits],
2956 ["workersDev", news.workersDev],
2957 ["access", news.access],
2958 ["vite", news.vite],
2959 ] as const
2960 ).flatMap(([key, value]) => (value !== undefined ? [key] : []));
2961 if (forbidden.length > 0) {
2962 return yield* Effect.fail(
2963 new WorkerVersionConfigError({
2964 message:
2965 `version.parent uploads a version of '${parentName}' — script-level settings belong to the parent Worker and cannot be set here: ${forbidden.join(", ")}. ` +
2966 `Remove ${forbidden.length === 1 ? "this prop" : "these props"} or configure ${forbidden.length === 1 ? "it" : "them"} on the parent.`,
2967 }),
2968 );
2969 }
2970 yield* validateTraffic(news.version?.traffic);
2971 const cronBindings = getCronBindings(bindings);
2972 if (cronBindings.length > 0) {
2973 return yield* Effect.fail(
2974 new WorkerVersionConfigError({
2975 message: `Cron Triggers are script-level settings and cannot be registered from a version of '${parentName}'. Configure crons on the parent Worker.`,
2976 }),
2977 );
2978 }
2979 // Any locally-hosted DO class (a durable_object_namespace binding
2980 // without a foreign scriptName) or DO/Workflow export would require
2981 // migrations, which the versions API can't carry — and which would
2982 // mutate the parent's namespaces.
2983 const hostedClasses = getDurableObjectBindings(bindings, parentName);
2984 const exportedClasses = Object.keys(news.exports ?? {});
2985 if (hostedClasses.length > 0 || exportedClasses.length > 0) {
2986 return yield* Effect.fail(
2987 new WorkerVersionConfigError({
2988 message: `A version of '${parentName}' cannot host Durable Object or Workflow classes (${[
2989 ...new Set([
2990 ...hostedClasses.map((c) => c.className),
2991 ...exportedClasses,
2992 ]),
2993 ].join(
2994 ", ",
2995 )}): class migrations apply to the parent script. Host the classes on the parent Worker and reference them cross-script instead.`,
2996 }),
2997 );
2998 }
2999 });
3000
3001 /**
3002 * Reconcile a *version worker*: upload this Worker's code + bindings
3003 * as an immutable version of the parent's script (no script of its
3004 * own), then optionally shift `version.traffic`% of the parent's
3005 * traffic to it. Idempotent in the reconciler sense: every run
3006 * converges cloud state to "the parent's script has a version with
3007 * exactly this content, receiving exactly this traffic" — re-running
3008 * with changed content simply uploads the next immutable version.
3009 */
3010 const putWorkerVersion = Effect.fn(function* (
3011 id: string,
3012 fqn: string,
3013 news: WorkerProps,
3014 bindings: ResourceBinding<Worker["Binding"]>[],
3015 session: ScopedPlanStatusSession,
3016 output: Worker["Attributes"] | undefined,
3017 ) {
3018 ({ news } = newsWithBoundEnv(news, bindings));
3019 const { accountId } = yield* yield* CloudflareEnvironment;
3020 const version = news.version!;
3021 const parentName = resolveVersionParentName(version);
3022 if (parentName === undefined) {
3023 return yield* Effect.fail(
3024 new WorkerVersionConfigError({
3025 message: `version.parent did not resolve to a Worker script name. Pass a Worker (e.g. \`yield* Cloudflare.Worker.ref(id, { stage })\`) or a literal script name.`,
3026 }),
3027 );
3028 }
3029 yield* validateVersionWorkerProps(news, bindings, parentName);
3030 const traffic = version.traffic ?? 0;
3031 // Resolve the alias and preview context BEFORE the upload: the
3032 // aliased preview URL (`<alias>-<script>.<subdomain>.workers.dev`)
3033 // is deterministic ahead of time — unlike the versioned URL, whose
3034 // prefix comes from the server-assigned version id — which is what
3035 // lets a `Worker.URL` (self_url) binding be baked into the
3036 // version's own bindings.
3037 const alias = yield* resolveVersionAlias(id, news, parentName);
3038 const { previewsEnabled, accountSubdomain } =
3039 yield* resolveVersionPreviewContext(accountId, parentName);
3040 const aliasedUrl =
3041 alias !== undefined && previewsEnabled
3042 ? `https://${alias}-${parentName}.${accountSubdomain}.workers.dev`
3043 : undefined;
3044 const selfUrl = hasSelfUrlBinding(bindings) ? aliasedUrl : undefined;
3045 if (hasSelfUrlBinding(bindings) && selfUrl === undefined) {
3046 return yield* Effect.fail(
3047 new WorkerVersionConfigError({
3048 message: previewsEnabled
3049 ? `A version of '${parentName}' binds its own URL (Worker.URL), but no preview alias fits: '<alias>-${parentName}' must stay within a 63-character DNS label. Set a short version.alias or shorten the parent's name.`
3050 : `A version of '${parentName}' binds its own URL (Worker.URL), but the parent's workers.dev previews are disabled — a version's URL is its aliased preview URL. Enable the parent's workers.dev subdomain (url: true, the default).`,
3051 }),
3052 );
3053 }
3054 yield* Effect.logInfo(
3055 `Cloudflare Worker version: preparing bundle for ${parentName} (from ${id})`,
3056 );
3057 const {
3058 assets,
3059 bundle,
3060 hash: preparedHash,
3061 } = yield* prepareAssetsAndBundle(id, fqn, parentName, news, {
3062 skipAssetsRead: false,
3063 });
3064 const metadataHash = yield* resolveWorkerMetadataHash({
3065 props: news,
3066 bindings,
3067 accountId,
3068 stack: { name: stack.name, stage: stack.stage },
3069 selfUrl,
3070 });
3071 const hash = {
3072 ...preparedHash,
3073 metadata: metadataHash,
3074 } satisfies Worker["Attributes"]["hash"];
3075 // Lower the `Worker.URL` sentinel into the aliased preview URL —
3076 // same lowering `putWorker` performs, with the alias standing in
3077 // for the script's own URL. `Worker.Self` lowers to a
3078 // service binding on the parent script (versions have no name of
3079 // their own).
3080 const metadataBindings = bindings.flatMap((b) =>
3081 withoutDevOnlyBindings(b.data.bindings ?? []).map((item) =>
3082 item.type === "self_url"
3083 ? { type: "plain_text" as const, name: item.name, text: selfUrl! }
3084 : item.type === "self_service"
3085 ? {
3086 type: "service" as const,
3087 name: item.name,
3088 service: parentName,
3089 }
3090 : item,
3091 ),
3092 );
3093 let metadataAssets:
3094 | workers.CreateScriptVersionRequest["metadata"]["assets"]
3095 | undefined;
3096 if (assets) {
3097 yield* Effect.logInfo(
3098 `Cloudflare Worker version: uploading assets for ${parentName}`,
3099 );
3100 const { jwt } = yield* uploadAssets(
3101 accountId,
3102 parentName,
3103 assets,
3104 session,
3105 );
3106 metadataAssets = {
3107 jwt,
3108 config: mergeAssetsConfigFiles(assets.config, assets),
3109 };
3110 metadataBindings.push({ type: "assets", name: "ASSETS" });
3111 }
3112 appendAlchemyAndEnvBindings(
3113 metadataBindings,
3114 news,
3115 accountId,
3116 parentName,
3117 );
3118 const compatibility = getCompatibility(news);
3119 yield* session.note(`Uploading version of ${parentName} ...`, {
3120 kind: "status",
3121 });
3122 const created = yield* workers
3123 .createScriptVersion({
3124 accountId,
3125 scriptName: parentName,
3126 metadata: {
3127 mainModule: bundle.main!,
3128 assets: metadataAssets,
3129 bindings: metadataBindings,
3130 compatibilityDate: compatibility.date,
3131 compatibilityFlags: compatibility.flags,
3132 cacheOptions: news.cache ?? getCacheBinding(bindings),
3133 annotations:
3134 alias !== undefined ||
3135 version.message !== undefined ||
3136 version.tag !== undefined
3137 ? {
3138 workersAlias: alias,
3139 workersMessage: version.message,
3140 workersTag: version.tag,
3141 }
3142 : undefined,
3143 },
3144 files: bundle.files,
3145 })
3146 .pipe(
3147 Effect.retry({
3148 while: isBindingTargetNotFound,
3149 schedule: bindingTargetNotFoundRetrySchedule(),
3150 }),
3151 Effect.catchTag("WorkerNotFound", () =>
3152 Effect.fail(
3153 new WorkerVersionConfigError({
3154 message: `version.parent script '${parentName}' does not exist. Deploy the parent Worker first (or check the referenced stage/stack).`,
3155 }),
3156 ),
3157 ),
3158 );
3159 const versionId = created.id ?? undefined;
3160 if (versionId === undefined) {
3161 return yield* Effect.fail(
3162 new WorkerVersionConfigError({
3163 message: `Cloudflare did not return a version id for the uploaded version of '${parentName}'.`,
3164 }),
3165 );
3166 }
3167 let deploymentId: string | undefined;
3168 if (traffic > 0) {
3169 yield* session.note(
3170 `Deploying version at ${traffic}% of ${parentName}'s traffic ...`,
3171 { kind: "status" },
3172 );
3173 deploymentId = yield* deployVersionTraffic({
3174 accountId,
3175 scriptName: parentName,
3176 versionId,
3177 traffic,
3178 message: version.message,
3179 });
3180 }
3181 // Version-affinity transform rules (`version.affinity`): a canary
3182 // rides the *parent's* zone traffic, so the rules land on the
3183 // parent's zones — observed custom-domain attachments plus, when
3184 // `parent` was passed as a Worker resource, its recorded routes.
3185 const previousAffinityZoneIds = output?.affinityZoneIds ?? [];
3186 let affinityZoneIds: string[] | undefined;
3187 if (version.affinity !== undefined || previousAffinityZoneIds.length) {
3188 const resolvedAffinity =
3189 version.affinity !== undefined
3190 ? yield* resolveVersionAffinity(version.affinity)
3191 : undefined;
3192 let hostsByZone: Map<string, AffinityZoneHost[]> | undefined;
3193 if (resolvedAffinity !== undefined) {
3194 hostsByZone = new Map();
3195 const addHost = (zoneId: string, host: AffinityZoneHost) => {
3196 const hosts = hostsByZone!.get(zoneId) ?? [];
3197 hosts.push(host);
3198 hostsByZone!.set(zoneId, hosts);
3199 };
3200 // A `version.parent` passed as a Worker resource resolves to
3201 // the parent's Attributes at reconcile time (see
3202 // resolveVersionParentName) — its recorded routes and domain
3203 // tell us the zones the parent serves on.
3204 const parentAttrs =
3205 typeof version.parent === "object" && version.parent !== null
3206 ? (version.parent as unknown as Partial<Worker["Attributes"]>)
3207 : undefined;
3208 const redirectHosts = new Set(parentAttrs?.domain?.redirects ?? []);
3209 const attached = yield* workers
3210 .listDomains({ accountId, service: parentName })
3211 .pipe(Effect.map((r) => r.result ?? []));
3212 for (const d of attached) {
3213 if (!d.hostname || !d.zoneId || redirectHosts.has(d.hostname)) {
3214 continue;
3215 }
3216 addHost(d.zoneId, { host: d.hostname, wildcard: false });
3217 }
3218 for (const route of parentAttrs?.routes ?? []) {
3219 const host = route.pattern.split("/")[0];
3220 if (!host || host.includes('"')) continue;
3221 addHost(route.zoneId, { host, wildcard: host.includes("*") });
3222 }
3223 if (hostsByZone.size === 0) {
3224 return yield* Effect.fail(
3225 new WorkerVersionConfigError({
3226 message:
3227 `version.affinity pins users via a zone Transform Rule setting the ${AFFINITY_HEADER} header, which only sees zone traffic — the parent '${parentName}' has no custom domains (or recorded zone routes) to place the rule on. ` +
3228 `Give the parent a \`domain\` or zone \`routes\`, or pass the parent as a Worker resource so its routes are visible here.`,
3229 }),
3230 );
3231 }
3232 yield* session.note("Reconciling version-affinity rules ...", {
3233 kind: "status",
3234 });
3235 }
3236 const placed = yield* reconcileAffinityRules({
3237 scriptName: parentName,
3238 desired:
3239 resolvedAffinity !== undefined && hostsByZone !== undefined
3240 ? { affinity: resolvedAffinity, hostsByZone }
3241 : undefined,
3242 previousZoneIds: previousAffinityZoneIds,
3243 });
3244 affinityZoneIds = placed.length > 0 ? placed : undefined;
3245 }
3246 // The aliased URL is primary (`url`): it is stable across deploys,
3247 // re-pointing at each newly uploaded version. The per-version URL
3248 // (prefixed by the version id) rides along in `urls`.
3249 const versionedUrl = previewsEnabled
3250 ? `https://${versionId.split("-")[0]}-${parentName}.${accountSubdomain}.workers.dev`
3251 : undefined;
3252 const urls = [
3253 ...(aliasedUrl ? [aliasedUrl] : []),
3254 ...(versionedUrl ? [versionedUrl] : []),
3255 ];
3256 return {
3257 // A version worker never owns a script — carry the *parent*
3258 // script's immutable ID (its preview URLs are protected through
3259 // the parent).
3260 workerId:
3261 cachedWorkerId(output?.workerId, parentName) ??
3262 (yield* findWorkerId(accountId, parentName)),
3263 workerName: parentName,
3264 namespace: undefined,
3265 logpush: undefined,
3266 url: urls[0],
3267 urls,
3268 domain: undefined,
3269 tags: undefined,
3270 durableObjectNamespaces: {},
3271 accountId,
3272 routes: [],
3273 crons: [],
3274 versionOf: parentName,
3275 versionId,
3276 versionAlias: alias,
3277 deploymentId,
3278 affinityZoneIds,
3279 hash,
3280 } satisfies Worker["Attributes"];
3281 });
3282
3283 /**
3284 * Reconcile a *Preview Worker*: create/update a first-class Preview of
3285 * the parent script and upload this Worker's code + bindings as a
3286 * Preview deployment. Isolated same-Worker Durable Object classes
3287 * get their own namespace per Preview.
3288 */
3289 const putWorkerPreview = Effect.fn(function* (
3290 id: string,
3291 fqn: string,
3292 news: WorkerProps,
3293 bindings: ResourceBinding<Worker["Binding"]>[],
3294 session: ScopedPlanStatusSession,
3295 output: Worker["Attributes"] | undefined,
3296 ) {
3297 const { accountId } = yield* yield* CloudflareEnvironment;
3298 const preview = news.preview!;
3299 const parentName = resolvePreviewParentName(preview);
3300 if (parentName === undefined) {
3301 return yield* Effect.fail(
3302 new WorkerPreviewConfigError({
3303 message: `preview.of did not resolve to a Worker script name. Pass a Worker (e.g. \`yield* Cloudflare.Worker.ref(id, { stage })\`) or a literal script name.`,
3304 }),
3305 );
3306 }
3307 if (news.version !== undefined) {
3308 return yield* Effect.fail(
3309 new WorkerPreviewConfigError({
3310 message: `preview and version cannot be set together. Use preview.of for branch/PR Previews; use version.parent / version.traffic for canaries and gradual rollouts.`,
3311 }),
3312 );
3313 }
3314 const forbidden = (
3315 [
3316 ["name", news.name],
3317 ["namespace", news.namespace],
3318 ["crons", news.crons],
3319 ["tailConsumers", news.tailConsumers],
3320 ["streamingTailConsumers", news.streamingTailConsumers],
3321 ["domain", news.domain],
3322 ["routes", news.routes],
3323 ["workersDev", news.workersDev],
3324 ["access", news.access],
3325 ] as const
3326 ).flatMap(([key, value]) => (value !== undefined ? [key] : []));
3327 if (forbidden.length > 0) {
3328 return yield* Effect.fail(
3329 new WorkerPreviewConfigError({
3330 message:
3331 `preview.of creates a Preview of '${parentName}' — script-level settings belong to the parent Worker and cannot be set here: ${forbidden.join(", ")}. ` +
3332 `Remove ${forbidden.length === 1 ? "this prop" : "these props"} or configure ${forbidden.length === 1 ? "it" : "them"} on the parent.`,
3333 }),
3334 );
3335 }
3336 const cronBindings = getCronBindings(bindings);
3337 if (cronBindings.length > 0) {
3338 return yield* Effect.fail(
3339 new WorkerPreviewConfigError({
3340 message: `Cron Triggers are script-level settings and cannot be registered from a Preview of '${parentName}'. Configure crons on the parent Worker.`,
3341 }),
3342 );
3343 }
3344
3345 const budget = 63 - parentName.length - 1;
3346 const userName = preview.name;
3347 let previewName: string;
3348 if (userName !== undefined) {
3349 if (!/^[a-z][a-z0-9-]*$/.test(userName)) {
3350 return yield* Effect.fail(
3351 new WorkerPreviewConfigError({
3352 message: `preview.name '${userName}' is invalid: names must start with a lowercase letter and contain only lowercase letters, digits, and dashes.`,
3353 }),
3354 );
3355 }
3356 if (userName.length > budget) {
3357 return yield* Effect.fail(
3358 new WorkerPreviewConfigError({
3359 message: `preview.name '${userName}' is too long: '<name>-${parentName}' must fit in a 63-character DNS label, leaving ${Math.max(budget, 0)} characters for the name.`,
3360 }),
3361 );
3362 }
3363 previewName = userName;
3364 } else if (budget < 4) {
3365 return yield* Effect.fail(
3366 new WorkerPreviewConfigError({
3367 message: `Cannot derive a Preview name: '<name>-${parentName}' must fit in a 63-character DNS label. Set preview.name to a short value or shorten the parent's name.`,
3368 }),
3369 );
3370 } else {
3371 const readable = stack.stage
3372 .toLowerCase()
3373 .replace(/[^a-z0-9-]+/g, "-")
3374 .replace(/^-+|-+$/g, "");
3375 const derived = (readable.length > 0 ? readable : `p${id}`)
3376 .slice(0, budget)
3377 .replace(/-+$/, "");
3378 previewName = /^[a-z]/.test(derived)
3379 ? derived
3380 : `p${derived}`.slice(0, budget);
3381 }
3382
3383 yield* Effect.logInfo(
3384 `Cloudflare Worker Preview: ${previewName} of ${parentName} (from ${id})`,
3385 );
3386
3387 const existingPreview = yield* workers
3388 .getPreview({
3389 accountId,
3390 workerId: parentName,
3391 previewId: previewName,
3392 })
3393 .pipe(
3394 Effect.catchTag("PreviewNotFound", () => Effect.succeed(undefined)),
3395 Effect.catchTag("WorkerNotFound", () =>
3396 Effect.fail(
3397 new WorkerPreviewConfigError({
3398 message: `preview.of script '${parentName}' does not exist. Deploy the parent Worker first (or check the referenced stage/stack).`,
3399 }),
3400 ),
3401 ),
3402 );
3403
3404 const previewResource =
3405 existingPreview ??
3406 (yield* workers
3407 .createPreview({
3408 accountId,
3409 workerId: parentName,
3410 name: previewName,
3411 ignoreBaseConfig: true,
3412 })
3413 .pipe(
3414 Effect.catchTag("PreviewAlreadyExists", () =>
3415 workers
3416 .getPreview({
3417 accountId,
3418 workerId: parentName,
3419 previewId: previewName,
3420 })
3421 .pipe(
3422 Effect.retry({
3423 while: (error) => error._tag === "PreviewNotFound",
3424 schedule: Schedule.spaced("500 millis"),
3425 times: 8,
3426 }),
3427 ),
3428 ),
3429 Effect.catchTag("WorkerNotFound", () =>
3430 Effect.fail(
3431 new WorkerPreviewConfigError({
3432 message: `preview.of script '${parentName}' does not exist. Deploy the parent Worker first (or check the referenced stage/stack).`,
3433 }),
3434 ),
3435 ),
3436 ));
3437
3438 const accountSubdomain = yield* getAccountSubdomain(accountId);
3439 const parentAttrs =
3440 typeof preview.of === "object" && preview.of !== null
3441 ? (preview.of as unknown as Partial<Worker["Attributes"]>)
3442 : undefined;
3443 const parentDomainName = parentAttrs?.domain?.name;
3444 const parentDomainPreviews = parentAttrs?.domain?.previews === true;
3445 const constructedUrls = [
3446 ...(parentDomainPreviews && parentDomainName
3447 ? [`https://${previewResource.slug}.${parentDomainName}`]
3448 : []),
3449 `https://${previewResource.slug}-${parentName}.${accountSubdomain}.workers.dev`,
3450 ];
3451 const stableUrl = previewResource.urls?.[0] ?? constructedUrls[0];
3452 const selfUrl = hasSelfUrlBinding(bindings) ? stableUrl : undefined;
3453
3454 yield* Effect.logInfo(
3455 `Cloudflare Worker Preview: preparing bundle for ${parentName} (preview ${previewName})`,
3456 );
3457 const {
3458 assets,
3459 bundle,
3460 hash: preparedHash,
3461 } = yield* prepareAssetsAndBundle(id, fqn, parentName, news, {
3462 skipAssetsRead: false,
3463 });
3464 const metadataHash = yield* resolveWorkerMetadataHash({
3465 props: news,
3466 bindings,
3467 accountId,
3468 stack: { name: stack.name, stage: stack.stage },
3469 selfUrl,
3470 });
3471 const hash = {
3472 ...preparedHash,
3473 metadata: metadataHash,
3474 } satisfies Worker["Attributes"]["hash"];
3475
3476 const metadataBindings = bindings.flatMap((b) =>
3477 withoutDevOnlyBindings(b.data.bindings ?? []).map((item) =>
3478 item.type === "self_url"
3479 ? { type: "plain_text" as const, name: item.name, text: selfUrl! }
3480 : item.type === "self_service"
3481 ? {
3482 type: "service" as const,
3483 name: item.name,
3484 service: parentName,
3485 }
3486 : item,
3487 ),
3488 );
3489 let metadataAssets:
3490 | workers.CreatePreviewDeploymentMetadata["assets"]
3491 | undefined;
3492 if (assets) {
3493 yield* Effect.logInfo(
3494 `Cloudflare Worker Preview: uploading assets for ${parentName}`,
3495 );
3496 const { jwt } = yield* uploadAssets(
3497 accountId,
3498 parentName,
3499 assets,
3500 session,
3501 );
3502 metadataAssets = {
3503 jwt,
3504 config: mergeAssetsConfigFiles(assets.config, assets),
3505 };
3506 metadataBindings.push({ type: "assets", name: "ASSETS" });
3507 }
3508 appendAlchemyAndEnvBindings(
3509 metadataBindings,
3510 news,
3511 accountId,
3512 parentName,
3513 );
3514
3515 const hostedClasses = getDurableObjectBindings(bindings, parentName);
3516 const classNames = [...new Set(hostedClasses.map((c) => c.className))];
3517 let migrations: unknown = undefined;
3518 if (classNames.length > 0) {
3519 const latest = yield* workers
3520 .getPreviewDeployment({
3521 accountId,
3522 workerId: parentName,
3523 previewId: previewResource.id,
3524 deploymentId: "latest",
3525 })
3526 .pipe(
3527 Effect.catchTag(
3528 ["PreviewNotFound", "PreviewDeploymentNotFound"],
3529 () => Effect.succeed(undefined),
3530 ),
3531 );
3532 const oldTag = latest?.migrationTag ?? undefined;
3533 const previous = output?.previewDoClasses ?? [];
3534 if (oldTag === undefined) {
3535 migrations = {
3536 new_tag: "alchemy:v1",
3537 new_sqlite_classes: classNames,
3538 };
3539 } else {
3540 const added = classNames.filter((c) => !previous.includes(c));
3541 const deleted = previous.filter((c) => !classNames.includes(c));
3542 if (added.length > 0 || deleted.length > 0) {
3543 migrations = {
3544 old_tag: oldTag,
3545 new_tag: bumpMigrationTagVersion(oldTag) ?? "alchemy:v1",
3546 ...(added.length > 0 ? { new_sqlite_classes: added } : {}),
3547 ...(deleted.length > 0 ? { deleted_classes: deleted } : {}),
3548 };
3549 }
3550 }
3551 }
3552
3553 const compatibility = getCompatibility(news);
3554 const annotations =
3555 preview.message !== undefined || preview.tag !== undefined
3556 ? {
3557 ...(preview.message !== undefined
3558 ? { "workers/message": preview.message }
3559 : {}),
3560 ...(preview.tag !== undefined
3561 ? { "workers/tag": preview.tag }
3562 : {}),
3563 }
3564 : undefined;
3565
3566 yield* session.note(
3567 `Uploading Preview ${previewName} of ${parentName} ...`,
3568 {
3569 kind: "status",
3570 },
3571 );
3572 const deployment = yield* workers.createPreviewDeployment({
3573 accountId,
3574 workerId: parentName,
3575 previewId: previewResource.id,
3576 metadata: {
3577 mainModule: bundle.main!,
3578 assets: metadataAssets,
3579 compatibilityDate: compatibility.date,
3580 compatibilityFlags: compatibility.flags,
3581 cacheOptions: news.cache ?? getCacheBinding(bindings),
3582 annotations,
3583 migrations,
3584 env: bindingsToPreviewEnv(metadataBindings),
3585 },
3586 files: bundle.files,
3587 });
3588
3589 const refreshed = yield* workers
3590 .getPreview({
3591 accountId,
3592 workerId: parentName,
3593 previewId: previewResource.id,
3594 })
3595 .pipe(
3596 Effect.catchTag("PreviewNotFound", () =>
3597 Effect.succeed(previewResource),
3598 ),
3599 );
3600
3601 const urls = [
3602 ...new Set([
3603 ...(refreshed.urls ?? []),
3604 ...constructedUrls,
3605 ...(deployment.urls ?? []),
3606 ]),
3607 ];
3608 return {
3609 workerId:
3610 cachedWorkerId(output?.workerId, parentName) ??
3611 (yield* findWorkerId(accountId, parentName)),
3612 workerName: parentName,
3613 namespace: undefined,
3614 logpush: undefined,
3615 url: urls[0],
3616 urls,
3617 domain: undefined,
3618 tags: undefined,
3619 durableObjectNamespaces: {},
3620 accountId,
3621 routes: [],
3622 crons: [],
3623 previewOf: parentName,
3624 previewId: previewResource.id,
3625 previewName,
3626 previewSlug: previewResource.slug,
3627 previewDoClasses: classNames.length > 0 ? classNames : undefined,
3628 deploymentId: deployment.id,
3629 hash,
3630 } satisfies Worker["Attributes"];
3631 });
3632
3633 const putWorker = Effect.fn(function* (
3634 id: string,
3635 fqn: string,
3636 news: WorkerProps,
3637 bindings: ResourceBinding<Worker["Binding"]>[],
3638 olds: WorkerProps | undefined,
3639 output: Worker["Attributes"] | undefined,
3640 session: ScopedPlanStatusSession,
3641 existingSettings?: workers.GetScriptScriptAndVersionSettingResponse,
3642 ) {
3643 news = newsWithBoundEnv(news, bindings).news;
3644 const { accountId } = yield* yield* CloudflareEnvironment;
3645 // Prefer the deployed name: regenerating would target a different
3646 // script if the generator's output for this id ever drifts.
3647 const name =
3648 output?.workerName ?? (yield* createWorkerName(id, news.name));
3649 // When set, this Worker is a Workers for Platforms "user worker"
3650 // uploaded into a dispatch namespace rather than a routable
3651 // account-level script. The put/settings calls switch endpoints and
3652 // the subdomain / custom-domain / cron reconciliation is skipped.
3653 const dispatchNamespace = resolveNamespaceName(news?.namespace);
3654 yield* validateTraffic(news.version?.traffic);
3655 if (news.version !== undefined && dispatchNamespace) {
3656 return yield* Effect.fail(
3657 new WorkerVersionConfigError({
3658 message: `Workers for Platforms user workers do not support versions or gradual deployments — remove the version prop from '${name}'.`,
3659 }),
3660 );
3661 }
3662 // Resolve the Worker's own URL up front when a `Worker.URL` binding
3663 // is present: the value must exist before the bundle is built (Vite
3664 // inlines VITE_*-prefixed env into the client bundle) and before the
3665 // metadata bindings are assembled (the `self_url` sentinel lowers
3666 // into a plain_text binding below).
3667 const selfUrl = hasSelfUrlBinding(bindings)
3668 ? dispatchNamespace
3669 ? yield* Effect.die(
3670 `Worker "${name}" binds its own URL (Worker.URL), but a dispatch-namespace user worker is invoked via dynamic dispatch and has no URL of its own.`,
3671 )
3672 : yield* resolveSelfUrl(name, news, accountId)
3673 : undefined;
3674 yield* Effect.logInfo(
3675 `Cloudflare Worker ${olds ? "update" : "create"}: preparing bundle for ${name}`,
3676 );
3677 // If the caller handed us a precomputed asset hash that matches
3678 // what we previously stored, we can skip walking the directory
3679 // entirely and tell Cloudflare to keep the assets it already
3680 // has bound to this script. The disk read is the expensive
3681 // part; the script PUT happens either way.
3682 const prebuiltAssets = normalizePrebuiltAssets(news.assets, output);
3683 const {
3684 assets,
3685 bundle,
3686 hash: preparedHash,
3687 } = yield* prepareAssetsAndBundle(id, fqn, name, news, {
3688 skipAssetsRead: prebuiltAssets?.skip,
3689 selfUrl,
3690 });
3691 // When the caller supplied a precomputed hash (e.g. via
3692 // `Command.Build`), store *that* hash in output state so the
3693 // next diff can short-circuit by comparing it directly. The
3694 // hash that `readAssets` produces is the manifest-derived
3695 // hash, which is shaped differently from any upstream
3696 // build-input hash and will never match it on the next pass.
3697 const metadataHash = yield* resolveWorkerMetadataHash({
3698 props: news,
3699 bindings,
3700 accountId,
3701 stack: { name: stack.name, stage: stack.stage },
3702 selfUrl,
3703 });
3704 const hash = {
3705 ...preparedHash,
3706 assets: prebuiltAssets?.hash ?? preparedHash.assets,
3707 metadata: metadataHash,
3708 } satisfies Worker["Attributes"]["hash"];
3709 // `transferredFrom` is alchemy-only transfer metadata on
3710 // durable_object_namespace bindings — it drives the
3711 // `transferred_classes` migration below and must be stripped from the
3712 // wire-shape binding before upload.
3713 const metadataBindings = bindings.flatMap((b) =>
3714 withoutDevOnlyBindings(b.data.bindings ?? []).map(
3715 (item): WireWorkerBinding => {
3716 // Lower the `Worker.URL` sentinel into the resolved URL —
3717 // Cloudflare has no native binding for it.
3718 if (item.type === "self_url") {
3719 return { type: "plain_text", name: item.name, text: selfUrl! };
3720 }
3721 // Lower the `Worker.Self` sentinel into a service
3722 // binding targeting this Worker's own physical name.
3723 if (item.type === "self_service") {
3724 return { type: "service", name: item.name, service: name };
3725 }
3726 if (
3727 item.type === "durable_object_namespace" &&
3728 item.transferredFrom !== undefined
3729 ) {
3730 const { transferredFrom: _, ...rest } = item;
3731 return rest;
3732 }
3733 // `queueId` (mode discrimination) and `shim` (dev-mode remote
3734 // producer) are alchemy-only metadata on queue bindings — strip
3735 // them from the wire shape.
3736 if (
3737 item.type === "queue" &&
3738 (item.queueId !== undefined || item.shim !== undefined)
3739 ) {
3740 const { queueId: _, shim: __, ...rest } = item;
3741 return rest;
3742 }
3743 return item;
3744 },
3745 ),
3746 );
3747 const expectedDurableObjectClassNames =
3748 getExpectedDurableObjectClassNames(metadataBindings, name);
3749 let metadataAssets:
3750 | workers.PutScriptRequest["metadata"]["assets"]
3751 | undefined;
3752 let keepAssets = false;
3753 if (prebuiltAssets?.skip) {
3754 // Hash matched what's already on Cloudflare: keep the
3755 // existing asset manifest and skip the upload session.
3756 yield* Effect.logInfo(
3757 `Cloudflare Worker update: assets unchanged for ${name}, keeping existing`,
3758 );
3759 keepAssets = true;
3760 // `keepAssets` only preserves the uploaded files — the PUT
3761 // replaces the asset config wholesale. The skip path never
3762 // walked the directory, so read just `_headers`/`_redirects`
3763 // here or a no-op deploy would wipe their rules.
3764 metadataAssets = {
3765 config: mergeAssetsConfigFiles(
3766 prebuiltAssets.config,
3767 yield* readAssetsConfigFiles(prebuiltAssets.directory),
3768 ),
3769 };
3770 metadataBindings.push({
3771 type: "assets",
3772 name: "ASSETS",
3773 });
3774 } else if (assets) {
3775 // We had to read the directory. Even after the read, the
3776 // computed hash may match what's already deployed (e.g.
3777 // legacy `string` / `AssetsProps` shapes that don't carry a
3778 // precomputed hash, or a precomputed hash that disagreed with
3779 // disk). In that case still keep the existing manifest and
3780 // skip the upload session — Cloudflare's content-addressed
3781 // session would no-op on every byte anyway.
3782 if (assets.hash === prebuiltAssets?.hash) {
3783 yield* Effect.logInfo(
3784 `Cloudflare Worker update: assets unchanged for ${name}, keeping existing`,
3785 );
3786 keepAssets = true;
3787 // Fold the build-emitted `_headers`/`_redirects` into the PUT
3788 // config: source providers (Astro/SvelteKit/Waku/Nuxt) hash the
3789 // files and carry them on the read result, but only `readAssets`
3790 // pre-merges them — without this, framework header/redirect
3791 // rules never reach Cloudflare. Idempotent for pre-merged
3792 // configs (explicit config wins).
3793 metadataAssets = {
3794 config: mergeAssetsConfigFiles(assets.config, assets),
3795 };
3796 } else {
3797 yield* Effect.logInfo(
3798 `Cloudflare Worker ${olds ? "update" : "create"}: uploading assets for ${name}`,
3799 );
3800 const { jwt } = yield* uploadAssets(
3801 accountId,
3802 name,
3803 assets,
3804 session,
3805 dispatchNamespace,
3806 );
3807 metadataAssets = {
3808 jwt,
3809 // Same `_headers`/`_redirects` fold as the keep path above.
3810 config: mergeAssetsConfigFiles(assets.config, assets),
3811 };
3812 }
3813 metadataBindings.push({
3814 type: "assets",
3815 name: "ASSETS",
3816 });
3817 }
3818 appendAlchemyAndEnvBindings(metadataBindings, news, accountId, name);
3819 yield* Effect.logInfo(
3820 `Cloudflare Worker ${olds ? "update" : "create"}: uploading script for ${name}`,
3821 );
3822 const size =
3823 bundle.files
3824 ?.filter((file) => !file.name.endsWith(".map"))
3825 .reduce((acc, file) => acc + file.size, 0) ?? 0;
3826 const sizeKB = size / 1024;
3827 const sizeMB = sizeKB / 1024;
3828 const bundleSize = `${sizeKB > 1024 ? `${sizeMB.toFixed(2)} MB` : `${sizeKB.toFixed(2)} KB`}`;
3829 yield* session.note(`Uploading worker (${bundleSize}) ...`, {
3830 kind: "status",
3831 });
3832
3833 // Read existing worker settings for migration tracking
3834 const oldSettings =
3835 existingSettings ??
3836 (yield* workers
3837 .getScriptScriptAndVersionSetting({
3838 accountId,
3839 scriptName: name,
3840 })
3841 .pipe(
3842 Effect.map((s) => s as typeof s | undefined),
3843 Effect.catch(() => Effect.succeed(undefined)),
3844 ));
3845
3846 const oldTags = Array.from(new Set(oldSettings?.tags ?? []));
3847 const oldBindings = oldSettings?.bindings ?? [];
3848
3849 // Parse the DO logical-id→class mapping from script tags (packed
3850 // `alchemy:dos:` and legacy per-DO `alchemy:do:` formats)
3851 const oldDoClassNameByLogicalId = getDurableObjectTagMap(oldTags);
3852 const currentDoBindings = getDurableObjectBindings(bindings, name);
3853 const currentDoClassNameByLogicalId = Object.fromEntries(
3854 currentDoBindings.map((binding) => [
3855 binding.logicalId,
3856 binding.className,
3857 ]),
3858 );
3859
3860 // Parse alchemy:migration-tag:{version}
3861 const oldMigrationTag = oldTags.flatMap((tag) =>
3862 tag.startsWith("alchemy:migration-tag:")
3863 ? [tag.slice("alchemy:migration-tag:".length)]
3864 : [],
3865 )[0];
3866 const newMigrationTag = bumpMigrationTagVersion(oldMigrationTag);
3867
3868 // Compute delete-class candidates. Candidates are validated against
3869 // observed namespace ownership below — a class may already have been
3870 // transferred to another script by its new host's deploy.
3871 const deletedClassCandidates: string[] = [];
3872 for (const [logicalId, className] of Object.entries(
3873 oldDoClassNameByLogicalId,
3874 )) {
3875 if (!currentDoClassNameByLogicalId[logicalId]) {
3876 deletedClassCandidates.push(className);
3877 }
3878 }
3879
3880 // Backward compatibility for old workers that have DO bindings but no
3881 // alchemy:do tags yet. Cross-script bindings (`scriptName` set to
3882 // anything other than this worker) are NEVER candidates for
3883 // delete-class migrations — the class lives on the foreign script
3884 // and we don't own its lifecycle.
3885 if (Object.keys(oldDoClassNameByLogicalId).length === 0) {
3886 for (const oldBinding of oldBindings) {
3887 const ownedLocally =
3888 !("scriptName" in oldBinding) || oldBinding.scriptName === name;
3889 if (
3890 oldBinding.type === "durable_object_namespace" &&
3891 "className" in oldBinding &&
3892 oldBinding.className &&
3893 ownedLocally &&
3894 !currentDoBindings.some(
3895 (binding) => binding.bindingName === oldBinding.name,
3896 )
3897 ) {
3898 deletedClassCandidates.push(oldBinding.className);
3899 }
3900 }
3901 }
3902
3903 // Class names the current deploy references *cross-script* (mapped to
3904 // the foreign script). A class that both leaves the "hosted here" set
3905 // and shows up here has moved to another Worker — Cloudflare rejects
3906 // deleting a class while any binding in the upload references its
3907 // class name, and deleting would destroy the namespace's data.
3908 const crossScriptClassTargets = new Map<string, string>();
3909 for (const item of metadataBindings) {
3910 if (
3911 item.type === "durable_object_namespace" &&
3912 item.className &&
3913 typeof item.scriptName === "string" &&
3914 item.scriptName !== name
3915 ) {
3916 crossScriptClassTargets.set(item.className, item.scriptName);
3917 }
3918 }
3919
3920 // One account-level namespace listing serves both sides of a
3921 // transfer: the destination checks the source still hosts the class
3922 // before emitting `transferred_classes`, and the former host checks
3923 // whether a to-be-deleted class was already transferred away.
3924 // Dispatch-namespace user workers keep the legacy behavior — their
3925 // namespaces don't surface on the account-level list.
3926 const mayTransferIn = currentDoBindings.some(
3927 (binding) =>
3928 !oldDoClassNameByLogicalId[binding.logicalId] &&
3929 binding.transferredFrom !== undefined,
3930 );
3931 const observedNamespaces =
3932 !dispatchNamespace &&
3933 (deletedClassCandidates.length > 0 || mayTransferIn)
3934 ? yield* listDurableObjectNamespaces(accountId)
3935 : [];
3936 const hosts = (
3937 namespaces: readonly { script: string; class: string }[],
3938 scriptName: string,
3939 className: string,
3940 ) =>
3941 namespaces.some(
3942 (ns) => ns.script === scriptName && ns.class === className,
3943 );
3944 const deletedClasses: string[] = [];
3945 for (const className of deletedClassCandidates) {
3946 if (dispatchNamespace) {
3947 deletedClasses.push(className);
3948 continue;
3949 }
3950 const targetScriptName = crossScriptClassTargets.get(className);
3951 const namespaceId =
3952 oldBindings.flatMap((binding) =>
3953 binding.type === "durable_object_namespace" &&
3954 "className" in binding &&
3955 binding.className === className &&
3956 (!("scriptName" in binding) ||
3957 binding.scriptName === undefined ||
3958 binding.scriptName === name) &&
3959 "namespaceId" in binding &&
3960 typeof binding.namespaceId === "string"
3961 ? [binding.namespaceId]
3962 : [],
3963 )[0] ??
3964 output?.durableObjectNamespaces?.[className] ??
3965 observedNamespaces.find(
3966 (ns) => ns.script === name && ns.class === className,
3967 )?.id;
3968 if (targetScriptName === undefined) {
3969 const findNamespace = (namespaces: typeof observedNamespaces) =>
3970 namespaces.find((ns) =>
3971 namespaceId === undefined
3972 ? ns.script === name && ns.class === className
3973 : ns.id === namespaceId,
3974 );
3975 // Absence from an account listing does not prove a transfer.
3976 const namespace =
3977 findNamespace(observedNamespaces) ??
3978 (yield* listDurableObjectNamespaces(accountId).pipe(
3979 Effect.flatMap((namespaces) => {
3980 const namespace = findNamespace(namespaces);
3981 return namespace
3982 ? Effect.succeed(namespace)
3983 : Effect.fail(
3984 new MissingDurableObjects({
3985 scriptName: name,
3986 expected: [className],
3987 }),
3988 );
3989 }),
3990 Effect.retry({
3991 while: (error) => error._tag === "MissingDurableObjects",
3992 schedule: Schedule.spaced("2 seconds"),
3993 times: 5,
3994 }),
3995 ));
3996 if (namespace.script === name && namespace.class === className) {
3997 deletedClasses.push(className);
3998 }
3999 continue;
4000 }
4001 // A missing listing is inconclusive; the original namespace must appear on the new host.
4002 const transferred = (namespaces: typeof observedNamespaces) =>
4003 namespaceId !== undefined &&
4004 namespaces.some(
4005 (ns) =>
4006 ns.id === namespaceId &&
4007 ns.script === targetScriptName &&
4008 ns.class === className,
4009 );
4010 const namespaces = yield* listDurableObjectNamespaces(accountId).pipe(
4011 Effect.repeat({
4012 schedule: Schedule.spaced("2 seconds"),
4013 until: (observed) =>
4014 transferred(observed) ||
4015 (hosts(observed, name, className) &&
4016 hosts(observed, targetScriptName, className)),
4017 times: 5,
4018 }),
4019 );
4020 if (transferred(namespaces)) {
4021 continue;
4022 }
4023 // local → cross-script transition without a transfer. Fail before
4024 // any upload: Cloudflare would reject the combined delete + binding
4025 // anyway, and silently deleting would destroy the namespace's
4026 // data. See the error's docs for the two ways out
4027 // (`transferredFrom` on the new host, or a two-phase removal).
4028 return yield* Effect.fail(
4029 new DurableObjectTransferRequired({
4030 scriptName: name,
4031 className,
4032 targetScriptName,
4033 }),
4034 );
4035 }
4036
4037 // Collect container-backed class names so we can send container metadata
4038 const containerClassNames = new Set(
4039 bindings.flatMap((b) =>
4040 (b.data.containers ?? []).map((c) => c.className),
4041 ),
4042 );
4043
4044 // Compute new, renamed, and transferred classes
4045 const newClasses: string[] = [];
4046 const newSqliteClasses: string[] = [];
4047 const renamedClasses: { from: string; to: string }[] = [];
4048 const transferredClasses: {
4049 from: string;
4050 fromScript: string;
4051 to: string;
4052 }[] = [];
4053 for (const binding of currentDoBindings) {
4054 let previousClassName: string | undefined =
4055 oldDoClassNameByLogicalId[binding.logicalId];
4056 if (!previousClassName) {
4057 // No DO metadata tag maps this logical id to a class — the
4058 // worker was created outside Alchemy (raw API / Wrangler) or
4059 // before these tags existed. Fall back to matching the observed
4060 // cloud binding by binding name so adoption reuses the existing
4061 // class instead of asking Cloudflare to create one that already
4062 // exists (which fails the migration). This is the "first deploy
4063 // must match the existing class name" path; once we write the
4064 // `alchemy:dos:` tag, subsequent renames are driven by logical id.
4065 const observed = oldBindings.find(
4066 (old) =>
4067 old.type === "durable_object_namespace" &&
4068 "className" in old &&
4069 old.className &&
4070 // Only a *locally-owned* binding proves the class exists on
4071 // this script. A cross-script binding under the same name —
4072 // e.g. this worker previously referenced another host's
4073 // class and now hosts its own — points at a foreign
4074 // namespace and must not suppress the create migration
4075 // (Cloudflare rejects a local binding for a class the
4076 // script isn't configured to implement).
4077 (!("scriptName" in old) ||
4078 old.scriptName === undefined ||
4079 old.scriptName === name) &&
4080 old.name === binding.bindingName,
4081 );
4082 if (observed && "className" in observed && observed.className) {
4083 previousClassName = observed.className;
4084 }
4085 }
4086 if (!previousClassName) {
4087 // A class new to this script is a host move when the declaration
4088 // says so: `transferredFrom` lists the former host(s) — moves
4089 // are always declared, never inferred, because a class deleted
4090 // on one worker and created on another is otherwise ambiguous
4091 // between "move the data" and "delete + fresh namespace". The
4092 // declared source must be observed to still host the namespace;
4093 // otherwise (fresh stage, transfer already completed) fall
4094 // through to a plain create.
4095 const fromScript = dispatchNamespace
4096 ? undefined
4097 : yield* resolveTransferSource({
4098 accountId,
4099 selfScriptName: name,
4100 logicalId: binding.logicalId,
4101 className: binding.className,
4102 sources: normalizeTransferSources(
4103 binding.transferredFrom,
4104 name,
4105 ),
4106 observedNamespaces,
4107 });
4108 if (fromScript !== undefined) {
4109 // Data-preserving move: ship Cloudflare's
4110 // `transferred_classes` migration instead of creating a
4111 // fresh class.
4112 transferredClasses.push({
4113 from: binding.className,
4114 fromScript,
4115 to: binding.className,
4116 });
4117 continue;
4118 }
4119 // Default all new Durable Object classes to SQLite. Cloudflare
4120 // recommends SQLite for new namespaces, and container-backed
4121 // Durable Objects require it.
4122 newSqliteClasses.push(binding.className);
4123 } else if (previousClassName !== binding.className) {
4124 renamedClasses.push({
4125 from: previousClassName,
4126 to: binding.className,
4127 });
4128 }
4129 }
4130
4131 yield* Effect.logInfo(
4132 `Cloudflare Worker put: durable object reconciliation ${JSON.stringify(
4133 {
4134 oldDoClassNameByLogicalId,
4135 currentDoClassNameByLogicalId,
4136 deletedClasses,
4137 renamedClasses,
4138 transferredClasses,
4139 newSqliteClasses,
4140 },
4141 )}`,
4142 );
4143
4144 // Pack every DO logical-id→class mapping into as few `alchemy:dos:`
4145 // tags as possible — one tag per DO blows Cloudflare's 10-tag limit
4146 // at 7+ bindings (#811).
4147 const alchemyDoTags = encodeDurableObjectTags(currentDoBindings);
4148
4149 const alchemyTags = [
4150 ...createAlchemyWorkerTags(id),
4151 ...alchemyDoTags,
4152 ...(newMigrationTag
4153 ? [`alchemy:migration-tag:${newMigrationTag}`]
4154 : []),
4155 ];
4156 const metadataTags = Array.from(
4157 new Set([...alchemyTags, ...(news.tags ?? [])]),
4158 );
4159 yield* validateWorkerTags(name, metadataTags, alchemyTags.length);
4160
4161 const migrations = {
4162 oldTag: oldMigrationTag,
4163 newTag: newMigrationTag,
4164 newClasses,
4165 deletedClasses,
4166 renamedClasses,
4167 transferredClasses,
4168 newSqliteClasses,
4169 };
4170
4171 const metadataContainers = [...containerClassNames].map(
4172 (className) => ({
4173 className,
4174 }),
4175 );
4176
4177 const compatibility = getCompatibility(news);
4178 const tailConsumers = resolveTailConsumers(news.tailConsumers);
4179 const streamingTailConsumers = resolveTailConsumers(
4180 news.streamingTailConsumers,
4181 );
4182 const metadata: workers.PutScriptRequest["metadata"] = {
4183 annotations: news.version
4184 ? {
4185 workersMessage: news.version.message,
4186 workersTag: news.version.tag,
4187 }
4188 : undefined,
4189 assets: metadataAssets,
4190 bindings: metadataBindings,
4191 bodyPart: undefined,
4192 cacheOptions: news.cache ?? getCacheBinding(bindings),
4193 compatibilityDate: compatibility.date,
4194 compatibilityFlags: compatibility.flags,
4195 containers:
4196 metadataContainers.length > 0 ? metadataContainers : undefined,
4197 keepAssets,
4198 keepBindings: undefined,
4199 limits: news.limits,
4200 logpush: news.logpush,
4201 mainModule: bundle.main,
4202 migrations,
4203 observability: resolveObservability(news, bindings),
4204 placement: news.placement,
4205 tags: metadataTags,
4206 tailConsumers,
4207 streamingTailConsumers,
4208 usageModel: undefined,
4209 };
4210 const rolloutTraffic = getSelfRolloutTraffic(news);
4211 let versionId: string | undefined;
4212 let deploymentId: string | undefined;
4213 let worker: {
4214 id?: string | null;
4215 logpush?: boolean | null;
4216 /** The immutable script id (Cloudflare's script "tag"). */
4217 tag?: string | null;
4218 };
4219 // A gradual rollout (`version.traffic` < 100) deploys through the
4220 // versions API instead of the full-cutover script PUT. That's only
4221 // possible when the script already has a live deployment to split
4222 // traffic against (otherwise the first deploy takes 100%), and only
4223 // for changes a version can carry — code, bindings, compatibility
4224 // settings, cache. Script-level settings in the upload metadata
4225 // (tags, observability, limits, placement, logpush) keep their live
4226 // values until the next full deploy, matching
4227 // `wrangler versions upload` semantics.
4228 if (
4229 rolloutTraffic !== undefined &&
4230 existingSettings !== undefined &&
4231 output?.hash !== undefined &&
4232 !dispatchNamespace
4233 ) {
4234 const migratedClasses = [
4235 ...migrations.newClasses,
4236 ...migrations.newSqliteClasses,
4237 ...migrations.deletedClasses,
4238 ...migrations.renamedClasses.map((r) => r.to),
4239 ...migrations.transferredClasses.map((t) => t.to),
4240 ];
4241 if (migratedClasses.length > 0) {
4242 return yield* Effect.fail(
4243 new WorkerVersionConfigError({
4244 message: `This deploy of '${name}' changes Durable Object classes (${migratedClasses.join(", ")}), which requires a migration — migrations cannot ride a gradual rollout. Deploy at 100% (remove version.traffic) first, then resume gradual rollouts.`,
4245 }),
4246 );
4247 }
4248 yield* session.note(
4249 `Uploading version of ${name} (${bundleSize}) ...`,
4250 { kind: "status" },
4251 );
4252 const created = yield* workers
4253 .createScriptVersion({
4254 accountId,
4255 scriptName: name,
4256 metadata: {
4257 mainModule: metadata.mainModule!,
4258 assets: metadata.assets,
4259 bindings: metadata.bindings,
4260 keepAssets: metadata.keepAssets,
4261 compatibilityDate: metadata.compatibilityDate,
4262 compatibilityFlags: metadata.compatibilityFlags,
4263 cacheOptions: metadata.cacheOptions,
4264 annotations:
4265 news.version?.alias !== undefined ||
4266 news.version?.message !== undefined ||
4267 news.version?.tag !== undefined
4268 ? {
4269 workersAlias: news.version?.alias,
4270 workersMessage: news.version?.message,
4271 workersTag: news.version?.tag,
4272 }
4273 : undefined,
4274 },
4275 files: bundle.files,
4276 })
4277 .pipe(
4278 Effect.retry({
4279 while: isBindingTargetNotFound,
4280 schedule: bindingTargetNotFoundRetrySchedule(),
4281 }),
4282 );
4283 versionId = created.id ?? undefined;
4284 if (versionId === undefined) {
4285 return yield* Effect.fail(
4286 new WorkerVersionConfigError({
4287 message: `Cloudflare did not return a version id for the uploaded version of '${name}'.`,
4288 }),
4289 );
4290 }
4291 if (rolloutTraffic > 0) {
4292 yield* session.note(
4293 `Deploying version at ${rolloutTraffic}% of traffic ...`,
4294 { kind: "status" },
4295 );
4296 deploymentId = yield* deployVersionTraffic({
4297 accountId,
4298 scriptName: name,
4299 versionId,
4300 traffic: rolloutTraffic,
4301 message: news.version?.message,
4302 });
4303 }
4304 worker = { id: name, logpush: existingSettings.logpush };
4305 } else {
4306 if (rolloutTraffic !== undefined) {
4307 // First deploy of this script (or a pre-create placeholder is
4308 // the only thing live) — there is no previous version worth
4309 // splitting traffic with, so this deploy takes 100%.
4310 yield* Effect.logInfo(
4311 `Cloudflare Worker ${name}: no previous live version to split traffic with; deploying at 100%`,
4312 );
4313 }
4314 worker = yield* putWorkerScriptWithMigrationRecovery();
4315 }
4316
4317 function putWorkerScriptWithMigrationRecovery() {
4318 return putWorkerScript({
4319 accountId,
4320 scriptName: name,
4321 dispatchNamespace,
4322 metadata,
4323 files: bundle.files,
4324 }).pipe(
4325 Effect.catch((err) => {
4326 // When adopting a Worker managed by Wrangler (or after a previous
4327 // deploy with mismatched migrations), the old_tag precondition
4328 // fails. The only way to discover the actual tag is through the
4329 // error message — getScriptSettings is meant to return it but
4330 // doesn't at runtime.
4331 const msg = String(
4332 typeof err === "object" && err !== null && "message" in err
4333 ? err.message
4334 : err,
4335 );
4336 const expectedTag = msg.match(
4337 /when expected tag is ['"]?([^'"]+)['"]?/,
4338 )?.[1];
4339 if (expectedTag) {
4340 return putWorkerScript({
4341 accountId,
4342 scriptName: name,
4343 dispatchNamespace,
4344 metadata: {
4345 ...metadata,
4346 migrations: {
4347 ...migrations,
4348 oldTag: expectedTag,
4349 newTag: bumpMigrationTagVersion(expectedTag),
4350 },
4351 },
4352 files: bundle.files,
4353 });
4354 }
4355 // @effect-diagnostics-next-line anyUnknownInErrorContext:off
4356 return Effect.fail(err as any);
4357 }),
4358 );
4359 }
4360 const { settings, durableObjectNamespaces } =
4361 yield* getWorkerSettingsWithDurableObjects(
4362 name,
4363 expectedDurableObjectClassNames,
4364 dispatchNamespace,
4365 );
4366 // Workers for Platforms user workers are invoked via dynamic dispatch,
4367 // never routed directly — they have no workers.dev subdomain, custom
4368 // domains, zone routes, or cron triggers. Skip all of that
4369 // reconciliation.
4370 if (dispatchNamespace) {
4371 return {
4372 workerId:
4373 worker.tag ??
4374 cachedWorkerId(output?.workerId, name) ??
4375 (yield* Effect.fail(
4376 new WorkerIdNotFound({
4377 scriptName: name,
4378 message: `Cloudflare Worker: the dispatch-namespace upload for '${name}' did not return the script's immutable ID`,
4379 }),
4380 )),
4381 workerName: name,
4382 namespace: dispatchNamespace,
4383 logpush: worker.logpush ?? undefined,
4384 url: undefined,
4385 tags: settings.tags ?? metadata.tags,
4386 durableObjectNamespaces,
4387 accountId,
4388 urls: [],
4389 domain: undefined,
4390 routes: [],
4391 crons: [],
4392 tailConsumers:
4393 settings.tailConsumers?.map((c) => ({ service: c.service })) ??
4394 tailConsumers,
4395 // The settings read endpoint doesn't expose
4396 // `streaming_tail_consumers`; record what this deploy uploaded.
4397 streamingTailConsumers,
4398 hash,
4399 } satisfies Worker["Attributes"];
4400 }
4401 // Reconcile the workers.dev settings against observed cloud state.
4402 // We can't diff `news.workersDev` against `olds.workersDev` here
4403 // because both default to `undefined` (meaning "enable") — that
4404 // comparison would skip the API call on every deploy where the user
4405 // never set the prop, leaving the subdomain in whatever state
4406 // Cloudflare currently has it (disabled by default, or whatever
4407 // a previous failed/external action left it as).
4408 const workersDev = resolveWorkersDev(news.workersDev);
4409 const observedSubdomain = yield* workers
4410 .getScriptSubdomain({
4411 accountId,
4412 scriptName: name,
4413 })
4414 .pipe(
4415 Effect.orElseSucceed((): workers.GetScriptSubdomainResponse => ({
4416 enabled: false,
4417 previewsEnabled: false,
4418 })),
4419 );
4420 if (
4421 workersDev.enabled !== observedSubdomain.enabled ||
4422 workersDev.previewsEnabled !== observedSubdomain.previewsEnabled
4423 ) {
4424 yield* session.note(
4425 `${workersDev.enabled || workersDev.previewsEnabled ? "Enabling" : "Disabling"} workers.dev subdomain...`,
4426 { kind: "status" },
4427 );
4428 // Cloudflare's script registry is eventually consistent — for the
4429 // first few hundred ms after `putScript` returns, POST /subdomain
4430 // can still get back `WorkerNotFound` (a generic "unknown error"
4431 // body), or a bare 500 surfaced as `InternalServerError` /
4432 // `UnknownCloudflareError` (code 10013). Bigger uploads race harder.
4433 // Retry the subdomain toggle on those transient tags with a short
4434 // exponential backoff; same pattern we use elsewhere in this
4435 // provider for DO-namespace propagation and for `putScript` itself.
4436 yield* setWorkerSubdomain(name, workersDev).pipe(
4437 Effect.retry({
4438 while: (error) =>
4439 error._tag === "WorkerNotFound" ||
4440 error._tag === "InternalServerError" ||
4441 error._tag === "UnknownCloudflareError",
4442 schedule: Schedule.max([
4443 Schedule.exponential(200),
4444 Schedule.recurs(15),
4445 ]),
4446 }),
4447 );
4448 }
4449 // Custom domains are managed only when `domain` is declared on props
4450 // (#942): an omitted `domain` leaves live attachments alone — and
4451 // spares a plain workers.dev Worker the listDomains call on every
4452 // deploy (#926) — while `domain: null` explicitly detaches
4453 // everything. When managed, the canonical name + aliases + redirect
4454 // hostnames are all attached (DNS + edge certificate); redirect
4455 // hostnames additionally get a redirect rule, which runs before the
4456 // Worker so those requests never invoke it. Unmanaged domains
4457 // persisted in state carry forward so read keeps observing them.
4458 const manageCustomDomains = news.domain !== undefined;
4459 const domainConfig = yield* resolveWorkerDomain(news.domain);
4460 const previousDomain = stateWorkerDomain(output);
4461 let effectiveDomain = manageCustomDomains
4462 ? domainConfig
4463 : previousDomain;
4464 if (!manageCustomDomains && previousDomain !== undefined) {
4465 // Unmanaged, but state remembers domains: observe which of them
4466 // are actually still attached and carry only those forward —
4467 // stale state entries (e.g. legacy records whose attach never
4468 // happened, or hostnames detached out-of-band) must not
4469 // resurface in `urls`. Plain workers.dev Workers (no persisted
4470 // domains) never pay this listDomains call (#926).
4471 const live = new Set(
4472 yield* workers.listDomains({ accountId, service: name }).pipe(
4473 Effect.map((r) =>
4474 (r.result ?? []).flatMap((d) =>
4475 d.hostname ? [d.hostname] : [],
4476 ),
4477 ),
4478 Effect.catch(() => Effect.succeed([] as string[])),
4479 ),
4480 );
4481 const serving = [
4482 ...(live.has(previousDomain.name) ? [previousDomain.name] : []),
4483 ...previousDomain.aliases.filter((h) => live.has(h)),
4484 ];
4485 effectiveDomain =
4486 serving.length > 0
4487 ? {
4488 name: serving[0],
4489 aliases: serving.slice(1),
4490 redirects: previousDomain.redirects.filter((h) =>
4491 live.has(h),
4492 ),
4493 }
4494 : undefined;
4495 }
4496 if (manageCustomDomains) {
4497 const desiredHostnames = domainConfig
4498 ? [
4499 domainConfig.name,
4500 ...domainConfig.aliases,
4501 ...domainConfig.redirects,
4502 ]
4503 : [];
4504 yield* session.note(
4505 `Reconciling custom domains (${desiredHostnames.length}) ...`,
4506 { kind: "status" },
4507 );
4508 // Capture hostname → zone for *currently attached* domains before
4509 // reconcile detaches removed ones — a removed redirect hostname's
4510 // zone is otherwise unresolvable and its rule couldn't be cleaned.
4511 const liveBeforeReconcile = yield* workers
4512 .listDomains({ accountId, service: name })
4513 .pipe(
4514 Effect.map((r) =>
4515 (r.result ?? []).flatMap((d) =>
4516 d.hostname && d.zoneId
4517 ? [[d.hostname, d.zoneId] as const]
4518 : [],
4519 ),
4520 ),
4521 Effect.catch(() => Effect.succeed([])),
4522 );
4523 const reconciled = yield* reconcileDomains(
4524 name,
4525 desiredHostnames,
4526 domainConfig?.zone,
4527 domainConfig?.previews,
4528 );
4529 const zoneIdByHostname = new Map([
4530 ...liveBeforeReconcile,
4531 ...reconciled.map((d) => [d.hostname, d.zoneId] as const),
4532 ]);
4533 yield* reconcileRedirectRules({
4534 scriptName: name,
4535 domain: domainConfig,
4536 zoneIdByHostname,
4537 previousRedirects: previousDomain?.redirects ?? [],
4538 });
4539 effectiveDomain = domainConfig;
4540 }
4541 const urls = yield* computeWorkerUrls({
4542 scriptName: name,
4543 workersDev,
4544 domain: effectiveDomain,
4545 uploadedVersionId: versionId,
4546 uploadedVersionAlias: news.version?.alias,
4547 });
4548 const desiredRoutes = yield* normalizeRoutes(news.routes);
4549 const previousRoutes = output?.routes ?? [];
4550 if (desiredRoutes.length > 0 || previousRoutes.length > 0) {
4551 yield* session.note(
4552 `Reconciling worker routes (${desiredRoutes.length}) ...`,
4553 { kind: "status" },
4554 );
4555 }
4556 const routes = yield* reconcileRoutes(
4557 name,
4558 desiredRoutes,
4559 previousRoutes,
4560 );
4561 // Version-affinity transform rules (`version.affinity`): pin each
4562 // user to one version during gradual rollouts by filling the
4563 // version-key header from a stable request property, on every zone
4564 // this Worker serves on. Runs after the domain/route reconciles so
4565 // freshly attached surfaces resolve their zones, and also when only
4566 // *previous* state holds rules, so removing affinity converges.
4567 const affinity =
4568 news.version?.parent == null ? news.version?.affinity : undefined;
4569 const previousAffinityZoneIds = output?.affinityZoneIds ?? [];
4570 let affinityZoneIds: string[] | undefined;
4571 if (affinity !== undefined || previousAffinityZoneIds.length > 0) {
4572 const resolvedAffinity =
4573 affinity !== undefined
4574 ? yield* resolveVersionAffinity(affinity)
4575 : undefined;
4576 let hostsByZone: Map<string, AffinityZoneHost[]> | undefined;
4577 if (resolvedAffinity !== undefined) {
4578 hostsByZone = new Map();
4579 const addHost = (zoneId: string, host: AffinityZoneHost) => {
4580 const hosts = hostsByZone!.get(zoneId) ?? [];
4581 hosts.push(host);
4582 hostsByZone!.set(zoneId, hosts);
4583 };
4584 // Serving hostnames from *observed* domain attachments (covers
4585 // managed, unmanaged, and adopted domains alike); redirect
4586 // hostnames 301 before the Worker and never need a key.
4587 const redirectHosts = new Set(effectiveDomain?.redirects ?? []);
4588 const attached = yield* workers
4589 .listDomains({ accountId, service: name })
4590 .pipe(Effect.map((r) => r.result ?? []));
4591 for (const d of attached) {
4592 if (!d.hostname || !d.zoneId || redirectHosts.has(d.hostname)) {
4593 continue;
4594 }
4595 addHost(d.zoneId, { host: d.hostname, wildcard: false });
4596 }
4597 for (const route of routes) {
4598 const host = route.pattern.split("/")[0];
4599 if (!host || host.includes('"')) continue;
4600 addHost(route.zoneId, { host, wildcard: host.includes("*") });
4601 }
4602 if (hostsByZone.size === 0) {
4603 return yield* Effect.fail(
4604 new WorkerVersionConfigError({
4605 message:
4606 `version.affinity pins users via a zone Transform Rule setting the ${AFFINITY_HEADER} header, which only sees zone traffic — give '${name}' a custom domain (\`domain\`) or zone \`routes\`. ` +
4607 `On the bare workers.dev URL, clients must send the header themselves.`,
4608 }),
4609 );
4610 }
4611 yield* session.note("Reconciling version-affinity rules ...", {
4612 kind: "status",
4613 });
4614 }
4615 const placed = yield* reconcileAffinityRules({
4616 scriptName: name,
4617 desired:
4618 resolvedAffinity !== undefined && hostsByZone !== undefined
4619 ? { affinity: resolvedAffinity, hostsByZone }
4620 : undefined,
4621 previousZoneIds: previousAffinityZoneIds,
4622 });
4623 affinityZoneIds = placed.length > 0 ? placed : undefined;
4624 }
4625 const desiredCrons = normalizeCrons([
4626 ...getCronBindings(bindings),
4627 ...(news.crons ?? []),
4628 ]);
4629 const previousCrons = output?.crons ?? [];
4630 // Same gating as read: skip getScriptSchedule when neither props nor
4631 // prior state indicate cron management (#926).
4632 const crons =
4633 desiredCrons.length > 0 || previousCrons.length > 0
4634 ? yield* reconcileCrons(name, desiredCrons, previousCrons, session)
4635 : [];
4636 return {
4637 // The immutable script ID. The gradual-rollout branch deploys via
4638 // the versions API (no script PUT response), and rows persisted by
4639 // older releases carried the script *name* here — both fall back
4640 // to a lazy listing lookup.
4641 workerId:
4642 worker.tag ??
4643 cachedWorkerId(output?.workerId, name) ??
4644 (yield* findWorkerId(accountId, name)),
4645 workerName: name,
4646 namespace: undefined,
4647 logpush: worker.logpush ?? undefined,
4648 url: urls[0],
4649 urls,
4650 domain: effectiveDomain,
4651 tags: settings.tags ?? metadata.tags,
4652 durableObjectNamespaces,
4653 accountId,
4654 routes,
4655 crons,
4656 // Observed post-upload settings are authoritative: the gradual
4657 // rollout branch above deploys via the versions API, which leaves
4658 // script-level settings (tail consumers included) at their live
4659 // values until the next full deploy.
4660 tailConsumers:
4661 settings.tailConsumers?.map((c) => ({ service: c.service })) ??
4662 tailConsumers,
4663 // GET script-settings has no `streaming_tail_consumers` field (the
4664 // API only carries it on upload metadata), so the uploaded value is
4665 // authoritative here. In the gradual-rollout branch the versions
4666 // API leaves script-level settings live-as-is, matching how the
4667 // metadata surface treats every other script-level field.
4668 streamingTailConsumers,
4669 versionOf: undefined,
4670 versionId,
4671 deploymentId,
4672 affinityZoneIds,
4673 hash,
4674 } satisfies Worker["Attributes"];
4675 });
4676
4677 // Compare the desired assets against the deployed asset-content hash.
4678 //
4679 // For `AssetsWithHash` (the documented `Command.Build` contract) the
4680 // upstream build already produced an authoritative hash — compare
4681 // strings without touching the filesystem. Reading the directory here
4682 // would crash when the prior state was written on a different machine
4683 // and the path doesn't exist locally, blocking any local reapply even
4684 // though the precomputed hash is right there in props.
4685 //
4686 // For the plain `string` / `AssetsProps` shapes there is no hash in
4687 // props, so read + hash the directory exactly like the apply path does
4688 // (`readAssets`) and compare against the stored content hash — an
4689 // unchanged tree converges to a noop plan instead of a forever-dirty
4690 // conservative update. The overall read cost is unchanged: previously
4691 // every plan reported "changed" and `putWorker` read the tree during
4692 // apply only to discover nothing changed (`keepAssets`); now the diff
4693 // reads it and a match skips reconcile entirely. A directory that is
4694 // missing or invalid at plan time (e.g. produced by an upstream build
4695 // step that only runs during apply) degrades to "changed" — apply
4696 // reads it once and either uploads or surfaces the real typed error.
4697 const assetsChanged = Effect.fn(function* (
4698 assets: WorkerProps["assets"],
4699 output: Worker["Attributes"],
4700 ) {
4701 if (!assets) {
4702 return false;
4703 }
4704 // An explicitly-undefined `hash` (`{ directory, hash: maybe }`) is
4705 // the hash-less shape, not a supplied hash — fall through to the
4706 // directory read instead of comparing undefined forever-dirty.
4707 if (
4708 Predicate.hasProperty(assets, "hash") &&
4709 assets.hash !== undefined
4710 ) {
4711 return assets.hash !== output.hash?.assets;
4712 }
4713 const read = yield* prepareAssets(assets).pipe(
4714 Effect.catchTag(
4715 ["PlatformError", "AssetTooLargeError", "TooManyAssetsError"],
4716 () => Effect.succeed(undefined),
4717 ),
4718 );
4719 return read === undefined || read.hash !== output.hash?.assets;
4720 });
4721
4722 const hasChanged = Effect.fn(function* (
4723 id: string,
4724 fqn: string,
4725 props: WorkerProps,
4726 output: Worker["Attributes"],
4727 bindings: readonly ResourceBinding<Worker["Binding"]>[] | undefined,
4728 accountId: string,
4729 ) {
4730 // #745: metadata-only edits (compatibility, observability, placement,
4731 // limits, logpush, env literals, bindings, subdomain config, tags)
4732 // don't touch the bundle/vite/asset-content hashes below, so compare a
4733 // hash of that surface first. Skipped when bindings are still
4734 // unresolved: the hash can't be computed deterministically here, and
4735 // the eventual apply stores it once bindings resolve.
4736 if (bindings) {
4737 // For a version worker, `Worker.URL` resolves to the *aliased
4738 // preview URL* (see `putWorkerVersion`) — recompute the same
4739 // value here so the metadata hash matches what the apply stored
4740 // and unchanged deploys stay noops.
4741 const versionParent =
4742 props.version?.parent != null
4743 ? (resolveVersionParentName(props.version) ?? output.versionOf)
4744 : undefined;
4745 const previewParent =
4746 props.preview?.of != null
4747 ? (resolvePreviewParentName(props.preview) ?? output.previewOf)
4748 : undefined;
4749 const selfUrl = hasSelfUrlBinding(bindings)
4750 ? previewParent !== undefined
4751 ? output.url
4752 : versionParent !== undefined
4753 ? yield* Effect.gen(function* () {
4754 const alias = yield* resolveVersionAlias(
4755 id,
4756 props,
4757 versionParent,
4758 );
4759 return alias !== undefined
4760 ? `https://${alias}-${versionParent}.${yield* getAccountSubdomain(accountId)}.workers.dev`
4761 : undefined;
4762 })
4763 : yield* resolveSelfUrl(output.workerName, props, accountId)
4764 : undefined;
4765 const metadataHash = yield* resolveWorkerMetadataHash({
4766 props,
4767 bindings,
4768 accountId,
4769 stack: { name: stack.name, stage: stack.stage },
4770 selfUrl,
4771 });
4772 if (metadataHash !== output.hash?.metadata) {
4773 return true;
4774 }
4775 }
4776 // External source provider: the source recomputes the hash slots
4777 // it owns (without building where it can) and any defined slot
4778 // that differs from state means an update. `additionalWorkspaces`
4779 // is auxiliary metadata for the input hash, never a change signal.
4780 if (props.source) {
4781 const source = yield* resolveSource(props);
4782 const slots = yield* source.hash(
4783 makeSourceContext({
4784 dotAlchemy,
4785 id,
4786 fqn,
4787 workerName: output.workerName,
4788 props,
4789 compatibility: getCompatibility(props),
4790 stack: { name: stack.name, stage: stack.stage },
4791 }),
4792 output.hash,
4793 );
4794 for (const slot of ["bundle", "input", "assets"] as const) {
4795 if (
4796 slots[slot] !== undefined &&
4797 slots[slot] !== output.hash?.[slot]
4798 ) {
4799 return true;
4800 }
4801 }
4802 if (source.ownsAssets) {
4803 // Source-owned assets are covered by the `input` hash.
4804 return false;
4805 }
4806 if (!props.assets) {
4807 return false;
4808 }
4809 const assetsHash = Predicate.hasProperty(props.assets, "hash")
4810 ? props.assets.hash
4811 : undefined;
4812 if (assetsHash === undefined) {
4813 return true;
4814 }
4815 return assetsHash !== output.hash?.assets;
4816 }
4817 if (props.script !== undefined) {
4818 const scriptHash = yield* hashScript(props.script);
4819 if (scriptHash !== output.hash?.bundle) {
4820 return true;
4821 }
4822 return yield* assetsChanged(props.assets, output);
4823 }
4824 if (props.vite) {
4825 const Vite = yield* loadVite;
4826 const { hash } = yield* Vite.hashViteInput(
4827 props.vite.rootDir,
4828 props.vite.memo,
4829 Effect.succeed(output.hash?.additionalWorkspaces ?? []),
4830 );
4831 return hash !== output.hash?.input;
4832 }
4833 // Assets-only Worker — there is no bundle to hash. A stored bundle
4834 // hash means the Worker previously had a script and is being
4835 // converted to assets-only, which must deploy.
4836 if (props.main === undefined) {
4837 if (output.hash?.bundle !== undefined) {
4838 return true;
4839 }
4840 // No assets either — the config is invalid; report "changed" so
4841 // the apply runs and surfaces the descriptive error.
4842 if (!props.assets) {
4843 return true;
4844 }
4845 return yield* assetsChanged(props.assets, output);
4846 }
4847 const bundleHash = yield* prepareBundle(id, fqn, props).pipe(
4848 Effect.map((b) => b.hash),
4849 );
4850 if (bundleHash !== output.hash?.bundle) {
4851 return true;
4852 }
4853 return yield* assetsChanged(props.assets, output);
4854 });
4855
4856 return Worker.Provider.of({
4857 stables: ["workerId", "workerName"],
4858 list: () =>
4859 Effect.gen(function* () {
4860 const { accountId } = yield* yield* CloudflareEnvironment;
4861 // Account-scoped enumeration of every Worker script. The
4862 // per-script `read` makes several extra calls (subdomain,
4863 // settings, domains, schedule) to fully hydrate
4864 // url/durableObjectNamespaces/domains/crons. Doing that for
4865 // every script on the account is both expensive (4 calls × N)
4866 // and fragile — a single script with a binding shape the
4867 // settings schema doesn't know about would break the whole
4868 // listing (the same reason `read` deliberately avoids
4869 // `listScripts`). For `list()` we hydrate the core identifying
4870 // and settings fields that come straight from the script
4871 // metadata and leave the binding-derived fields at the same
4872 // defaults `read` returns when those sub-resources are absent
4873 // (`url: undefined`, `durableObjectNamespaces: {}`,
4874 // `domains: []`, `crons: []`). `accountId` + `workerName` are
4875 // sufficient for `delete`.
4876 return yield* workers.listScripts.pages({ accountId }).pipe(
4877 Stream.runCollect,
4878 Effect.map((chunk) =>
4879 Array.from(chunk).flatMap((page) =>
4880 // Annotate the element type as the full `Attributes` shape
4881 // (incl. the optional `hash`) so it matches `read` exactly.
4882 // `list()` is an inference source for the provider's resource
4883 // type; a narrower element (e.g. via `satisfies`, which omits
4884 // `hash`) would derail `Res` inference and cascade every
4885 // lifecycle method's requirement channel to `never`.
4886 (page.result ?? []).flatMap(
4887 (script): Worker["Attributes"][] =>
4888 script.id
4889 ? [
4890 {
4891 accountId,
4892 // Practically always present; an empty value
4893 // is healed by the per-script read.
4894 workerId: script.tag ?? "",
4895 workerName: script.id,
4896 namespace: undefined,
4897 logpush: script.logpush ?? undefined,
4898 url: undefined,
4899 tags: script.tags ?? undefined,
4900 durableObjectNamespaces: {},
4901 urls: [],
4902 domain: undefined,
4903 routes: [],
4904 crons: [],
4905 },
4906 ]
4907 : [],
4908 ),
4909 ),
4910 ),
4911 );
4912 }),
4913 diff: Effect.fn(function* ({
4914 id,
4915 fqn,
4916 news: desired,
4917 olds,
4918 output,
4919 newBindings,
4920 }) {
4921 const { accountId } = yield* yield* CloudflareEnvironment;
4922 // Effect-valued `env` entries (tagged Worker classes / resource
4923 // Effects — the circular-bindings pattern) never resolve at plan
4924 // time and are dropped from persisted props by `stripUnresolved`
4925 // at the commit boundary. Their deploy-time identity is carried by
4926 // the resolved binding data, which the metadata hash below covers.
4927 // Strip them before the resolution check so a tag in `env` doesn't
4928 // force the raw-props fallback — which compares the Effect's JSON
4929 // form against the stripped stored props and re-plans an update
4930 // forever (#874).
4931 const news = stripEffects(desired);
4932 if (!isResolved(news)) return undefined;
4933 if ((output?.accountId ?? accountId) !== accountId) {
4934 return { action: "replace" };
4935 }
4936 // An account-level script and a dispatch-namespace ("user worker")
4937 // script are distinct cloud resources; moving a Worker into, out of,
4938 // or between namespaces requires a replacement.
4939 const newNamespace = resolveNamespaceName(news.namespace);
4940 const oldNamespace =
4941 output?.namespace ?? resolveNamespaceName(olds?.namespace);
4942 if (newNamespace !== oldNamespace) {
4943 return { action: "replace" };
4944 }
4945 // A version worker (version.parent), a Preview Worker
4946 // (preview.of), and a script-owning Worker are distinct cloud
4947 // resources — switching mode or parent is a replacement.
4948 const newIsVersion = news.version?.parent != null;
4949 const oldIsVersion =
4950 output?.versionOf !== undefined || olds?.version?.parent != null;
4951 if (newIsVersion !== oldIsVersion) {
4952 return { action: "replace" };
4953 }
4954 if (newIsVersion) {
4955 const newParent = resolveVersionParentName(news.version);
4956 const oldParent =
4957 output?.versionOf ?? resolveVersionParentName(olds?.version);
4958 if (
4959 newParent !== undefined &&
4960 oldParent !== undefined &&
4961 newParent !== oldParent
4962 ) {
4963 return { action: "replace" };
4964 }
4965 }
4966 const newIsPreview = news.preview?.of != null;
4967 const oldIsPreview =
4968 output?.previewOf !== undefined || olds?.preview?.of != null;
4969 if (newIsPreview !== oldIsPreview) {
4970 return { action: "replace" };
4971 }
4972 if (newIsPreview) {
4973 const newParent = resolvePreviewParentName(news.preview);
4974 const oldParent =
4975 output?.previewOf ?? resolvePreviewParentName(olds?.preview);
4976 if (
4977 newParent !== undefined &&
4978 oldParent !== undefined &&
4979 newParent !== oldParent
4980 ) {
4981 return { action: "replace" };
4982 }
4983 }
4984 const oldWorkerName =
4985 output?.workerName ?? (yield* createWorkerName(id, olds?.name));
4986 // Auto-generated names are engine-owned: the deployed name stays
4987 // authoritative even if the generator would name this id
4988 // differently today (a Worker replace would also destroy its
4989 // Durable Object storage). Only an explicit user-provided name
4990 // can force a replace.
4991 const workerName = news.name ?? oldWorkerName;
4992 if (workerName !== oldWorkerName) {
4993 return { action: "replace" };
4994 }
4995 if (!output) {
4996 return;
4997 }
4998 // Compare the full domain config — name, aliases (ordered: they
4999 // drive `urls` order), and redirects — against persisted state
5000 // (with legacy `domains`-list state migrated on the fly).
5001 const newDomainConfig = yield* resolveWorkerDomain(news.domain);
5002 const oldDomainConfig = stateWorkerDomain(output);
5003 const domainKey = (d: ResolvedWorkerDomain | undefined) =>
5004 d === undefined
5005 ? ""
5006 : JSON.stringify([
5007 d.name,
5008 d.aliases,
5009 [...d.redirects].sort(),
5010 d.zone ?? null,
5011 d.previews === true,
5012 ]);
5013 // An omitted `domain` unmanages the surface (#942): reconcile
5014 // carries previously-observed custom domains forward in state, so
5015 // comparing the empty desired config against those would report a
5016 // dirty plan on every deploy, forever. Only a declared `domain`
5017 // (including `null`, the explicit detach-all) participates.
5018 const domainsChanged =
5019 news.domain !== undefined &&
5020 domainKey(newDomainConfig) !== domainKey(oldDomainConfig);
5021 const newCrons = normalizeCrons([
5022 ...(Array.isArray(newBindings)
5023 ? getCronBindings(
5024 newBindings as ResourceBinding<Worker["Binding"]>[],
5025 )
5026 : []),
5027 ...(news.crons ?? []),
5028 ]).sort();
5029 const oldCrons = [...(output?.crons ?? [])].sort();
5030 const cronsChanged =
5031 newCrons.length !== oldCrons.length ||
5032 newCrons.some((cron, index) => cron !== oldCrons[index]);
5033 const newRouteKeys = (yield* normalizeRoutes(news.routes))
5034 .map(routeKey)
5035 .sort();
5036 const oldRouteKeys = (output?.routes ?? []).map(routeKey).sort();
5037 const routesChanged =
5038 newRouteKeys.length !== oldRouteKeys.length ||
5039 newRouteKeys.some((key, index) => key !== oldRouteKeys[index]);
5040 // `url` is `urls[0]`: the canonical custom domain when one is
5041 // configured (or carried forward from state when the surface is
5042 // unmanaged, #942/#975), else the stable workers.dev URL. It's
5043 // stable across this update exactly when the recomputed value
5044 // matches what's deployed. Carry it forward as a stable only
5045 // then, so downstream resources that reference `worker.url`
5046 // (e.g. a GitHub Webhook delivery URL built via
5047 // `Output.interpolate`) resolve it to a concrete value during
5048 // planning instead of an unresolved Output — otherwise every
5049 // worker update spuriously re-updates them. Previews-only mode
5050 // and version workers derive `url` from version preview URLs —
5051 // never assumed stable here.
5052 const newWorkersDev = resolveWorkersDev(news.workersDev);
5053 const effectiveDomainConfig =
5054 news.domain !== undefined ? newDomainConfig : oldDomainConfig;
5055 const newUrl = newIsVersion
5056 ? undefined
5057 : effectiveDomainConfig !== undefined
5058 ? `https://${effectiveDomainConfig.name}`
5059 : newWorkersDev.enabled
5060 ? (output.urls ?? []).find(
5061 (u) =>
5062 isWorkersDevHostname(urlHostname(u)) &&
5063 urlHostname(u).startsWith(`${workerName}.`),
5064 )
5065 : undefined;
5066 const urlStable = newUrl !== undefined && newUrl === output.url;
5067 // `durableObjectNamespaces` maps each hosted DO class name to the
5068 // namespace id Cloudflare assigned it. Those ids are permanent for
5069 // the lifetime of a (worker, class) pair, so the map only changes
5070 // when a class is added or removed — never on a plain code/config
5071 // update. Carry it forward as a stable whenever the set of local DO
5072 // class names is unchanged, for the same reason as `url` above:
5073 // downstream resources that bind a DO namespace via
5074 // `worker.durableObjectNamespaces[name]` (e.g. a Container attached
5075 // to a DO) must resolve it to a concrete value during planning.
5076 // Otherwise the binding holds an unresolved Output, which
5077 // `diffBindings` treats as "changed", spuriously re-updating the
5078 // bound resource on every deploy. Class names are structural (not the
5079 // namespace id), so this comparison holds even when `newBindings` is
5080 // otherwise unresolved.
5081 const newDoClassNames = Array.isArray(newBindings)
5082 ? getExpectedDurableObjectClassNames(
5083 (newBindings as ResourceBinding<Worker["Binding"]>[]).flatMap(
5084 (b) => b.data.bindings ?? [],
5085 ),
5086 workerName,
5087 ).sort()
5088 : [];
5089 const oldDoClassNames = Object.keys(
5090 output.durableObjectNamespaces ?? {},
5091 ).sort();
5092 const doNamespacesStable =
5093 oldWorkerName === workerName &&
5094 newDoClassNames.length === oldDoClassNames.length &&
5095 newDoClassNames.every((name, i) => name === oldDoClassNames[i]);
5096 // Rows persisted by older releases carried the script *name* in
5097 // `workerId` (interrupted precreates a provisional ""). Plan one
5098 // update even when nothing else changed, so reconcile re-records
5099 // the immutable Worker ID.
5100 const legacyWorkerId =
5101 cachedWorkerId(output.workerId, output.workerName) === undefined;
5102 if (
5103 legacyWorkerId ||
5104 domainsChanged ||
5105 routesChanged ||
5106 cronsChanged ||
5107 (yield* hasChanged(
5108 id,
5109 fqn,
5110 news,
5111 output,
5112 Array.isArray(newBindings)
5113 ? (newBindings as ResourceBinding<Worker["Binding"]>[])
5114 : undefined,
5115 accountId,
5116 ))
5117 ) {
5118 // The immutable script ID is always stable across an update —
5119 // except the healing update above, where its value is about to
5120 // change from the legacy shape to the real ID (downstream
5121 // consumers must see the fresh value).
5122 const stables: string[] = legacyWorkerId ? [] : ["workerId"];
5123 if (oldWorkerName === workerName) {
5124 stables.push("workerName");
5125 }
5126 if (urlStable) {
5127 stables.push("url");
5128 }
5129 if (doNamespacesStable) {
5130 stables.push("durableObjectNamespaces");
5131 }
5132 return {
5133 action: "update",
5134 stables: stables.length > 0 ? stables : undefined,
5135 };
5136 }
5137 // Machine-local source paths and resource-backed `env` values have
5138 // already been compared by their content hashes and canonical
5139 // binding data. Ignore those representations in the raw fallback so
5140 // checkout paths and resource attribute projections do not force
5141 // perpetual updates (#745).
5142 if (Array.isArray(newBindings)) {
5143 const normalizeComparedProps = (props: WorkerProps) => {
5144 const { env: _env, ...rest } = props;
5145 return {
5146 ...rest,
5147 ...(props.main !== undefined
5148 ? { main: "<source>" }
5149 : undefined),
5150 ...(props.assets
5151 ? {
5152 assets: {
5153 ...(typeof props.assets === "string"
5154 ? undefined
5155 : props.assets),
5156 directory: "<source>",
5157 },
5158 }
5159 : undefined),
5160 ...(props.vite
5161 ? { vite: { ...props.vite, rootDir: "<source>" } }
5162 : undefined),
5163 };
5164 };
5165 if (
5166 olds !== undefined &&
5167 !havePropsChanged(
5168 normalizeComparedProps(olds),
5169 normalizeComparedProps(news),
5170 )
5171 ) {
5172 return { action: "noop" };
5173 }
5174 }
5175 }),
5176 precreate: Effect.fn(function* ({ id, news, session, bindings }) {
5177 const { accountId } = yield* yield* CloudflareEnvironment;
5178 yield* assertCloudflareTelemetryCompatibility(
5179 news as WorkerProps,
5180 bindings,
5181 );
5182 const name = yield* createWorkerName(id, news.name);
5183 // A version worker uploads to its parent's script during
5184 // reconcile; pre-creating a placeholder script under this
5185 // resource's own generated name would leave a stray script behind.
5186 // Version workers can't host Durable Object / Workflow classes, so
5187 // no placeholder is needed for circular bindings either. (`news`
5188 // is raw here — `version.parent` may be an unresolved ref, but its
5189 // *presence* is statically known.)
5190 if (news.version?.parent != null || news.preview?.of != null) {
5191 yield* Effect.logInfo(
5192 `Cloudflare Worker precreate: skipping stub for ${news.preview?.of != null ? "Preview" : "version"} worker ${id}`,
5193 );
5194 return {
5195 // Provisional: a version worker resolves its parent's
5196 // immutable ID at reconcile — nothing observes this stub row
5197 // (version workers have no circular bindings).
5198 workerId: "",
5199 workerName: name,
5200 namespace: undefined,
5201 logpush: undefined,
5202 url: undefined,
5203 tags: undefined,
5204 durableObjectNamespaces: {},
5205 accountId,
5206 urls: [],
5207 domain: undefined,
5208 routes: [],
5209 crons: [],
5210 } satisfies Worker["Attributes"];
5211 }
5212 // A Workers for Platforms user worker can't be pre-created: precreate
5213 // runs on raw, *unresolved* props (so resources in a dependency cycle
5214 // can signal early), meaning a `namespace` that references the
5215 // namespace resource is still an unresolved Output here, and the
5216 // namespace itself may not be deployed yet. There's also nothing to
5217 // pre-create — a user worker is dispatched to by name, never bound to
5218 // circularly. Return a stub; `reconcile` performs the real upload
5219 // once props resolve and the namespace exists.
5220 if (news.namespace != null) {
5221 yield* Effect.logInfo(
5222 `Cloudflare Worker precreate: skipping stub for dispatch-namespace worker ${name}`,
5223 );
5224 return {
5225 // Provisional: reconcile records the real immutable ID from
5226 // the dispatch upload — nothing observes this stub row (user
5227 // workers are dispatched by name, never bound circularly).
5228 workerId: "",
5229 workerName: name,
5230 namespace:
5231 typeof news.namespace === "string" ? news.namespace : undefined,
5232 logpush: undefined,
5233 url: undefined,
5234 tags: undefined,
5235 durableObjectNamespaces: {},
5236 accountId,
5237 urls: [],
5238 domain: undefined,
5239 routes: [],
5240 crons: [],
5241 } satisfies Worker["Attributes"];
5242 }
5243 const dispatchNamespace = resolveNamespaceName(news.namespace);
5244 const exportMap = news.exports ?? {};
5245 // A worker hosts Durable Object classes from two independent sources:
5246 // Effect-native DO *exports* (classes defined in the worker entry) and
5247 // DO *bindings* declared in `env` — e.g. a bare `Cloudflare.DurableObject`
5248 // that fronts a Container image. The placeholder must declare *every*
5249 // hosted class so each namespace is created here. A class that exists
5250 // only as a binding (a container-fronted DO) is otherwise absent from
5251 // this stub, and a resource caught in a worker<->container dependency
5252 // cycle — which resolves `worker.durableObjectNamespaces[className]`
5253 // against the precreate stub rather than the final reconcile output —
5254 // fails because the namespace id it needs never surfaced.
5255 const exportDerived = Object.keys(exportMap)
5256 .filter((logicalId) => isDurableObjectExport(exportMap[logicalId]))
5257 .map((logicalId) => ({ logicalId, className: logicalId }));
5258 // Transfer-destination classes are excluded from the placeholder
5259 // entirely (class list, bindings, tags): Cloudflare forbids
5260 // creating the destination class of a `transferred_classes`
5261 // migration ahead of the transfer, so `reconcile` must perform it
5262 // with the real upload. (`transferredFrom` may still be an
5263 // unresolved Output here — precreate runs on raw props — but only
5264 // its presence matters.)
5265 const durableObjects = mergeDurableObjectClasses(
5266 exportDerived,
5267 getDurableObjectBindings(bindings, name),
5268 ).filter((binding) => !binding.transferredFrom);
5269 const doClasses = durableObjects.map((binding) => binding.className);
5270 // Only attach container metadata for classes actually fronted by a
5271 // Container binding (mirrors reconcile's `containerClassNames`).
5272 // Mapping every DO class to a container would wrongly mark plain DOs
5273 // as container-backed in the placeholder.
5274 const containers = Array.from(
5275 new Set(
5276 bindings.flatMap((b) =>
5277 (b.data.containers ?? []).map((c) => c.className),
5278 ),
5279 ),
5280 ).map((className) => ({ className }));
5281 const alchemyDoTags = encodeDurableObjectTags(durableObjects);
5282 const alchemyTags = [
5283 ...createAlchemyWorkerTags(id),
5284 ...alchemyDoTags,
5285 ];
5286 const tags = Array.from(
5287 new Set([...alchemyTags, ...(news.tags ?? [])]),
5288 );
5289 yield* validateWorkerTags(name, tags, alchemyTags.length);
5290 yield* Effect.logInfo(
5291 `Cloudflare Worker precreate: starting ${name}`,
5292 );
5293 yield* Effect.logInfo(
5294 `Cloudflare Worker precreate: durable objects ${JSON.stringify(
5295 durableObjects,
5296 )}`,
5297 );
5298 const existingSettings = yield* getScriptSettings(
5299 accountId,
5300 name,
5301 dispatchNamespace,
5302 ).pipe(
5303 // A freshly pre-created stub can briefly report "has no
5304 // versions" before its first version registers — treat it the
5305 // same as a missing worker (nothing to adopt yet). For a user
5306 // worker the dispatch-namespace endpoints report a missing
5307 // script as `DispatchNamespaceScriptNotFound` (and a missing
5308 // namespace as `DispatchNamespaceNotFound`).
5309 Effect.catchTag("WorkerNotFound", () => Effect.succeed(undefined)),
5310 Effect.catchTag("WorkerHasNoVersions", () =>
5311 Effect.succeed(undefined),
5312 ),
5313 Effect.catchTag("DispatchNamespaceScriptNotFound", () =>
5314 Effect.succeed(undefined),
5315 ),
5316 Effect.catchTag("DispatchNamespaceNotFound", () =>
5317 Effect.succeed(undefined),
5318 ),
5319 );
5320 let durableObjectNamespaces = getDurableObjects(
5321 existingSettings?.bindings,
5322 );
5323
5324 let placeholder: { tag?: string | null } | undefined;
5325 if (existingSettings) {
5326 // Engine has already cleared this resource for write via
5327 // `read` + AdoptPolicy. Either we own it (matching tags) or
5328 // the user opted in to a takeover (`--adopt` / `adopt(true)`).
5329 yield* Effect.logInfo(
5330 `Cloudflare Worker precreate: reusing existing ${name}`,
5331 );
5332 } else {
5333 yield* session.note("Pre-creating worker...", { kind: "status" });
5334 const compatibility = getCompatibility(news);
5335 const mainModule = "main.js";
5336 const placeholderScript = `${doClasses.length > 0 ? 'import { DurableObject } from "cloudflare:workers";\n\n' : ""}export default { fetch() { return new Response("Alchemy worker is being deployed...", { status: 503, headers: { "Cache-Control": "no-store" } }) } };\n${doClasses
5337 .map(
5338 (className) =>
5339 `export class ${className} extends DurableObject {}`,
5340 )
5341 .join("\n")}`;
5342 placeholder = yield* putWorkerScript({
5343 accountId,
5344 scriptName: name,
5345 dispatchNamespace,
5346 metadata: {
5347 mainModule,
5348 bindings:
5349 doClasses.length > 0
5350 ? doClasses.map((className) => ({
5351 type: "durable_object_namespace" as const,
5352 name: className,
5353 className,
5354 }))
5355 : undefined,
5356 // Spreading `getCompatibility` here would set `date`/`flags`,
5357 // which are not metadata keys — the placeholder would upload
5358 // with no compatibility date or flags at all.
5359 compatibilityDate: compatibility.date,
5360 compatibilityFlags: compatibility.flags,
5361 containers,
5362 migrations:
5363 doClasses.length > 0
5364 ? {
5365 oldTag: undefined,
5366 newTag: undefined,
5367 newClasses: [],
5368 deletedClasses: [],
5369 renamedClasses: [],
5370 transferredClasses: [],
5371 newSqliteClasses: doClasses,
5372 }
5373 : undefined,
5374 observability: resolveObservability(news, bindings),
5375 tags,
5376 },
5377 files: [
5378 new File([placeholderScript], mainModule, {
5379 type: "application/javascript+module",
5380 }),
5381 ],
5382 }).pipe(
5383 // Cloudflare's PUT /workers/scripts/{name} intermittently
5384 // returns code 10002 / "An unknown error has occurred" on the
5385 // first put for a fresh worker name. Surfaced as the shared
5386 // `InternalServerError` upstream (alchemy-run/distilled#290).
5387 // Also match `UnknownCloudflareError` for older
5388 // @distilled.cloud/cloudflare versions that haven't picked
5389 // up the patch yet.
5390 Effect.retry({
5391 while: (e) =>
5392 e._tag === "InternalServerError" ||
5393 e._tag === "UnknownCloudflareError",
5394 schedule: Schedule.max([
5395 Schedule.exponential(1000),
5396 Schedule.recurs(5),
5397 ]),
5398 }),
5399 );
5400 if (doClasses.length > 0) {
5401 ({ durableObjectNamespaces } =
5402 yield* getWorkerSettingsWithDurableObjects(
5403 name,
5404 doClasses,
5405 dispatchNamespace,
5406 ));
5407 }
5408 }
5409
5410 if (existingSettings && doClasses.length > 0) {
5411 ({ durableObjectNamespaces } =
5412 yield* getWorkerSettingsWithDurableObjects(
5413 name,
5414 doClasses,
5415 dispatchNamespace,
5416 ));
5417 }
5418
5419 /**
5420 * Precreate publishes the stable `https://{name}.{subdomain}.workers.dev`
5421 * URL when `workersDev` is enabled (the default). Cycle peers that
5422 * interpolate `worker.url` (e.g. Stripe.WebhookEndpoint) need a
5423 * valid HTTPS URL here — the previous `url: undefined` stub made
5424 * Stripe reject `"undefined/webhooks/stripe"`. Custom domains and
5425 * version/preview URLs are still unresolved; reconcile overwrites
5426 * `url` with the final value. `workersDev: false` keeps `url`
5427 * undefined.
5428 */
5429 const workersDevUrl = resolveWorkersDev(
5430 news.workersDev as WorkerProps["workersDev"],
5431 ).enabled
5432 ? `https://${name}.${yield* getAccountSubdomain(accountId)}.workers.dev`
5433 : undefined;
5434
5435 return {
5436 // The placeholder upload's tag (or, when adopting an existing
5437 // script, the listing lookup); reconcile re-records it after
5438 // the full deploy.
5439 workerId:
5440 placeholder?.tag ?? (yield* findWorkerId(accountId, name)),
5441 workerName: name,
5442 namespace: dispatchNamespace,
5443 logpush: existingSettings?.logpush ?? undefined,
5444 url: workersDevUrl,
5445 tags: existingSettings?.tags ?? tags,
5446 durableObjectNamespaces,
5447 accountId,
5448 urls: workersDevUrl !== undefined ? [workersDevUrl] : [],
5449 domain: undefined,
5450 routes: [],
5451 crons: [],
5452 } satisfies Worker["Attributes"];
5453 }),
5454 read: Effect.fn(
5455 function* ({ id, output, olds }) {
5456 const { accountId } = yield* yield* CloudflareEnvironment;
5457 // Version workers don't own a script — their `workerName` is the
5458 // *parent's* script, so the normal read below would hydrate (and
5459 // potentially brand as adoptable) a resource we don't own. The
5460 // version itself is immutable; all we verify is that it still
5461 // exists on the parent.
5462 if (
5463 output?.versionOf !== undefined ||
5464 olds?.version?.parent != null
5465 ) {
5466 if (output?.versionOf === undefined || !output.versionId) {
5467 // No recorded version (e.g. state was written before the
5468 // upload completed) — nothing to observe; reconcile will
5469 // upload a fresh version.
5470 return undefined;
5471 }
5472 return yield* workers
5473 .getScriptVersion({
5474 accountId,
5475 scriptName: output.versionOf,
5476 versionId: output.versionId,
5477 })
5478 .pipe(
5479 Effect.map(() => output),
5480 Effect.catchTag(["WorkerNotFound", "VersionNotFound"], () =>
5481 Effect.succeed(undefined),
5482 ),
5483 );
5484 }
5485 if (output?.previewOf !== undefined || olds?.preview?.of != null) {
5486 if (output?.previewOf === undefined || !output.previewId) {
5487 return undefined;
5488 }
5489 return yield* workers
5490 .getPreview({
5491 accountId,
5492 workerId: output.previewOf,
5493 previewId: output.previewId,
5494 })
5495 .pipe(
5496 Effect.map(() => output),
5497 Effect.catchTag(["WorkerNotFound", "PreviewNotFound"], () =>
5498 Effect.succeed(undefined),
5499 ),
5500 );
5501 }
5502 const workerName =
5503 output?.workerName ?? (yield* createWorkerName(id, olds?.name));
5504 const dispatchNamespace =
5505 output?.namespace ?? resolveNamespaceName(olds?.namespace);
5506 yield* Effect.logInfo(
5507 `Cloudflare Worker read: checking ${workerName}`,
5508 );
5509
5510 // Workers for Platforms user workers have no subdomain, custom
5511 // domains, or cron triggers — read only the script settings from
5512 // the dispatch-namespace endpoint.
5513 if (dispatchNamespace) {
5514 const settings = yield* getScriptSettings(
5515 accountId,
5516 workerName,
5517 dispatchNamespace,
5518 );
5519 yield* Effect.logInfo(
5520 `Cloudflare Worker read: found ${workerName} in dispatch namespace ${dispatchNamespace}`,
5521 );
5522 const attrs = {
5523 accountId,
5524 // Rows persisted by older releases carried the script name
5525 // here — treat those as unknown and fetch the real ID from
5526 // the dispatch-namespace script endpoint.
5527 workerId:
5528 cachedWorkerId(output?.workerId, workerName) ??
5529 (yield* wfp
5530 .getDispatchNamespaceScript({
5531 accountId,
5532 dispatchNamespace,
5533 scriptName: workerName,
5534 })
5535 .pipe(
5536 Effect.flatMap((r) =>
5537 r.script?.tag != null
5538 ? Effect.succeed(r.script.tag)
5539 : Effect.fail(
5540 new WorkerIdNotFound({
5541 scriptName: workerName,
5542 message: `Cloudflare Worker: dispatch-namespace script '${workerName}' has no immutable ID in its metadata`,
5543 }),
5544 ),
5545 ),
5546 )),
5547 workerName,
5548 namespace: dispatchNamespace,
5549 logpush: settings.logpush ?? undefined,
5550 url: undefined,
5551 tags: settings.tags ?? undefined,
5552 durableObjectNamespaces: getDurableObjects(settings.bindings),
5553 urls: [],
5554 domain: undefined,
5555 routes: [],
5556 crons: [],
5557 tailConsumers: settings.tailConsumers?.map((c) => ({
5558 service: c.service,
5559 })),
5560 // Not observable: GET script-settings has no
5561 // `streaming_tail_consumers` field. Carry the last deployed
5562 // value forward like other provider-managed caches.
5563 streamingTailConsumers: output?.streamingTailConsumers,
5564 // The bundle hash is computed locally during deployment and
5565 // cannot be reconstructed from Cloudflare's read APIs.
5566 hash: output?.hash,
5567 } satisfies Worker["Attributes"];
5568 return hasAlchemyWorkerTags(id, settings.tags ?? [])
5569 ? attrs
5570 : Unowned(attrs);
5571 }
5572
5573 // We deliberately don't call `listScripts({ accountId })` here:
5574 // it pulls every Worker on the account back through a strict
5575 // schema decode, and a single existing Worker the schema doesn't
5576 // know about (e.g. `placement_mode: "targeted"`) breaks the
5577 // entire read. `getScriptSettings` already fails with
5578 // `WorkerNotFound` if the script doesn't exist, which the
5579 // surrounding `Effect.catchTag` turns into `undefined` — that's
5580 // all the existence check we need.
5581 //
5582 // Domain / route / cron observation is gated on whether Alchemy
5583 // manages that surface for this Worker (#926). A plain workers.dev
5584 // Worker otherwise pays for listDomains + zone route listings +
5585 // getScriptSchedule on every plan/deploy read. Route observation
5586 // is additionally scoped to the zones state already associates
5587 // with this Worker — see readWorkerRoutes. Empty-array props
5588 // (`domain: []`, `routes: []`, `crons: []`) still observe so we
5589 // can detect drift and converge deletions.
5590 const observeDomains = shouldObserveWorkerDomains(olds, output);
5591 const observeRoutes = shouldObserveWorkerRoutes(olds, output);
5592 const observeCrons = shouldObserveWorkerCrons(olds, output);
5593 const [subdomain, settings, domainsList, routesList] =
5594 yield* Effect.all(
5595 [
5596 workers.getScriptSubdomain({
5597 accountId,
5598 scriptName: workerName,
5599 }),
5600 workers.getScriptScriptAndVersionSetting({
5601 accountId,
5602 scriptName: workerName,
5603 }),
5604 observeDomains
5605 ? workers
5606 .listDomains({
5607 accountId,
5608 service: workerName,
5609 })
5610 .pipe(Effect.map((r) => r.result ?? []))
5611 : Effect.succeed(
5612 [] as workers.ListDomainsResponse["result"],
5613 ),
5614 observeRoutes
5615 ? readWorkerRoutes(workerName, output?.routes)
5616 : Effect.succeed([] as Worker["Attributes"]["routes"]),
5617 ],
5618 // Bound concurrency so a single Worker read doesn't stampede
5619 // the account alongside every other Worker's lifecycle calls.
5620 { concurrency: 1 },
5621 );
5622 // Classify the observed hostnames using the declared config
5623 // (`olds.domain`): the canonical name and each alias/redirect
5624 // keep their declared role while still attached; drift (attached
5625 // hostnames we don't know about) is appended to the aliases. The
5626 // Cloudflare API returns domains in non-deterministic order, so
5627 // the declared order is what keeps `url`/`urls` from flipping
5628 // between reads.
5629 const declared = yield* resolveWorkerDomain(olds?.domain).pipe(
5630 Effect.orElseSucceed(() => undefined),
5631 );
5632 const observed = new Set(
5633 domainsList.flatMap((d) => (d.hostname ? [d.hostname] : [])),
5634 );
5635 const keptName =
5636 declared !== undefined && observed.has(declared.name)
5637 ? declared.name
5638 : undefined;
5639 const keptAliases =
5640 declared?.aliases.filter((h) => observed.has(h)) ?? [];
5641 const keptRedirects =
5642 declared?.redirects.filter((h) => observed.has(h)) ?? [];
5643 const classified = new Set([
5644 ...(keptName ? [keptName] : []),
5645 ...keptAliases,
5646 ...keptRedirects,
5647 ]);
5648 const drift = [...observed].filter((h) => !classified.has(h));
5649 const serving = [
5650 ...(keptName ? [keptName] : []),
5651 ...keptAliases,
5652 ...drift,
5653 ];
5654 const observedDomain =
5655 serving.length > 0
5656 ? {
5657 name: serving[0],
5658 aliases: serving.slice(1),
5659 redirects: keptRedirects,
5660 }
5661 : undefined;
5662 const urls = [
5663 ...serving.map((h) => `https://${h}`),
5664 ...(subdomain.enabled
5665 ? [
5666 `https://${workerName}.${yield* getAccountSubdomain(accountId)}.workers.dev`,
5667 ]
5668 : []),
5669 ];
5670 const crons = observeCrons ? yield* getWorkerCrons(workerName) : [];
5671 yield* Effect.logInfo(
5672 `Cloudflare Worker read: found ${workerName}`,
5673 );
5674 const attrs = {
5675 accountId,
5676 // The settings endpoint doesn't expose the immutable ID;
5677 // reuse the cached value, falling back to a lazy listing
5678 // lookup for unknown/legacy rows (older releases persisted
5679 // the script *name* here) so adoption records the real ID.
5680 workerId:
5681 cachedWorkerId(output?.workerId, workerName) ??
5682 (yield* findWorkerId(accountId, workerName)),
5683 workerName,
5684 namespace: undefined,
5685 logpush: settings.logpush ?? undefined,
5686 url: urls[0],
5687 urls,
5688 domain: observedDomain,
5689 tags: settings.tags ?? undefined,
5690 durableObjectNamespaces: getDurableObjects(settings.bindings),
5691 routes: routesList,
5692 crons,
5693 tailConsumers: settings.tailConsumers?.map((c) => ({
5694 service: c.service,
5695 })),
5696 // Not observable: GET script-settings has no
5697 // `streaming_tail_consumers` field. Carry the last deployed
5698 // value forward like other provider-managed caches.
5699 streamingTailConsumers: output?.streamingTailConsumers,
5700 // The bundle hash is computed locally during deployment and
5701 // cannot be reconstructed from Cloudflare's read APIs.
5702 hash: output?.hash,
5703 // Rule placement is provider-managed state, not observed here
5704 // (a getPhas call per known zone on every read); carry the
5705 // cleanup list forward like any other stable cache.
5706 affinityZoneIds: output?.affinityZoneIds,
5707 } satisfies Worker["Attributes"];
5708
5709 // Centralized ownership decision: the engine routes `read`'s
5710 // return value based on `AdoptPolicy`. We hand it the attrs
5711 // either as-is (owned: alchemy tags identify this stack/stage/id,
5712 // safe to silently adopt even without `--adopt`) or branded with
5713 // `Unowned` (caller must opt in via `--adopt` or the engine
5714 // raises `OwnedBySomeoneElse`).
5715 return hasAlchemyWorkerTags(id, settings.tags ?? [])
5716 ? attrs
5717 : Unowned(attrs);
5718 },
5719 (effect) =>
5720 effect.pipe(
5721 // A worker that exists but hasn't registered a version yet reads
5722 // as "not deployed" — fall through to (re)create like NotFound.
5723 // The dispatch-namespace endpoints report the same conditions as
5724 // `DispatchNamespaceScriptNotFound` / `DispatchNamespaceNotFound`.
5725 Effect.catchTag("WorkerNotFound", () =>
5726 Effect.succeed(undefined),
5727 ),
5728 Effect.catchTag("WorkerHasNoVersions", () =>
5729 Effect.succeed(undefined),
5730 ),
5731 Effect.catchTag("DispatchNamespaceScriptNotFound", () =>
5732 Effect.succeed(undefined),
5733 ),
5734 Effect.catchTag("DispatchNamespaceNotFound", () =>
5735 Effect.succeed(undefined),
5736 ),
5737 ),
5738 ),
5739 reconcile: Effect.fn(function* ({
5740 id,
5741 fqn,
5742 news,
5743 olds,
5744 bindings,
5745 output,
5746 session,
5747 }) {
5748 yield* assertCloudflareTelemetryCompatibility(news, bindings);
5749 if (news.preview?.of != null && news.version?.parent != null) {
5750 return yield* Effect.fail(
5751 new WorkerPreviewConfigError({
5752 message: `preview and version cannot be set together. Use preview.of for branch/PR Previews; use version.parent / version.traffic for canaries and gradual rollouts.`,
5753 }),
5754 );
5755 }
5756 // A version worker uploads an immutable version to its parent's
5757 // script instead of owning a script of its own — none of the
5758 // script-level observation below applies.
5759 if (news.version?.parent != null) {
5760 return yield* putWorkerVersion(
5761 id,
5762 fqn,
5763 news,
5764 bindings,
5765 session,
5766 output,
5767 );
5768 }
5769 if (news.preview?.of != null) {
5770 return yield* putWorkerPreview(
5771 id,
5772 fqn,
5773 news,
5774 bindings,
5775 session,
5776 output,
5777 );
5778 }
5779 const { accountId } = yield* yield* CloudflareEnvironment;
5780 const name =
5781 output?.workerName ?? (yield* createWorkerName(id, news.name));
5782 const durableObjects = getDurableObjectBindings(bindings, name).map(
5783 ({ logicalId, className }) => ({
5784 logicalId,
5785 className,
5786 }),
5787 );
5788 yield* Effect.logInfo(
5789 `Cloudflare Worker reconcile: starting ${name}`,
5790 );
5791 yield* Effect.logInfo(
5792 `Cloudflare Worker reconcile: durable objects ${JSON.stringify(
5793 durableObjects,
5794 )}`,
5795 );
5796
5797 const dispatchNamespace = resolveNamespaceName(news.namespace);
5798 // Observe — fetch the script's current settings if it already exists.
5799 // `putWorker` is a true upsert against the Cloudflare API; the
5800 // existing settings inform asset/migration decisions and let the
5801 // reconciler converge whether the worker is brand-new, adopted, or
5802 // an in-place update.
5803 const existingSettings = yield* getScriptSettings(
5804 accountId,
5805 name,
5806 dispatchNamespace,
5807 ).pipe(
5808 // After a pre-create stub (or under a busy account right after
5809 // the first upload) the settings read can race the script
5810 // registry and 404 with "has no versions". Treat it as "no
5811 // existing settings" so reconcile proceeds to upload/converge.
5812 // The dispatch-namespace endpoints raise
5813 // `DispatchNamespaceScriptNotFound` / `DispatchNamespaceNotFound`.
5814 Effect.catchTag("WorkerNotFound", () => Effect.succeed(undefined)),
5815 Effect.catchTag("WorkerHasNoVersions", () =>
5816 Effect.succeed(undefined),
5817 ),
5818 Effect.catchTag("DispatchNamespaceScriptNotFound", () =>
5819 Effect.succeed(undefined),
5820 ),
5821 Effect.catchTag("DispatchNamespaceNotFound", () =>
5822 Effect.succeed(undefined),
5823 ),
5824 );
5825 yield* Effect.logInfo(
5826 `Cloudflare Worker reconcile: existing durable object tags ${JSON.stringify(
5827 (existingSettings?.tags ?? []).filter(isDurableObjectTag),
5828 )}`,
5829 );
5830 yield* Effect.logInfo(
5831 `Cloudflare Worker reconcile: previous durable object tags ${JSON.stringify(
5832 (output?.tags ?? []).filter(isDurableObjectTag),
5833 )}`,
5834 );
5835
5836 return yield* putWorker(
5837 id,
5838 fqn,
5839 news,
5840 bindings,
5841 olds,
5842 output,
5843 session,
5844 existingSettings,
5845 );
5846 }),
5847 delete: Effect.fn(function* ({ output }) {
5848 // Version workers own a version, not the script — `workerName` is
5849 // the *parent's* script and must never be deleted here. Versions
5850 // can't be deleted through the API (they age out of Cloudflare's
5851 // retention window); all we clean up is live traffic: if the
5852 // current deployment routes traffic to our version, restore 100%
5853 // to the other version in the split.
5854 if (output.versionOf !== undefined) {
5855 // Remove our affinity rules from the parent's zones — the
5856 // canary owned them, and with the canary gone there is no
5857 // split left to pin. Best-effort: a zone we can no longer
5858 // touch must not fail the destroy.
5859 if (output.affinityZoneIds?.length) {
5860 yield* reconcileAffinityRules({
5861 scriptName: output.versionOf,
5862 desired: undefined,
5863 previousZoneIds: output.affinityZoneIds,
5864 }).pipe(Effect.catch(() => Effect.void));
5865 }
5866 if (!output.versionId) return;
5867 yield* Effect.logInfo(
5868 `Cloudflare Worker delete: releasing version ${output.versionId} of ${output.versionOf}`,
5869 );
5870 const { deployments } = yield* workers
5871 .listScriptDeployments({
5872 accountId: output.accountId,
5873 scriptName: output.versionOf,
5874 })
5875 .pipe(
5876 Effect.catchTag("WorkerNotFound", () =>
5877 Effect.succeed({
5878 deployments:
5879 [] as workers.ListScriptDeploymentsResponse["deployments"],
5880 }),
5881 ),
5882 );
5883 const latest = deployments[0];
5884 if (
5885 !latest?.versions.some((v) => v.versionId === output.versionId)
5886 ) {
5887 // Not part of the live deployment — the version just ages out.
5888 return;
5889 }
5890 const stable = latest.versions
5891 .filter((v) => v.versionId !== output.versionId)
5892 .sort((a, b) => b.percentage - a.percentage)[0];
5893 if (!stable) {
5894 // Our version holds 100% of traffic; there is no other version
5895 // to restore. Deleting the resource must not take the parent's
5896 // script down — leave the deployment in place.
5897 yield* Effect.logWarning(
5898 `Cloudflare Worker delete: version ${output.versionId} serves 100% of '${output.versionOf}' traffic; leaving the deployment as-is. Re-deploy the parent to move off it.`,
5899 );
5900 return;
5901 }
5902 yield* workers
5903 .createScriptDeployment({
5904 accountId: output.accountId,
5905 scriptName: output.versionOf,
5906 strategy: "percentage",
5907 versions: [{ versionId: stable.versionId, percentage: 100 }],
5908 })
5909 .pipe(Effect.catchTag("WorkerNotFound", () => Effect.void));
5910 return;
5911 }
5912 if (output.previewOf !== undefined) {
5913 const previewId = output.previewId ?? output.previewName;
5914 if (!previewId) return;
5915 yield* Effect.logInfo(
5916 `Cloudflare Worker delete: deleting Preview ${previewId} of ${output.previewOf}`,
5917 );
5918 yield* workers
5919 .deletePreview({
5920 accountId: output.accountId,
5921 workerId: output.previewOf,
5922 previewId,
5923 })
5924 .pipe(
5925 Effect.catchTag(
5926 ["PreviewNotFound", "WorkerNotFound"],
5927 () => Effect.void,
5928 ),
5929 );
5930 return;
5931 }
5932 yield* Effect.logInfo(
5933 `Cloudflare Worker delete: deleting ${output.workerName}`,
5934 );
5935 // Workers for Platforms user workers have no custom domains; delete
5936 // the script straight out of its dispatch namespace.
5937 if (output.namespace) {
5938 yield* deleteWorkerScript(
5939 output.accountId,
5940 output.workerName,
5941 output.namespace,
5942 ).pipe(
5943 Effect.catchTag(
5944 [
5945 "DispatchNamespaceScriptNotFound",
5946 "DispatchNamespaceNotFound",
5947 ],
5948 () => Effect.void,
5949 ),
5950 );
5951 return;
5952 }
5953 // Look up live domain IDs rather than trusting persisted state.
5954 // We no longer track `{ id, zoneId }` on the output; fetching
5955 // straight from Cloudflare handles both the normal case and
5956 // adopted workers whose domains we never recorded.
5957 const liveDomains = yield* workers
5958 .listDomains({
5959 accountId: output.accountId,
5960 service: output.workerName,
5961 })
5962 .pipe(
5963 Effect.map((r) => r.result ?? []),
5964 Effect.catch(() => Effect.succeed([])),
5965 );
5966 // Remove our redirect rules from each affected zone's dynamic
5967 // redirect entrypoint *before* detaching the domains — the live
5968 // domain list is what resolves each redirect hostname's zone.
5969 // Reconciling with an empty domain config removes exactly the
5970 // rules tagged `alchemy:worker:<script>:redirect:*`, leaving
5971 // everything else in the shared entrypoint untouched.
5972 // Remove our version-affinity rules from every zone state says
5973 // holds them. Best-effort, like the redirect rules below.
5974 if (output.affinityZoneIds?.length) {
5975 yield* reconcileAffinityRules({
5976 scriptName: output.workerName,
5977 desired: undefined,
5978 previousZoneIds: output.affinityZoneIds,
5979 }).pipe(Effect.catch(() => Effect.void));
5980 }
5981 const redirects = stateWorkerDomain(output)?.redirects ?? [];
5982 if (redirects.length > 0) {
5983 yield* reconcileRedirectRules({
5984 scriptName: output.workerName,
5985 domain: undefined,
5986 zoneIdByHostname: new Map(
5987 liveDomains.flatMap((d) =>
5988 d.hostname && d.zoneId
5989 ? [[d.hostname, d.zoneId] as const]
5990 : [],
5991 ),
5992 ),
5993 previousRedirects: redirects,
5994 }).pipe(Effect.catch(() => Effect.void));
5995 }
5996 if (liveDomains.length) {
5997 yield* Effect.all(
5998 liveDomains.flatMap((d) =>
5999 d.id
6000 ? [
6001 workers
6002 .deleteDomain({
6003 accountId: output.accountId,
6004 domainId: d.id,
6005 })
6006 .pipe(
6007 Effect.catchTag("DomainNotFound", () => Effect.void),
6008 ),
6009 ]
6010 : [],
6011 ),
6012 { concurrency: "unbounded" },
6013 );
6014 }
6015 // Routes are zone-scoped; enumerating every zone live is
6016 // expensive, so trust the persisted route ids (refreshed by
6017 // `read`) and tolerate already-deleted routes.
6018 if (output.routes?.length) {
6019 yield* Effect.all(
6020 output.routes.map((route) =>
6021 workers
6022 .deleteRoute({
6023 zoneId: route.zoneId,
6024 routeId: route.id,
6025 })
6026 .pipe(Effect.catchTag("RouteNotFound", () => Effect.void)),
6027 ),
6028 { concurrency: "unbounded" },
6029 );
6030 }
6031 yield* deleteWorkerScript(
6032 output.accountId,
6033 output.workerName,
6034 undefined,
6035 ).pipe(Effect.catchTag("WorkerNotFound", () => Effect.void));
6036 }),
6037 tail: ({ output }) =>
6038 telemetry.tailScript({
6039 accountId: output.accountId,
6040 scriptName: output.workerName,
6041 }),
6042 logs: ({ output, options }) =>
6043 telemetry.queryLogs({
6044 accountId: output.accountId,
6045 filters: [
6046 {
6047 key: "$workers.scriptName",
6048 operation: "eq",
6049 type: "string",
6050 value: output.workerName,
6051 },
6052 ],
6053 options,
6054 }),
6055 });
6056 }),
6057 );
6058
6059const contentTypeForModule = (filePath: string) => {
6060 // Vendored Python packages upload as opaque data, mirroring Wrangler's
6061 // vendored-module rules — with one exception: the `workers-runtime-sdk`
6062 // JS shims under `python_modules/workers/` must be ES modules so the
6063 // runtime can resolve them via `import_from_javascript()`.
6064 if (filePath.startsWith("python_modules/")) {
6065 return filePath.startsWith("python_modules/workers/") &&
6066 /\.m?js$/.test(filePath)
6067 ? "application/javascript+module"
6068 : "application/octet-stream";
6069 }
6070 const dot = filePath.lastIndexOf(".");
6071 return contentTypeFromExtension(dot === -1 ? "" : filePath.slice(dot));
6072};
6073
6074const contentTypeFromExtension = (extension: string) => {
6075 switch (extension) {
6076 case ".wasm":
6077 return "application/wasm";
6078 case ".txt":
6079 case ".html":
6080 case ".sql":
6081 case ".custom":
6082 return "text/plain";
6083 case ".bin":
6084 return "application/octet-stream";
6085 case ".mjs":
6086 case ".js":
6087 return "application/javascript+module";
6088 case ".cjs":
6089 return "application/javascript";
6090 case ".py":
6091 return "text/x-python";
6092 case ".map":
6093 return "application/source-map";
6094 default:
6095 return "application/octet-stream";
6096 }
6097};
6098
6099/**
6100 * Observe every Durable Object namespace on the account with its identity,
6101 * script and class. Namespace ownership is authoritative cloud state: after a
6102 * `transferred_classes` migration the namespace moves to the receiving
6103 * script, so this is how both sides of a transfer observe where a class
6104 * currently lives — the destination checks the source still hosts the class
6105 * before emitting the transfer, and the former host checks whether a class
6106 * it is about to delete has already been transferred away. Missing records
6107 * are inconclusive because pagination is not an atomic account snapshot.
6108 */
6109const listDurableObjectNamespaces = (accountId: string) =>
6110 durableObjectsApi.listNamespaces.items({ accountId }).pipe(
6111 Stream.runCollect,
6112 Effect.map((namespaces) =>
6113 Array.from(namespaces).flatMap((ns) =>
6114 ns.script && ns.class
6115 ? [{ id: ns.id, script: ns.script, class: ns.class }]
6116 : [],
6117 ),
6118 ),
6119 );
6120
6121/**
6122 * Coerce a resolved `transferredFrom` declaration to its list form, dropping
6123 * self-references (a worker naming itself as its own former host is
6124 * meaningless — the class already lives there).
6125 */
6126const normalizeTransferSources = (
6127 value: string | readonly string[] | undefined,
6128 selfScriptName: string,
6129): string[] =>
6130 (value === undefined
6131 ? []
6132 : Array.isArray(value)
6133 ? value
6134 : [value as string]
6135 ).filter((source) => source !== selfScriptName);
6136
6137function bumpMigrationTagVersion(
6138 oldTag: string | undefined,
6139): string | undefined {
6140 if (!oldTag) return undefined;
6141 const version = oldTag.match(/^(alchemy:)?v(\d+)$/)?.[2];
6142 if (!version) return "alchemy:v1";
6143 return `alchemy:v${parseInt(version, 10) + 1}`;
6144}
6145
6146/**
6147 * Merges a worker's export-derived and binding-derived Durable Object class
6148 * lists for the precreate placeholder, deduping by class name. The
6149 * binding-derived entry wins on a collision so the `alchemy:dos:` tag keys off
6150 * the same logical id (the binding sid) that `reconcile` writes.
6151 */
6152function mergeDurableObjectClasses(
6153 exportDerived: ReadonlyArray<{
6154 logicalId: string;
6155 className: string;
6156 transferredFrom?: string | string[];
6157 }>,
6158 bindingDerived: ReadonlyArray<{
6159 logicalId: string;
6160 className: string;
6161 transferredFrom?: string | string[];
6162 }>,
6163) {
6164 return Array.from(
6165 new Map(
6166 [...exportDerived, ...bindingDerived].map(
6167 (binding) => [binding.className, binding] as const,
6168 ),
6169 ).values(),
6170 );
6171}
6172
6173function getDurableObjectBindings(
6174 bindings: ReadonlyArray<ResourceBinding>,
6175 workerName: string,
6176) {
6177 // Resource authors (and the `make`/`yield* Tag`/plan-vs-apply machinery)
6178 // can register the same DO binding multiple times under the same logical
6179 // id — `binding()` is a plain `worker.bind` and intentionally has no
6180 // dedup. Collapse duplicates here so each `(logicalId, bindingName,
6181 // className)` tuple appears at most once. We also exclude cross-script
6182 // references: a `scriptName` pointing to *another* worker means this
6183 // worker just references a foreign class — ship the binding to
6184 // Cloudflare, but don't drive class migrations for it.
6185 const seen = new Set<string>();
6186 return bindings.flatMap((binding) =>
6187 (binding.data.bindings ?? []).flatMap((item: WorkerBinding) => {
6188 if (
6189 item.type !== "durable_object_namespace" ||
6190 !("className" in item) ||
6191 !item.className
6192 ) {
6193 return [];
6194 }
6195 if (item.scriptName !== undefined && item.scriptName !== workerName) {
6196 return [];
6197 }
6198 const dedupKey = `${binding.sid}::${item.name}::${item.className}`;
6199 if (seen.has(dedupKey)) return [];
6200 seen.add(dedupKey);
6201 return [
6202 {
6203 logicalId: binding.sid,
6204 bindingName: item.name,
6205 className: item.className,
6206 // Declared host history for a data-preserving
6207 // `transferred_classes` migration. Normalize an empty list to
6208 // "not declared"; self-references are dropped at resolution time.
6209 transferredFrom:
6210 Array.isArray(item.transferredFrom) &&
6211 item.transferredFrom.length === 0
6212 ? undefined
6213 : item.transferredFrom,
6214 },
6215 ];
6216 }),
6217 );
6218}
6219
6220/**
6221 * Cloudflare Worker script-tag limits, verified empirically against the live
6222 * API (2026-07):
6223 *
6224 * - at most **10 tags** per Worker (the 11th is rejected with `Forbidden`)
6225 * - each tag is at most **1024 bytes** (`BadRequest: Tag is too large`)
6226 * - tags may not contain `,` or `&`; everything else (`:`, `;`, `=`, `/`,
6227 * spaces, unicode) is accepted and round-trips intact
6228 *
6229 * Alchemy reserves 3 ownership tags (`alchemy:stack/stage/id`) and 1
6230 * migration tag, so the Durable Object logical-id→class mapping must not
6231 * spend one tag per DO (#811). Instead all mappings are packed into as few
6232 * `alchemy:dos:` tags as possible.
6233 */
6234const MAX_TAGS_PER_WORKER = 10;
6235const MAX_TAG_BYTES = 1024;
6236const LEGACY_DO_TAG_PREFIX = "alchemy:do:";
6237const PACKED_DO_TAG_PREFIX = "alchemy:dos:";
6238
6239class InvalidWorkerTags extends Data.TaggedError("InvalidWorkerTags")<{
6240 scriptName: string;
6241 reason: string;
6242}> {}
6243
6244/**
6245 * Pack Durable Object logical-id→class mappings into `alchemy:dos:` tags.
6246 *
6247 * Each mapping is encoded as `logicalId=className` — elided to just
6248 * `className` when the two are equal (the common case for export-derived
6249 * classes) — with both components `encodeURIComponent`-escaped so the `;`
6250 * pair separator, the `=` delimiter, and Cloudflare's forbidden tag
6251 * characters (`,`, `&`) can never collide with user identifiers. Pairs are
6252 * sorted for deterministic output and greedily packed so each tag stays
6253 * within Cloudflare's 1024-byte tag limit; workers with more DOs than fit in
6254 * one tag spill into additional `alchemy:dos:` tags.
6255 *
6256 * `encodeURIComponent` output is pure ASCII, so `String.length` equals the
6257 * tag's byte length.
6258 *
6259 * @internal exported for unit testing.
6260 */
6261export function encodeDurableObjectTags(
6262 durableObjects: ReadonlyArray<{ logicalId: string; className: string }>,
6263): string[] {
6264 const pairs = [...durableObjects]
6265 .sort((a, b) => a.logicalId.localeCompare(b.logicalId))
6266 .map(({ logicalId, className }) =>
6267 logicalId === className
6268 ? encodeURIComponent(className)
6269 : `${encodeURIComponent(logicalId)}=${encodeURIComponent(className)}`,
6270 );
6271 const tags: string[] = [];
6272 let payload = "";
6273 for (const pair of pairs) {
6274 const appended = payload === "" ? pair : `${payload};${pair}`;
6275 if (
6276 PACKED_DO_TAG_PREFIX.length + appended.length > MAX_TAG_BYTES &&
6277 payload !== ""
6278 ) {
6279 tags.push(`${PACKED_DO_TAG_PREFIX}${payload}`);
6280 payload = pair;
6281 } else {
6282 payload = appended;
6283 }
6284 }
6285 if (payload !== "") {
6286 tags.push(`${PACKED_DO_TAG_PREFIX}${payload}`);
6287 }
6288 return tags;
6289}
6290
6291/**
6292 * Parse the Durable Object logical-id→class mapping from a worker's script
6293 * tags. Reads both formats so workers deployed before the packed format roll
6294 * forward transparently:
6295 *
6296 * - legacy: one `alchemy:do:{logicalId}:{className}` tag per DO
6297 * - packed: `alchemy:dos:{pair};{pair};…` (see {@link encodeDurableObjectTags})
6298 *
6299 * A packed entry wins over a legacy entry for the same logical id.
6300 *
6301 * @internal exported for unit testing.
6302 */
6303export function getDurableObjectTagMap(tags: ReadonlyArray<string>) {
6304 const map: Record<string, string> = {};
6305 for (const tag of tags) {
6306 if (tag.startsWith(LEGACY_DO_TAG_PREFIX)) {
6307 const parts = tag.split(":");
6308 const logicalId = parts[2];
6309 const className = parts.slice(3).join(":");
6310 if (logicalId && className && !(logicalId in map)) {
6311 map[logicalId] = className;
6312 }
6313 }
6314 }
6315 for (const tag of tags) {
6316 if (tag.startsWith(PACKED_DO_TAG_PREFIX)) {
6317 for (const pair of tag.slice(PACKED_DO_TAG_PREFIX.length).split(";")) {
6318 if (pair === "") continue;
6319 const eq = pair.indexOf("=");
6320 const logicalId = decodeURIComponent(
6321 eq === -1 ? pair : pair.slice(0, eq),
6322 );
6323 const className = decodeURIComponent(
6324 eq === -1 ? pair : pair.slice(eq + 1),
6325 );
6326 if (logicalId && className) {
6327 map[logicalId] = className;
6328 }
6329 }
6330 }
6331 }
6332 return map;
6333}
6334
6335const isDurableObjectTag = (tag: string) =>
6336 tag.startsWith(LEGACY_DO_TAG_PREFIX) || tag.startsWith(PACKED_DO_TAG_PREFIX);
6337
6338/**
6339 * Fail fast — before the script upload — when a worker's tag set violates
6340 * Cloudflare's limits, instead of surfacing the raw `Forbidden`/`BadRequest`
6341 * at PUT time (#811). `alchemyTagCount` is the number of tags alchemy itself
6342 * generated (ownership + migration + packed DO tags) so the message can tell
6343 * the user how much of the budget is theirs.
6344 */
6345const validateWorkerTags = (
6346 scriptName: string,
6347 tags: ReadonlyArray<string>,
6348 alchemyTagCount: number,
6349) =>
6350 Effect.gen(function* () {
6351 if (tags.length > MAX_TAGS_PER_WORKER) {
6352 return yield* Effect.fail(
6353 new InvalidWorkerTags({
6354 scriptName,
6355 reason:
6356 `worker "${scriptName}" needs ${tags.length} script tags but Cloudflare allows at most ${MAX_TAGS_PER_WORKER}. ` +
6357 `Alchemy reserves ${alchemyTagCount} (ownership, migration and durable-object metadata); ` +
6358 `${tags.length - alchemyTagCount} user tags were passed via \`tags\`. Remove ${tags.length - MAX_TAGS_PER_WORKER} tag(s).`,
6359 }),
6360 );
6361 }
6362 const encoder = new TextEncoder();
6363 for (const tag of tags) {
6364 if (tag.includes(",") || tag.includes("&")) {
6365 return yield* Effect.fail(
6366 new InvalidWorkerTags({
6367 scriptName,
6368 reason: `worker "${scriptName}" tag ${JSON.stringify(tag)} contains ',' or '&', which Cloudflare rejects.`,
6369 }),
6370 );
6371 }
6372 const bytes = yield* Effect.sync(() => encoder.encode(tag).length);
6373 if (bytes > MAX_TAG_BYTES) {
6374 return yield* Effect.fail(
6375 new InvalidWorkerTags({
6376 scriptName,
6377 reason: `worker "${scriptName}" tag ${JSON.stringify(tag.slice(0, 64))}… is ${bytes} bytes; Cloudflare allows at most ${MAX_TAG_BYTES}.`,
6378 }),
6379 );
6380 }
6381 }
6382 });