import * as durableObjectsApi from "@distilled.cloud/cloudflare/durable-objects"; import * as rulesets from "@distilled.cloud/cloudflare/rulesets"; import * as workers from "@distilled.cloud/cloudflare/workers"; import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms"; import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import { isHttpClientError } from "effect/unstable/http/HttpClientError"; import * as crypto from "node:crypto"; import { Unowned } from "../../AdoptPolicy.ts"; import * as Artifacts from "../../Artifacts.ts"; import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts"; import { hashDirectory, type MemoOptions } from "../../Command/Memo.ts"; import { havePropsChanged, isResolved, stripEffects } from "../../Diff.ts"; import * as ProviderLayer from "../../Local/ProviderLayer.ts"; import * as Provider from "../../Provider.ts"; import { type ResourceBinding } from "../../Resource.ts"; import { Stack } from "../../Stack.ts"; import { cachedFunction } from "../../Util/cached-function.ts"; import { initialCwd } from "../../Util/Node.ts"; import { sha256Object } from "../../Util/sha256.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import { localRuntimeServices } from "../LocalRuntime.ts"; import { detachQueueConsumersOfScript } from "../Queues/Consumer.ts"; import { CloudflareLogs } from "../Logs.ts"; import { resolveZoneId, type Reference as ZoneReference, } from "../Zone/lookup.ts"; import { getAssetsPathPrefix, mergeAssetsConfigFiles, readAssets, readAssetsConfigFiles, uploadAssets, } from "./Assets.ts"; import { getCompatibility } from "./Compatibility.ts"; import { isDurableObjectExport } from "./DurableObject.ts"; import { LocalWorkerProvider } from "./LocalWorkerProvider.ts"; import { makeSourceContext, resolveSource } from "./Source.ts"; import { isSelfUrl, Worker, type ViteOptions, type WorkerProps, type WorkerRouteConfig, type WorkerVersionAffinity, } from "./Worker.ts"; import { getCacheBinding, getCronBindings, isContainerDecl, } from "./WorkerAsyncBindings.ts"; import type { WireWorkerBinding, WorkerBinding, WorkerSettingsBinding, } from "./WorkerBinding.ts"; import { readPrebuiltWorkerBundle } from "./Sources/Prebuilt.ts"; import { isPythonMain, readPythonWorkerBundle } from "./Sources/Python.ts"; import { WorkerBundle } from "./Sources/Rolldown.ts"; import { isWorkerLoader } from "./WorkerLoader.ts"; import { createWorkerName } from "./WorkerName.ts"; class MissingDurableObjects extends Data.TaggedError("MissingDurableObjects")<{ scriptName: string; expected: string[]; }> {} /** * A Durable Object class is being dropped from this Worker while a binding in * the same deploy still references it on another script — the class moved * cross-script, but its namespace (and every stored object in it) still lives * on this Worker. Cloudflare rejects a single upload that both deletes the * class and ships a binding referencing it, and silently deleting would * destroy the namespace's data irreversibly, so the deploy fails before any * upload. * * Moving a Durable Object class between Workers is always declared: set * `transferredFrom` on the Durable Object at its **new host** — naming the * former host by Worker logical id (same stack) or physical script name — and * Alchemy performs the data-preserving `transferred_classes` migration on the * new host's deploy; this deploy then converges on its own. To abandon the * data instead, remove the binding entirely in one deploy (which deletes the * class and its data), then add the cross-script binding in a second deploy. */ export class DurableObjectTransferRequired extends Data.TaggedError( "DurableObjectTransferRequired", )<{ scriptName: string; className: string; targetScriptName: string | undefined; }> { override get message() { return ( `Durable Object class '${this.className}' still lives on Worker '${this.scriptName}' but this deploy re-binds it as a cross-script reference` + (this.targetScriptName ? ` to '${this.targetScriptName}'` : "") + ". Durable Object data does NOT move with the class automatically. " + `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. ` + "To abandon the data, remove the binding entirely in one deploy before re-adding it as a cross-script reference." ); } } /** * More than one script matches the `transferredFrom` declaration of a Durable * Object (e.g. an orphaned script left behind by a `name` prop change still * carries the same alchemy tags, or the host history lists several scripts * that each still hold a same-class namespace). Alchemy refuses to guess * which namespace's data to move — narrow the declaration to the exact * physical script name that holds the data. */ export class AmbiguousDurableObjectTransfer extends Data.TaggedError( "AmbiguousDurableObjectTransfer", )<{ scriptName: string; logicalId: string; className: string; sources: string[]; }> { override get message() { return ( `Durable Object '${this.logicalId}' (class '${this.className}') is new to Worker '${this.scriptName}' and multiple scripts match its transferredFrom declaration: ${this.sources.join(", ")}. ` + `Narrow transferredFrom to the exact physical script name that holds the data.` ); } } /** * Resolve the Workers for Platforms dispatch-namespace *name* from a resolved * `namespace` prop or persisted attribute. The engine resolves a passed * {@link DispatchNamespace} resource to its Attributes object (see * `Input.Resolve` / Plan.ts), so the value is either the namespace name * string, that attributes object, or `undefined` for a regular Worker. * * @internal */ export const resolveNamespaceName = ( namespace: unknown, ): string | undefined => { if (namespace == null) return undefined; if (typeof namespace === "string") return namespace; return (namespace as { name?: string }).name; }; /** * Resolve a Worker's `tailConsumers` / `streamingTailConsumers` prop into * the wire-shape consumer list * (`[{ service }]`). The engine resolves a passed {@link Worker} to its * Attributes object — possibly stables-only during planning, but * `workerName` is always a stable — so each entry is either a script-name * string or that attributes object. Whole-resource entries are reduced to * the script name alone so hashing/diffing never sees the consumer's * per-deploy fields (`hash`, `url`, ...), mirroring * {@link resolveVersionParentName}. * * An empty array resolves to `[]` (explicitly detach every consumer); * `undefined`/absent resolves to `undefined`. * * This is also the seam for local emulation: the local provider lowers this * same resolved list into workerd's `Worker.tails` / `Worker.streamingTails` * service designators (`RuntimeWorker.tails` / `RuntimeWorker.streamingTails`). * * @internal */ export const resolveTailConsumers = ( tailConsumers: WorkerProps["tailConsumers" | "streamingTailConsumers"], ): { service: string }[] | undefined => { if (tailConsumers == null) return undefined; return tailConsumers.flatMap((consumer) => { const service = typeof consumer === "string" ? consumer : (consumer as { workerName?: unknown }).workerName; return typeof service === "string" ? [{ service }] : []; }); }; /** * A Worker's `version` configuration is invalid — a prop that can't be * combined with `version.parent` (script-level settings belong to the * parent), a locally-hosted Durable Object / Workflow class on a version * worker, an out-of-range `traffic`, or a gradual rollout that requires * changes the versions API can't carry (assets, DO migrations). */ export class WorkerVersionConfigError extends Data.TaggedError( "WorkerVersionConfigError", )<{ message: string; }> {} /** * Resolve the parent script *name* from a resolved `version.parent` prop or * persisted props. The engine resolves a passed {@link Worker} (or * `Worker.ref(...)`) to its Attributes object — possibly stables-only during * planning, but `workerName` is always a stable — so the value is either the * script name string, that attributes object, or `undefined`. * * @internal */ export const resolveVersionParentName = ( version: WorkerProps["version"], ): string | undefined => { const parent = version?.parent; if (parent == null) return undefined; if (typeof parent === "string") return parent; const workerName = (parent as { workerName?: unknown }).workerName; return typeof workerName === "string" ? workerName : undefined; }; /** * The traffic percentage a *self-owned* Worker's new version should receive, * or `undefined` for the default full cutover. Only a `version` prop without * a `parent` participates — version workers handle traffic separately. * * @internal */ const getSelfRolloutTraffic = (news: WorkerProps): number | undefined => { if (!news.version || news.version.parent != null) return undefined; const traffic = news.version.traffic; return traffic === undefined || traffic >= 100 ? undefined : traffic; }; const validateTraffic = (traffic: number | undefined) => traffic !== undefined && (!Number.isFinite(traffic) || traffic < 0 || traffic > 100) ? Effect.fail( new WorkerVersionConfigError({ message: `version.traffic must be a percentage between 0 and 100, got ${traffic}`, }), ) : Effect.void; /** The request header Cloudflare hashes to pin a request to a version. */ const AFFINITY_HEADER = "Cloudflare-Workers-Version-Key"; /** * `version.affinity` normalized to a single key source plus the optional * IP fallback. * * @internal exported for unit testing. */ export interface ResolvedVersionAffinity { source: | { kind: "cookie" | "header"; name: string } | { kind: "ip" } | { kind: "key"; expression: string }; ipFallback: boolean; } // Cookie / header names are interpolated into a double-quoted Rules-language // string literal — restrict them to the token characters real-world names // use so a name can never terminate the literal or smuggle expression text. const AFFINITY_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; /** * Validate `version.affinity` and normalize it to its key source: exactly * one of `cookie` / `header` / `key`, or `ip: true` alone; `ip` combines * with `cookie` / `header` as the absent-source fallback. * * @internal exported for unit testing. */ export const resolveVersionAffinity = ( affinity: WorkerVersionAffinity, ): Effect.Effect => Effect.gen(function* () { const declared = [ ...(affinity.cookie !== undefined ? ["cookie"] : []), ...(affinity.header !== undefined ? ["header"] : []), ...(affinity.key !== undefined ? ["key"] : []), ]; if (declared.length > 1) { return yield* Effect.fail( new WorkerVersionConfigError({ message: `version.affinity accepts exactly one key source, got ${declared.join(" and ")}. Combine sources with a raw \`key\` expression instead.`, }), ); } if (declared.length === 0 && affinity.ip !== true) { return yield* Effect.fail( new WorkerVersionConfigError({ message: "version.affinity requires a key source: set `cookie`, `header`, `key`, or `ip: true`.", }), ); } if (affinity.key !== undefined && affinity.ip === true) { return yield* Effect.fail( new WorkerVersionConfigError({ message: "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.", }), ); } for (const [prop, name] of [ ["cookie", affinity.cookie], ["header", affinity.header], ] as const) { if (name !== undefined && !AFFINITY_NAME_PATTERN.test(name)) { return yield* Effect.fail( new WorkerVersionConfigError({ message: `version.affinity.${prop} '${name}' is not a valid ${prop} name: expected only letters, digits, '_', '.', and '-'.`, }), ); } } const source: ResolvedVersionAffinity["source"] = affinity.cookie !== undefined ? { kind: "cookie", name: affinity.cookie } : affinity.header !== undefined ? // Rules-language header map keys are lowercase. { kind: "header", name: affinity.header.toLowerCase() } : affinity.key !== undefined ? { kind: "key", expression: affinity.key } : { kind: "ip" }; return { source, ipFallback: affinity.ip === true && (source.kind === "cookie" || source.kind === "header"), }; }); /** A hostname a Worker serves on within one zone. */ interface AffinityZoneHost { host: string; /** `true` when `host` came from a route pattern containing `*`. */ wildcard: boolean; } /** * The `http.host` clause scoping a zone's affinity rules to the Worker's * own hostnames, so unrelated zone traffic — and other Workers' rollouts * on the same zone — never get this Worker's version key. * * @internal exported for unit testing. */ export const affinityHostExpression = ( hosts: readonly AffinityZoneHost[], ): string => { const dedupe = (values: string[]) => [...new Set(values)].sort(); const exact = dedupe(hosts.filter((h) => !h.wildcard).map((h) => h.host)); const wild = dedupe(hosts.filter((h) => h.wildcard).map((h) => h.host)); const clauses = [ ...(exact.length === 1 ? [`http.host eq "${exact[0]}"`] : exact.length > 1 ? [`http.host in {${exact.map((h) => `"${h}"`).join(" ")}}`] : []), ...wild.map((h) => `http.host wildcard "${h}"`), ]; return clauses.length === 1 ? clauses[0] : `(${clauses.join(" or ")})`; }; interface AffinityRuleSpec { description: string; expression: string; /** Rules-language expression producing the header value. */ value: string; } const affinityRulePrefix = (scriptName: string) => `alchemy:worker:${scriptName}:affinity`; /** * The transform rules pinning one zone's traffic: a primary rule filling * the version-key header from the configured source, plus — for * `cookie`/`header` sources with `ip: true` — a fallback rule keying * requests that lack the source by client IP. * * @internal exported for unit testing. */ export const buildAffinityZoneRules = ( scriptName: string, affinity: ResolvedVersionAffinity, hosts: readonly AffinityZoneHost[], ): AffinityRuleSpec[] => { const prefix = affinityRulePrefix(scriptName); const hostExpr = affinityHostExpression(hosts); const { source } = affinity; if (source.kind === "ip" || source.kind === "key") { return [ { description: `${prefix}:key`, expression: hostExpr, value: source.kind === "ip" ? "to_string(ip.src)" : source.expression, }, ]; } const field = source.kind === "cookie" ? `http.request.cookies["${source.name}"]` : `http.request.headers["${source.name}"]`; return [ { description: `${prefix}:key`, expression: `${hostExpr} and len(${field}) > 0`, value: `${field}[0]`, }, ...(affinity.ipFallback ? [ { // An absent cookie/header is a *missing* value in the Rules // language, and every comparison on missing evaluates false — // `len(field) == 0` can never match. `not (len(field) > 0)` // is the complement that does: missing → false → `not` → true. description: `${prefix}:ip`, expression: `${hostExpr} and not (len(${field}) > 0)`, value: "to_string(ip.src)", }, ] : []), ]; }; /** * A Worker's `domain` configuration is invalid — a hostname appears in more * than one role (name/aliases/redirects), or a redirect targets itself. */ export class WorkerDomainConfigError extends Data.TaggedError( "WorkerDomainConfigError", )<{ message: string; }> {} /** * The resolved shape of `WorkerProps.workersDev`: `enabled` drives the * stable `..workers.dev` URL, `previewsEnabled` the * per-version preview URLs. The two toggles are independent on the * Cloudflare API. * * @internal exported for unit testing. */ export interface ResolvedWorkersDev { enabled: boolean; previewsEnabled: boolean; } /** * Resolve the `workersDev` prop to its full shape. `true` / omitted means * "default workers.dev behavior" (stable URL + version previews), `false` * disables both, and the object form fills unset toggles with `true`. * * @internal exported for unit testing. */ export const resolveWorkersDev = ( workersDev: WorkerProps["workersDev"], ): ResolvedWorkersDev => { if (workersDev === undefined || workersDev === true) { return { enabled: true, previewsEnabled: true }; } if (workersDev === false) { return { enabled: false, previewsEnabled: false }; } return { enabled: workersDev.enabled ?? true, previewsEnabled: workersDev.previewsEnabled ?? true, }; }; /** * The resolved shape of `WorkerProps.domain`: the canonical hostname plus * alias and redirect hostname lists, all punycode-normalized and * de-duplicated. * * @internal exported for unit testing. */ export interface ResolvedWorkerDomain { name: string; aliases: string[]; redirects: string[]; /** Pinned zone from props, when the caller set zoneId / zone / zoneName. */ zone?: ZoneReference; } const isZoneReference = (value: unknown): value is ZoneReference => { if (typeof value === "string") return true; if (typeof value !== "object" || value === null) return false; const candidate = value as { zoneId?: unknown; name?: unknown }; return ( typeof candidate.zoneId === "string" && (candidate.name === undefined || typeof candidate.name === "string") ); }; /** Collapse Worker.domain zone pin fields to one {@link ZoneReference}. */ export const resolveWorkerDomainZone = ( config: | { readonly zoneId?: unknown; readonly zoneName?: unknown; readonly zone?: unknown; } | undefined, ): ZoneReference | undefined => { if (config === undefined) return undefined; if (typeof config.zoneId === "string") return config.zoneId; if (isZoneReference(config.zone)) return config.zone; if (typeof config.zoneName === "string") return config.zoneName; return undefined; }; /** Whether an existing attachment must move to satisfy an explicit zone pin. */ export const shouldRecreateWorkerDomainAttachment = ( liveZoneId: string, desiredZoneId: string | undefined, ): boolean => desiredZoneId !== undefined && liveZoneId !== desiredZoneId; // After deleting a custom-domain attachment, Cloudflare can briefly retain // ownership of the hostname and reject the replacement with code 100116. const workerDomainConflictSchedule = Schedule.max([ Schedule.spaced("2 seconds"), Schedule.recurs(8), ]); // Convert non-ASCII hostnames (emoji, IDN, etc.) to punycode so the // Cloudflare API receives the form it stores domains in. `new URL(...)` // does IDNA via WHATWG URL parsing — `📦.alchemy.run` → `xn--5z8h.alchemy.run`. const toPunycode = (hostname: string): string => { try { return new URL(`https://${hostname}`).hostname; } catch { return hostname; } }; /** * Resolve the `domain` prop to its full shape — a bare string is shorthand * for `{ name }`. Hostnames are punycode-normalized and de-duplicated; * a hostname may only play one role, so aliases/redirects that repeat the * canonical name (or each other) fail with a typed error. * * @internal exported for unit testing. */ export const resolveWorkerDomain = ( // `string[]` is the pre-redesign prop shape — it can still reach us from // persisted `olds` written by older providers (read's classification). domain: WorkerProps["domain"] | string[], ): Effect.Effect => Effect.gen(function* () { if (domain === undefined || domain === null) return undefined; // Legacy array form: the first hostname was the primary custom domain // (`url = domains[0]` back then), the rest map to aliases. A legacy // empty array was the explicit detach-all — no domain. const config = typeof domain === "string" ? { name: domain } : Array.isArray(domain) ? domain.length > 0 ? { name: domain[0], aliases: domain.slice(1) } : undefined : domain; if (config === undefined) return undefined; const name = toPunycode(config.name); const aliases = Array.from(new Set((config.aliases ?? []).map(toPunycode))); const redirects = Array.from( new Set((config.redirects ?? []).map(toPunycode)), ); const overlap = [ ...aliases.filter((h) => h === name), ...redirects.filter((h) => h === name || aliases.includes(h)), ]; if (overlap.length > 0) { return yield* Effect.fail( new WorkerDomainConfigError({ 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.`, }), ); } const zone = resolveWorkerDomainZone(config); return zone === undefined ? { name, aliases, redirects } : { name, aliases, redirects, zone }; }); const isWorkersDevHostname = (hostname: string) => hostname.endsWith(".workers.dev"); // Hostnames that only appear in local-dev state (the dev server's // localhost/LAN URLs), never as attachable custom domains. const isLocalDevHostname = (hostname: string) => hostname === "localhost" || hostname === "::1" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname); const urlHostname = (url: string): string => { try { return new URL(url).hostname; } catch { return url; } }; /** The `rules` payload shape of a zone phase-entrypoint PUT. */ type PutZoneRedirectRules = rulesets.PutPhasForZoneRequest["rules"]; /** * The *custom domain* hostnames recorded in a Worker's persisted legacy * `domains` state — minus the workers.dev (stable or preview) and local-dev * entries that shared the list in older formats. * * @internal exported for unit testing. */ export const stateCustomDomains = ( domains: readonly unknown[] | undefined, ): string[] => normalizeStateDomains(domains).filter( (hostname) => !isWorkersDevHostname(hostname) && !isLocalDevHostname(hostname), ); /** * The Worker's persisted domain configuration: the `domain` attribute for * state written by the current format, else re-derived from the legacy * `domains` list (first custom hostname = canonical name, rest = aliases; * legacy state had no redirects). * * @internal exported for unit testing. */ export const stateWorkerDomain = ( output: object | undefined, ): ResolvedWorkerDomain | undefined => { const state = output as | { domain?: { name?: unknown; aliases?: unknown[]; redirects?: unknown[]; zone?: ZoneReference; zoneId?: unknown; zoneName?: unknown; } | null; domains?: unknown[]; } | undefined; const domain = state?.domain; if (domain && typeof domain.name === "string") { const zone = resolveWorkerDomainZone(domain); return { name: domain.name, aliases: (domain.aliases ?? []).filter( (h): h is string => typeof h === "string", ), redirects: (domain.redirects ?? []).filter( (h): h is string => typeof h === "string", ), ...(zone === undefined ? {} : { zone }), }; } const legacy = stateCustomDomains(state?.domains); return legacy.length > 0 ? { name: legacy[0], aliases: legacy.slice(1), redirects: [] } : undefined; }; // Workers for Platforms "user workers" live inside a dispatch namespace and // use a parallel family of script endpoints (`/workers/dispatch/namespaces/ // :namespace/scripts/...`). The request/response shapes are identical to the // account-level Workers API for everything the provider touches, so these // helpers route by `dispatchNamespace` and the call sites stay agnostic. /** * Read a script's combined settings, routing to the dispatch-namespace * endpoint when `dispatchNamespace` is set. The two response shapes are * structurally identical for the fields the provider consumes (`bindings`, * `tags`, `logpush`), so the WFP response is surfaced as the workers shape. * * @internal */ const getScriptSettings = ( accountId: string, scriptName: string, dispatchNamespace: string | undefined, ) => // `Effect.gen` (rather than a ternary) so the two branches unify into a // single `Effect` instead of a *union* of // Effects, which `.pipe`/`catchTag` at the call sites can't consume. Effect.gen(function* () { if (dispatchNamespace) { const settings = yield* wfp.getDispatchNamespaceScriptSetting({ accountId, dispatchNamespace, scriptName, }); // The dispatch-namespace settings response is structurally identical to // the account-level one for the fields the provider reads. return settings as unknown as workers.GetScriptScriptAndVersionSettingResponse; } return yield* workers.getScriptScriptAndVersionSetting({ accountId, scriptName, }); }); /** * Deploy-time binding validation rejects an upload whose bindings * reference a resource Cloudflare can't see (each resource type has * its own typed not-found error, verified against the live API). * Every bound resource is provisioned before the Worker deploys — * dependency order for KV/R2/D1/queues/etc., a pre-created stub * (which exports the Durable Object classes) for circular * Worker↔Worker references — so a not-found here is either * propagation lag on a just-created resource (a Secrets Store secret * still `pending`, a stub script not yet in the registry) that * retrying converges, or a genuine misconfiguration that keeps * failing and surfaces as the typed error once the bounded budget is * exhausted. */ const isBindingTargetNotFound = ( e: | Effect.Error> | Effect.Error> | Effect.Error>, ): boolean => e._tag === "SecretsStoreBindingNotFound" || e._tag === "KVNamespaceNotFound" || e._tag === "R2BucketNotFound" || e._tag === "D1DatabaseNotFound" || e._tag === "QueueNotFound" || e._tag === "ServiceBindingNotFound" || e._tag === "DurableObjectClassNotFound" || e._tag === "HyperdriveConfigNotFound" || e._tag === "VectorizeIndexNotFound" || e._tag === "DispatchNamespaceNotFound" || e._tag === "MtlsCertificateNotFound"; const bindingTargetNotFoundRetrySchedule = () => Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]); /** * Script PUT is an idempotent upsert, so a pure transport failure (the * request died before any response — e.g. Cloudflare closing a keep-alive * socket that idled while slow upstream resources provisioned earlier in the * deploy) is safe to replay. Errors that carry a response are real API * verdicts and are NOT retried here. */ const isScriptPutTransportError = (e: { _tag?: string }): boolean => isHttpClientError(e) && e.reason._tag === "TransportError"; const retryableScriptPut = ( e: Parameters[0], ): boolean => isBindingTargetNotFound(e) || isScriptPutTransportError(e); /** * Upsert a Worker script, routing to the dispatch-namespace endpoint when * `dispatchNamespace` is set. The metadata/files contract is identical, and * both endpoints run the same binding validation (see * {@link isBindingTargetNotFound}), so both get the same bounded retry. * * @internal */ /** * A cached `workerId` attribute usable as the immutable script ID — rules * out the legacy shape (older releases persisted the script *name*), the * `dev:`-marked local identity, and the precreate stub's provisional `""`. */ const cachedWorkerId = ( value: string | undefined, scriptName: string, ): string | undefined => value !== undefined && value !== "" && value !== scriptName && !value.startsWith("dev:") ? value : undefined; export class WorkerIdNotFound extends Data.TaggedError("WorkerIdNotFound")<{ scriptName: string; message: string; }> {} /** * Resolve a script's immutable Worker ID (carried as `tag` on Cloudflare's * wire) by script name. Neither the settings endpoints nor GET /content * expose it, so scan the account's script listing lazily and stop at the * first match. The listing is eventually consistent, so a missing entry is * retried briefly before failing. */ const findWorkerId = (accountId: string, scriptName: string) => workers.listScripts.items({ accountId }).pipe( Stream.filter((script) => script.id === scriptName), Stream.runHead, Effect.map(Option.getOrUndefined), Effect.flatMap((script) => script?.tag != null ? Effect.succeed(script.tag) : Effect.fail( new WorkerIdNotFound({ scriptName, message: `Cloudflare Worker: could not resolve the immutable ID of script '${scriptName}' from the account listing`, }), ), ), Effect.retry({ while: (e) => e._tag === "WorkerIdNotFound", schedule: Schedule.max([Schedule.spaced(2000), Schedule.recurs(3)]), }), ); const putWorkerScript = (params: { accountId: string; scriptName: string; dispatchNamespace: string | undefined; metadata: workers.PutScriptRequest["metadata"]; files: workers.PutScriptRequest["files"]; }) => Effect.gen(function* () { if (params.dispatchNamespace) { return yield* wfp .putDispatchNamespaceScript({ accountId: params.accountId, dispatchNamespace: params.dispatchNamespace, scriptName: params.scriptName, metadata: params.metadata as unknown as wfp.PutDispatchNamespaceScriptRequest["metadata"], files: params.files, }) .pipe( Effect.retry({ while: retryableScriptPut, schedule: bindingTargetNotFoundRetrySchedule(), }), ); } return yield* workers .putScript({ accountId: params.accountId, scriptName: params.scriptName, metadata: params.metadata, files: params.files, }) .pipe( Effect.retry({ while: retryableScriptPut, schedule: bindingTargetNotFoundRetrySchedule(), }), ); }); /** * Delete a Worker script, routing to the dispatch-namespace endpoint when * `dispatchNamespace` is set. * * @internal */ const deleteWorkerScript = ( accountId: string, scriptName: string, dispatchNamespace: string | undefined, ) => Effect.gen(function* () { if (dispatchNamespace) { return yield* wfp.deleteDispatchNamespaceScript({ accountId, dispatchNamespace, scriptName, force: true, }); } return yield* workers .deleteScript({ accountId, scriptName, force: true }) .pipe( // The script is still registered as a queue consumer (even with // `force`). Normally the sibling Consumer resource detaches first, // but state loss (e.g. a consumer row rewritten by a pre-stamping // dev run) can strand a live consumer pointing at this script with // nothing left to delete it. The script is going away, so any // consumer wiring pointing at it is dead — detach and retry. Effect.catchTag("QueueConsumerConflict", () => detachQueueConsumersOfScript(accountId, scriptName).pipe( Effect.andThen( workers.deleteScript({ accountId, scriptName, force: true }), ), ), ), ); }); /** * Normalize a Worker's persisted *legacy* `domains` state to bare * hostnames. Alchemy <= beta.44 stored each custom domain as a * `{ id, hostname, zoneId }` object; beta.45+ stored `https://` * URL strings (with the workers.dev URL mixed in); current state stores the * `domain` config object instead of a `domains` list. All legacy * generations coerce to hostnames so the diff never throws on older state * (#546). Entries that fit no generation are dropped rather than turned * into a bogus hostname that would skew the diff. * * @internal exported for unit testing. */ export const normalizeStateDomains = ( domains: readonly unknown[] | undefined, ): string[] => (domains ?? []).flatMap((u) => { if (typeof u === "string") { if (u.includes("://")) { try { return [new URL(u).hostname]; } catch { return []; } } return u.length > 0 ? [u] : []; } const hostname = (u as { hostname?: unknown } | null)?.hostname; return typeof hostname === "string" ? [hostname] : []; }); /** * Custom domains Alchemy is responsible for on this Worker — either declared * on props (`domain`) or already persisted as non-`workers.dev` URLs in state. * Used by `read` to skip `listDomains` when the surface is unmanaged (#926). * * @internal exported for unit testing. */ export const shouldObserveWorkerDomains = ( olds: Pick | undefined, output: object | undefined, ): boolean => olds?.domain !== undefined || stateWorkerDomain(output) !== undefined; /** * Zone routes Alchemy is responsible for on this Worker. Used by `read` to * skip account-wide zone/route fan-out when the surface is unmanaged (#926). * * @internal exported for unit testing. */ export const shouldObserveWorkerRoutes = ( olds: Pick | undefined, output: Pick | undefined, ): boolean => olds?.routes !== undefined || (output?.routes?.length ?? 0) > 0; /** * Cron triggers Alchemy is responsible for on this Worker. Used by `read` to * skip `getScriptSchedule` when the surface is unmanaged (#926). Effect-native * `cron()` bindings persist into `output.crons` after the first reconcile, so * subsequent reads still observe them. * * @internal exported for unit testing. */ export const shouldObserveWorkerCrons = ( olds: Pick | undefined, output: Pick | undefined, ): boolean => olds?.crons !== undefined || (output?.crons?.length ?? 0) > 0; /** * Optional override for the account's stable `workers.dev` subdomain * (`` in `https://