| 1 | import type { |
| 2 | BindingHook, |
| 3 | BindingServices, |
| 4 | HyperdriveOrigin, |
| 5 | Assets as RuntimeAssets, |
| 6 | DurableObjectNamespace as RuntimeDurableObject, |
| 7 | QueueConsumer as RuntimeQueueConsumer, |
| 8 | RuntimeServices, |
| 9 | } from "@alchemy.run/cloudflare-runtime/core"; |
| 10 | import type * as Context from "effect/Context"; |
| 11 | import * as Data from "effect/Data"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import type * as FileSystem from "effect/FileSystem"; |
| 14 | import type * as Path from "effect/Path"; |
| 15 | import type { PlatformError } from "effect/PlatformError"; |
| 16 | import type * as Scope from "effect/Scope"; |
| 17 | import type * as Stream from "effect/Stream"; |
| 18 | import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 19 | import type { Artifacts } from "../../Artifacts.ts"; |
| 20 | import type * as Bundle from "../../Bundle/Bundle.ts"; |
| 21 | import type { AssetReadResult, ValidationError } from "./Assets.ts"; |
| 22 | import type { WorkerExport } from "./WorkerRuntimeContext.ts"; |
| 23 | import { getToolingCompatibility } from "./Compatibility.ts"; |
| 24 | import { makeInlineScriptSource } from "./Sources/InlineScript.ts"; |
| 25 | import { makePrebuiltSource } from "./Sources/Prebuilt.ts"; |
| 26 | import { isPythonMain, makePythonSource } from "./Sources/Python.ts"; |
| 27 | import { makeRolldownSource } from "./Sources/Rolldown.ts"; |
| 28 | import type { |
| 29 | WorkerAssetsConfig, |
| 30 | WorkerProps, |
| 31 | WorkerSourceDescriptor, |
| 32 | } from "./Worker.ts"; |
| 33 | |
| 34 | /** |
| 35 | * The hash slots a Worker source contributes to |
| 36 | * `Worker["Attributes"]["hash"]`. Slots a source doesn't use are |
| 37 | * `undefined`. The `metadata` hash (#745) is deliberately NOT a source |
| 38 | * slot — it covers the deploy-time metadata surface and stays owned by |
| 39 | * the WorkerProvider. |
| 40 | */ |
| 41 | export interface SourceHash { |
| 42 | readonly bundle: string | undefined; |
| 43 | readonly assets: string | undefined; |
| 44 | readonly input: string | undefined; |
| 45 | /** |
| 46 | * Auxiliary metadata for the `input` hash (vite's auto-detected |
| 47 | * workspace directories, relative to the source root). Never compared |
| 48 | * as a change signal — only carried so the next `hash()` call can |
| 49 | * recompute `input` over the same workspace set. |
| 50 | */ |
| 51 | readonly additionalWorkspaces: string[] | undefined; |
| 52 | } |
| 53 | |
| 54 | export interface SourceBuildOutput { |
| 55 | /** |
| 56 | * Server bundle. `undefined` for assets-only workers (e.g. a static |
| 57 | * vite site with no server environment). |
| 58 | */ |
| 59 | readonly bundle: Bundle.BundleOutput | undefined; |
| 60 | /** |
| 61 | * Static assets, already read and manifest-hashed. `undefined` when the |
| 62 | * source doesn't own assets — the WorkerProvider merges in the |
| 63 | * props-level `assets` for those sources (see |
| 64 | * {@link SourceProvider.ownsAssets}). |
| 65 | */ |
| 66 | readonly assets: AssetReadResult | undefined; |
| 67 | /** Source-owned hash slots. Slots the source doesn't use are omitted. */ |
| 68 | readonly hash: Partial<SourceHash>; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Everything a source may need, derived once by the WorkerProvider from |
| 73 | * `(id, props, stack)`. Source-specific inputs (entry module path, vite |
| 74 | * options, inline script, ...) are closed over by the provider instance |
| 75 | * itself — providers are resolved per Worker from props, not injected |
| 76 | * once per stack. |
| 77 | */ |
| 78 | export interface SourceContext { |
| 79 | /** Resolved runtime storage directory, excluded from source inputs. */ |
| 80 | readonly dotAlchemy?: string; |
| 81 | /** Logical id of the Worker resource. */ |
| 82 | readonly id: string; |
| 83 | /** Namespace-qualified id (`ns/Worker`) — the display prefix for log lines. */ |
| 84 | readonly fqn: string; |
| 85 | /** Physical script name. */ |
| 86 | readonly workerName: string; |
| 87 | readonly compatibility: { |
| 88 | readonly date: string; |
| 89 | readonly flags: string[]; |
| 90 | }; |
| 91 | /** Effect-entry vs external-entry (async workers). Drives the virtual-entry plugin. */ |
| 92 | readonly entry: |
| 93 | | { readonly kind: "external" } |
| 94 | | { |
| 95 | readonly kind: "effect"; |
| 96 | readonly exports: Record<string, WorkerExport>; |
| 97 | }; |
| 98 | readonly stack: { readonly name: string; readonly stage: string }; |
| 99 | /** |
| 100 | * Raw `props.env`. Deliberately unresolved: env entries may be Effects |
| 101 | * whose evaluation requires plan-phase context that is not available |
| 102 | * inside lifecycle operations — a source that needs literal values |
| 103 | * (e.g. vite's `import.meta.env` defines) resolves them itself, exactly |
| 104 | * as the vite arm always has. |
| 105 | */ |
| 106 | readonly env: Record<string, unknown> | undefined; |
| 107 | /** `props.build` passthrough for rolldown-based sources. */ |
| 108 | readonly extraOptions: Bundle.BundleExtraOptions | undefined; |
| 109 | /** |
| 110 | * Raw `props.assets`. Sources that own their assets (vite) merge its |
| 111 | * routing config into the assets they read out of the build; sources |
| 112 | * that don't own assets never touch it (the WorkerProvider reads and |
| 113 | * diffs props-level assets centrally). |
| 114 | */ |
| 115 | readonly assets: WorkerAssetsConfig | undefined; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Runtime wiring for local dev, derived by the LocalWorkerProvider. |
| 120 | * Server-mode sources that embed their own workerd (the vite plugin) |
| 121 | * consume `worker` + `runtimeContext`; bundle-mode sources ignore both. |
| 122 | */ |
| 123 | export interface DevContext extends SourceContext { |
| 124 | readonly worker: { |
| 125 | readonly bindings: BindingHook<BindingServices>[]; |
| 126 | readonly durableObjectNamespaces: (RuntimeDurableObject & { |
| 127 | uniqueKey: string; |
| 128 | })[]; |
| 129 | readonly hyperdrives: Record<string, Required<HyperdriveOrigin>>; |
| 130 | /** Re-read on each (re)start so late-registered consumers are observed. */ |
| 131 | readonly queueConsumers: Effect.Effect<RuntimeQueueConsumer[]>; |
| 132 | readonly assets: RuntimeAssets | undefined; |
| 133 | }; |
| 134 | /** RuntimeServices context for providers that embed cloudflare-runtime. */ |
| 135 | readonly runtimeContext: Context.Context<RuntimeServices>; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * How a source serves local dev. Two irreducible modes: |
| 140 | * |
| 141 | * - `bundle` — the host runs workerd; the source supplies rebuild events |
| 142 | * and the host restarts workerd with each successful bundle. |
| 143 | * - `server` — the source runs its own dev server (vite dev); the host |
| 144 | * points the WorkerProxy at `url`. |
| 145 | */ |
| 146 | export type SourceDevHandle = |
| 147 | | { |
| 148 | readonly mode: "bundle"; |
| 149 | readonly bundles: Stream.Stream< |
| 150 | Bundle.BundleWatchEvent, |
| 151 | SourceError, |
| 152 | SourceDevServices |
| 153 | >; |
| 154 | } |
| 155 | | { |
| 156 | readonly mode: "server"; |
| 157 | readonly url: URL; |
| 158 | /** Register an HTTP forwarding Worker when the server does not run in workerd. */ |
| 159 | readonly serviceBinding?: "http"; |
| 160 | }; |
| 161 | |
| 162 | /** |
| 163 | * The closed requirements channel of a source. Anything else a source |
| 164 | * needs (vite module loading, framework CLIs, workerd runtime services |
| 165 | * for dev) it constructs internally — external providers must NOT demand |
| 166 | * bespoke services from the WorkerProvider. |
| 167 | */ |
| 168 | export type SourceServices = |
| 169 | | FileSystem.FileSystem |
| 170 | | Path.Path |
| 171 | | ChildProcessSpawner |
| 172 | | Scope.Scope |
| 173 | | Artifacts; |
| 174 | |
| 175 | /** Requirements available to `dev()` (no per-run Artifacts cache in the local host). */ |
| 176 | export type SourceDevServices = |
| 177 | | FileSystem.FileSystem |
| 178 | | Path.Path |
| 179 | | ChildProcessSpawner |
| 180 | | Scope.Scope; |
| 181 | |
| 182 | /** |
| 183 | * An error raised by an external source provider, wrapped at the load |
| 184 | * boundary so the WorkerProvider's lifecycle error union stays closed. |
| 185 | */ |
| 186 | export class SourceProviderError extends Data.TaggedError( |
| 187 | "Cloudflare.Workers.SourceProviderError", |
| 188 | )<{ |
| 189 | /** Module specifier or built-in kind. */ |
| 190 | readonly provider: string; |
| 191 | readonly message: string; |
| 192 | readonly cause?: unknown; |
| 193 | }> {} |
| 194 | |
| 195 | export type SourceError = |
| 196 | | Bundle.BundleError |
| 197 | | ValidationError |
| 198 | | PlatformError |
| 199 | | SourceProviderError; |
| 200 | |
| 201 | /** |
| 202 | * A Worker source provider: supplies the static assets, the server |
| 203 | * bundle, and hashes for memoization, plus a dev-serving story. The five |
| 204 | * built-in arms (inline script, rolldown, python, prebuilt, vite) live in |
| 205 | * `Workers/Sources/`; framework providers implement the same interface. |
| 206 | */ |
| 207 | export interface SourceProvider { |
| 208 | /** |
| 209 | * Whether this source produces its own static assets from its build |
| 210 | * (vite, framework builds). When `false`, the WorkerProvider reads and |
| 211 | * diffs the props-level `assets` directory centrally — including the |
| 212 | * AssetsWithHash fast path — and the source only supplies the bundle. |
| 213 | */ |
| 214 | readonly ownsAssets: boolean; |
| 215 | |
| 216 | /** |
| 217 | * Full build. Called from reconcile (`putWorker`). If the build is |
| 218 | * expensive it MUST be memoized per run via `Artifacts.cached` under |
| 219 | * the same key `hash()` uses, so a diff that had to build doesn't |
| 220 | * build twice. |
| 221 | */ |
| 222 | readonly build: ( |
| 223 | ctx: SourceContext, |
| 224 | ) => Effect.Effect<SourceBuildOutput, SourceError, SourceServices>; |
| 225 | |
| 226 | /** |
| 227 | * Recompute the source-owned hash slots for diff, as cheaply as |
| 228 | * possible and WITHOUT a full build when the source supports it. |
| 229 | * `previous` is `output.hash` from state. Returns only the slots this |
| 230 | * source uses; the WorkerProvider compares slot-wise against |
| 231 | * `previous` — any defined slot that differs ⇒ update. |
| 232 | * |
| 233 | * Invariants: |
| 234 | * - MUST be deterministic for an unchanged source tree and |
| 235 | * machine-independent for identical bytes (never hash absolute paths). |
| 236 | * - If it cannot avoid building, it MUST route the build through the |
| 237 | * same `Artifacts.cached` key as `build()` so diff→reconcile builds |
| 238 | * once. |
| 239 | * - `previous` is a hint, never truth: a source must not skip |
| 240 | * recomputation because `previous` looks fresh — it may only use it |
| 241 | * for auxiliary inputs (`additionalWorkspaces`). |
| 242 | */ |
| 243 | readonly hash: ( |
| 244 | ctx: SourceContext, |
| 245 | previous: SourceHash | undefined, |
| 246 | ) => Effect.Effect<Partial<SourceHash>, SourceError, SourceServices>; |
| 247 | |
| 248 | /** |
| 249 | * Local dev. Scoped: closing the Scope stops the watcher / dev server. |
| 250 | */ |
| 251 | readonly dev: ( |
| 252 | ctx: DevContext, |
| 253 | ) => Effect.Effect< |
| 254 | SourceDevHandle, |
| 255 | SourceError, |
| 256 | SourceDevServices | Scope.Scope |
| 257 | >; |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * The contract of a dynamically-imported source-provider module: its |
| 262 | * default export builds a {@link SourceProvider} from the descriptor's |
| 263 | * (JSON-serializable) options. `make` provides the package's own layers |
| 264 | * internally, so the closed {@link SourceServices} requirement holds. |
| 265 | */ |
| 266 | export interface WorkerSourceModule { |
| 267 | readonly make: ( |
| 268 | options: unknown, |
| 269 | ) => Effect.Effect<SourceProvider, SourceProviderError, SourceServices>; |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Validated provider modules memoized per specifier for the process |
| 274 | * lifetime — a Worker resolves its source at least twice per deploy |
| 275 | * (diff, then reconcile). `make(options)` still runs per resolution: a |
| 276 | * stack can host several Workers of the same provider with different |
| 277 | * options. |
| 278 | */ |
| 279 | const sourceModules = new Map<string, WorkerSourceModule>(); |
| 280 | |
| 281 | const importSourceModule = ( |
| 282 | specifier: string, |
| 283 | ): Effect.Effect<WorkerSourceModule, SourceProviderError> => |
| 284 | Effect.suspend(() => { |
| 285 | const cached = sourceModules.get(specifier); |
| 286 | if (cached) { |
| 287 | return Effect.succeed(cached); |
| 288 | } |
| 289 | return Effect.tryPromise({ |
| 290 | try: () => import(/* @vite-ignore */ specifier), |
| 291 | catch: (cause) => |
| 292 | new SourceProviderError({ |
| 293 | provider: specifier, |
| 294 | message: |
| 295 | `Failed to import Worker source provider "${specifier}". ` + |
| 296 | `Is the package installed in your project? Install it and re-run.`, |
| 297 | cause, |
| 298 | }), |
| 299 | }).pipe( |
| 300 | Effect.flatMap((mod: { default?: Partial<WorkerSourceModule> }) => { |
| 301 | if (typeof mod.default?.make !== "function") { |
| 302 | return Effect.fail( |
| 303 | new SourceProviderError({ |
| 304 | provider: specifier, |
| 305 | message: |
| 306 | `Module "${specifier}" is not a Worker source provider: ` + |
| 307 | `its default export must satisfy WorkerSourceModule ({ make(options) }).`, |
| 308 | }), |
| 309 | ); |
| 310 | } |
| 311 | const module = mod.default as WorkerSourceModule; |
| 312 | sourceModules.set(specifier, module); |
| 313 | return Effect.succeed(module); |
| 314 | }), |
| 315 | ); |
| 316 | }); |
| 317 | |
| 318 | /** |
| 319 | * Load an external source provider from its serializable descriptor: |
| 320 | * dynamically import the module, validate the shape, and call |
| 321 | * `make(options)`. |
| 322 | */ |
| 323 | export const loadSource = ( |
| 324 | descriptor: WorkerSourceDescriptor, |
| 325 | ): Effect.Effect<SourceProvider, SourceProviderError, SourceServices> => |
| 326 | importSourceModule(descriptor.provider).pipe( |
| 327 | Effect.flatMap((mod) => mod.make(descriptor.options)), |
| 328 | ); |
| 329 | |
| 330 | /** |
| 331 | * Resolve the {@link SourceProvider} for a Worker from its props. The |
| 332 | * `source` descriptor (external providers) wins; legacy props map to the |
| 333 | * built-in providers, preserving the historical precedence exactly: |
| 334 | * `script` → `vite` → `.py` main → `bundle: false` → rolldown (default). |
| 335 | * |
| 336 | * `source` is mutually exclusive with `script`/`vite`/`main` — a source |
| 337 | * is self-contained, and a provider that needs a custom entry takes it |
| 338 | * in its own `options`. |
| 339 | */ |
| 340 | export const resolveSource = ( |
| 341 | props: WorkerProps, |
| 342 | ): Effect.Effect<SourceProvider, SourceProviderError, SourceServices> => { |
| 343 | if (props.source) { |
| 344 | const conflict = |
| 345 | props.script !== undefined |
| 346 | ? "script" |
| 347 | : props.vite |
| 348 | ? "vite" |
| 349 | : props.main !== undefined |
| 350 | ? "main" |
| 351 | : undefined; |
| 352 | if (conflict) { |
| 353 | return Effect.fail( |
| 354 | new SourceProviderError({ |
| 355 | provider: props.source.provider, |
| 356 | message: |
| 357 | `Worker prop "source" is mutually exclusive with "${conflict}": ` + |
| 358 | `a source provider is self-contained — pass a custom entry via the provider's own options instead.`, |
| 359 | }), |
| 360 | ); |
| 361 | } |
| 362 | return loadSource(props.source); |
| 363 | } |
| 364 | if (props.script !== undefined) { |
| 365 | return Effect.succeed(makeInlineScriptSource(props.script)); |
| 366 | } |
| 367 | if (props.vite) { |
| 368 | // Loaded lazily: `./Sources/Vite.ts` pulls in |
| 369 | // `@alchemy.run/cloudflare-runtime/vite` (~0.5s), which is only |
| 370 | // needed for vite-based workers — not for every Worker resolution. |
| 371 | const vite = props.vite; |
| 372 | return Effect.promise(() => import("./Sources/Vite.ts")).pipe( |
| 373 | Effect.map((Vite) => Vite.makeViteSource(vite)), |
| 374 | ); |
| 375 | } |
| 376 | if (isPythonMain(props.main)) { |
| 377 | return Effect.succeed(makePythonSource(props.main)); |
| 378 | } |
| 379 | if (props.bundle === false) { |
| 380 | return Effect.succeed( |
| 381 | makePrebuiltSource({ main: props.main!, rules: props.rules }), |
| 382 | ); |
| 383 | } |
| 384 | return Effect.succeed(makeRolldownSource({ main: props.main! })); |
| 385 | }; |
| 386 | |
| 387 | /** |
| 388 | * Derive the shared {@link SourceContext} from a Worker's props. Pure — |
| 389 | * the physical `workerName` is computed by the caller (it may come from |
| 390 | * persisted output state rather than the name generator). |
| 391 | */ |
| 392 | export const makeSourceContext = (params: { |
| 393 | dotAlchemy?: string; |
| 394 | id: string; |
| 395 | fqn: string; |
| 396 | workerName: string; |
| 397 | props: WorkerProps; |
| 398 | compatibility: { date: string; flags: string[] }; |
| 399 | stack: { name: string; stage: string }; |
| 400 | }): SourceContext => ({ |
| 401 | dotAlchemy: params.dotAlchemy, |
| 402 | id: params.id, |
| 403 | fqn: params.fqn, |
| 404 | workerName: params.workerName, |
| 405 | compatibility: getToolingCompatibility( |
| 406 | params.compatibility, |
| 407 | params.props.main, |
| 408 | ), |
| 409 | entry: params.props.isExternal |
| 410 | ? { kind: "external" } |
| 411 | : { kind: "effect", exports: params.props.exports ?? {} }, |
| 412 | stack: { name: params.stack.name, stage: params.stack.stage }, |
| 413 | env: params.props.env, |
| 414 | extraOptions: params.props.build, |
| 415 | assets: params.props.assets, |
| 416 | }); |