1import 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";
10import type * as Context from "effect/Context";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import type * as FileSystem from "effect/FileSystem";
14import type * as Path from "effect/Path";
15import type { PlatformError } from "effect/PlatformError";
16import type * as Scope from "effect/Scope";
17import type * as Stream from "effect/Stream";
18import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
19import type { Artifacts } from "../../Artifacts.ts";
20import type * as Bundle from "../../Bundle/Bundle.ts";
21import type { AssetReadResult, ValidationError } from "./Assets.ts";
22import type { WorkerExport } from "./WorkerRuntimeContext.ts";
23import { getToolingCompatibility } from "./Compatibility.ts";
24import { makeInlineScriptSource } from "./Sources/InlineScript.ts";
25import { makePrebuiltSource } from "./Sources/Prebuilt.ts";
26import { isPythonMain, makePythonSource } from "./Sources/Python.ts";
27import { makeRolldownSource } from "./Sources/Rolldown.ts";
28import 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 */
41export 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
54export 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 */
78export 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 */
123export 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 */
146export 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 */
168export 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). */
176export 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 */
186export 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
195export 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 */
207export 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 */
266export 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 */
279const sourceModules = new Map<string, WorkerSourceModule>();
280
281const 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 */
323export 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 */
340export 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 */
392export 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});