1import {
2 Runtime,
3 type RuntimeServices,
4 type BindingHook,
5 type BindingServices,
6 type HyperdriveOrigin,
7 type Module,
8 type DurableObjectNamespace as RuntimeDurableObject,
9 type QueueConsumer as RuntimeQueueConsumer,
10 type Workflow as RuntimeWorkflow,
11} from "@alchemy.run/cloudflare-runtime/core";
12import type { ContainerImage } from "@alchemy.run/cloudflare-runtime/core/Docker";
13import * as WorkerProxy from "@alchemy.run/cloudflare-runtime/core/proxy/WorkerProxy";
14import * as Cause from "effect/Cause";
15import * as ConsoleService from "effect/Console";
16import * as Effect from "effect/Effect";
17import * as Equal from "effect/Equal";
18import * as Exit from "effect/Exit";
19import * as Fiber from "effect/Fiber";
20import type * as FileSystem from "effect/FileSystem";
21import * as PlatformFileSystem from "effect/FileSystem";
22import * as MutableHashMap from "effect/MutableHashMap";
23import * as Option from "effect/Option";
24import * as Path from "effect/Path";
25import { dotAlchemyDirectory } from "../../AlchemyContext.ts";
26import { isPathWithin } from "../../Util/isPathWithin.ts";
27import * as Result from "effect/Result";
28import * as Scope from "effect/Scope";
29import * as Semaphore from "effect/Semaphore";
30import * as Stream from "effect/Stream";
31import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
32import * as os from "node:os";
33import type * as Bundle from "../../Bundle/Bundle.ts";
34import { ANSI_RESET, ansiFg, colorsEnabled } from "../../Util/Terminal.ts";
35import { theme } from "../../Util/Theme.ts";
36import {
37 formatResourceTag,
38 makeResourceLogger,
39 makeResourceOutput,
40} from "../../Util/ResourceOutput.ts";
41import { makeDevLogDirectory, makeDevLogOpener } from "../../Local/DevLog.ts";
42import { FQN_SEPARATOR } from "../../FQN.ts";
43import * as LocalProvider from "../../Local/LocalProvider.ts";
44import { Stack } from "../../Stack.ts";
45import { unwrapRedacted } from "../../Util/index.ts";
46import { sha256 } from "../../Util/sha256.ts";
47import { localAccountId } from "../LocalAccount.ts";
48import {
49 isLiveId,
50 LOCAL_PROVIDERS_URL,
51 LocalRuntimeState,
52 localStorageDirectory,
53} from "../LocalRuntime.ts";
54import type { ConsumerSettings } from "../Queues/Consumer.ts";
55import type { WorkerAssetsConfig, WorkerProps } from "../Workers/Worker.ts";
56import { readAssetsConfigFiles } from "./Assets.ts";
57import { getCompatibility } from "./Compatibility.ts";
58import { watchPrebuiltWorkerBundle } from "./Sources/Prebuilt.ts";
59import { isPythonMain, watchPythonWorkerBundle } from "./Sources/Python.ts";
60import {
61 Artifacts as AlchemyArtifacts,
62 createArtifactStore,
63 makeScopedArtifacts,
64} from "../../Artifacts.ts";
65import { loadSource, SourceProviderError, type DevContext } from "./Source.ts";
66import { isSelfUrl, Worker } from "./Worker.ts";
67import { getCronBindings } from "./WorkerAsyncBindings.ts";
68import type { WorkerBinding } from "./WorkerBinding.ts";
69import { WorkerBundle, type WorkerBundleOptions } from "./Sources/Rolldown.ts";
70import { createWorkerName } from "./WorkerName.ts";
71import { resolveTailConsumers } from "./WorkerProvider.ts";
72import {
73 materializeRuntimeBindings,
74 WorkerValidationError,
75} from "./RuntimeBindings.ts";
76import { startViteChild } from "./ViteChild.ts";
77import { DEFAULT_DEV_PORT, type ViteChildConfig } from "./ViteChild.shared.ts";
78
79/** Local dev-server options (the worker-mode arm of `WorkerProps["dev"]`). */
80type DevServerOptions = Extract<WorkerProps["dev"], { mode?: "worker" }> & {
81 port: number;
82};
83
84const workerStartedMessage = (
85 fqn: string,
86 elapsed: number,
87 url: URL,
88 logDir: string,
89): string => {
90 const duration = `${Math.round(elapsed)}ms`;
91 if (!colorsEnabled()) {
92 return `${formatResourceTag(fqn, false)} Started in ${duration} → ${url} (logs: ${logDir})`;
93 }
94 return `${formatResourceTag(fqn, true)} Started in ${ansiFg(theme.color.success)}${duration}${ANSI_RESET} → ${ansiFg(theme.color.info)}\x1b[4m${url}${ANSI_RESET} ${ansiFg(theme.color.muted)}(logs: ${logDir})${ANSI_RESET}`;
95};
96
97const workerUpdatedMessage = (fqn: string, elapsed: number): string => {
98 const duration = `${Math.round(elapsed)}ms`;
99 return colorsEnabled()
100 ? `${formatResourceTag(fqn, true)} Updated in ${ansiFg(theme.color.success)}${duration}${ANSI_RESET}`
101 : `${formatResourceTag(fqn, false)} Updated in ${duration}`;
102};
103
104// Hosts that bind every interface — the dev server is then reachable at
105// `localhost` *and* at each LAN address, like Vite's `--host` output.
106const isWildcardHost = (host: string) =>
107 host === "0.0.0.0" || host === "::" || host === "[::]" || host === "*";
108
109/**
110 * Resolve every URL a local dev server is reachable at, most relevant
111 * first. A loopback or explicit host yields just that URL; a wildcard host
112 * (`0.0.0.0`, `::`) yields `http://localhost:<port>` followed by one URL
113 * per external IPv4 interface (`http://192.168.0.12:<port>`, ...) —
114 * mirroring Vite's "Local / Network" dev-server output.
115 */
116const resolveLocalUrls = (serverUrl: URL): Effect.Effect<string[]> =>
117 Effect.sync(() => {
118 const port = serverUrl.port;
119 const host = serverUrl.hostname;
120 if (!isWildcardHost(host)) {
121 return [serverUrl.origin];
122 }
123 // `os.networkInterfaces()` is a sync, CPU-only syscall with no Effect
124 // platform equivalent — wrapped in `Effect.sync` so it participates in
125 // the runtime.
126 const interfaces = os.networkInterfaces();
127 const lanAddresses = Object.values(interfaces)
128 .flatMap((addresses) => addresses ?? [])
129 .filter((address) => address.family === "IPv4" && !address.internal)
130 .map((address) => address.address);
131 return [
132 `http://localhost:${port}`,
133 ...lanAddresses.map((address) => `http://${address}:${port}`),
134 ];
135 });
136
137export const LocalWorkerProvider = () =>
138 LocalProvider.make(
139 Worker,
140 LOCAL_PROVIDERS_URL,
141 Effect.gen(function* () {
142 const bundler = yield* WorkerBundle;
143 const runtimeBase = process.cwd();
144 const dotAlchemy = yield* dotAlchemyDirectory;
145 const runtime = yield* Runtime;
146 const stack = yield* Stack;
147 const storageDirectory = yield* localStorageDirectory;
148 const openDevLog = yield* makeDevLogOpener;
149 const devLogDir = yield* makeDevLogDirectory;
150 // Per-resource logs nest by FQN (log/{stage}/Site/Worker/…) so two
151 // workers sharing a logical id in different namespaces never share a
152 // directory.
153 const workerLogSegments = (worker: { fqn: string }) => [
154 stack.stage,
155 ...worker.fqn.split(FQN_SEPARATOR),
156 ];
157 const workerLogDir = (worker: { fqn: string }) =>
158 devLogDir(...workerLogSegments(worker));
159 const baseConsole = yield* ConsoleService.Console;
160 const path = yield* Path.Path;
161 const localRuntimeState = yield* LocalRuntimeState;
162 const workerProxy = yield* WorkerProxy.WorkerProxy;
163 const context = yield* Effect.context<RuntimeServices>();
164 const rootScope = yield* Effect.scope;
165
166 // Proxies are deliberately NOT owned by the per-instance scope: they
167 // survive restarts so the worker's URL stays stable across rebuilds
168 // and config changes. Torn down by `stop` on delete.
169 const proxyInstances = new Map<
170 string,
171 {
172 serverOptions: DevServerOptions;
173 instance: WorkerProxy.WorkerProxyInstance;
174 scope: Scope.Closeable;
175 }
176 >();
177
178 // ConsumerSettings speaks the Cloudflare API's dialect (batchSize,
179 // maxWaitTimeMs in milliseconds); the runtime's QueueConsumer speaks
180 // wrangler's (maxBatchSize, maxBatchTimeout in seconds). Map
181 // explicitly — a spread silently drops the mismatched fields.
182 const toRuntimeConsumerSettings = (
183 settings: ConsumerSettings | undefined,
184 ): Pick<
185 RuntimeQueueConsumer,
186 "maxBatchSize" | "maxBatchTimeout" | "maxRetries" | "retryDelay"
187 > => ({
188 maxBatchSize: settings?.batchSize,
189 maxBatchTimeout:
190 settings?.maxWaitTimeMs !== undefined
191 ? settings.maxWaitTimeMs / 1000
192 : undefined,
193 maxRetries: settings?.maxRetries,
194 retryDelay: settings?.retryDelay,
195 });
196
197 const getQueueConsumers = Effect.fn(function* (scriptName: string) {
198 const consumers: RuntimeQueueConsumer[] = [];
199 for (const consumer of MutableHashMap.values(
200 localRuntimeState.queueConsumers,
201 )) {
202 if (consumer.scriptName === scriptName) {
203 // A LIVE queue (`Alchemy.remote()`) consumed locally: the broker
204 // is still local, but it is fed by the runtime's pull loop
205 // draining the real queue over the HTTP pull API (the
206 // Consumer's local provider attached the `http_pull` consumer
207 // and resolved the queue's real name).
208 if (isLiveId(consumer.queueId)) {
209 if (!consumer.queueName) {
210 return yield* Effect.die(
211 `Consumer of live queue ${consumer.queueId} is missing its resolved queueName — re-deploy the consumer`,
212 );
213 }
214 consumers.push({
215 queueName: consumer.queueName,
216 deadLetterQueue: consumer.deadLetterQueue,
217 ...toRuntimeConsumerSettings(consumer.settings),
218 pull: {
219 queueId: consumer.queueId,
220 accountId: consumer.accountId,
221 },
222 });
223 continue;
224 }
225 const queue = MutableHashMap.get(
226 localRuntimeState.queues,
227 consumer.queueId,
228 ).pipe(Option.getOrUndefined);
229 if (queue) {
230 consumers.push({
231 queueName: queue.queueName,
232 deadLetterQueue: consumer.deadLetterQueue,
233 ...toRuntimeConsumerSettings(consumer.settings),
234 });
235 } else {
236 return yield* Effect.die(`Queue ${consumer.queueId} not found`);
237 }
238 }
239 }
240 return consumers;
241 });
242
243 const startProxy = Effect.fn(function* (
244 id: string,
245 serverOptions: DevServerOptions,
246 ) {
247 const scope = yield* Scope.fork(rootScope);
248 const instance = yield* workerProxy
249 .serve(serverOptions)
250 .pipe(Scope.provide(scope));
251 proxyInstances.set(id, { serverOptions, instance, scope });
252 return instance;
253 });
254
255 const stopProxy = Effect.fn(function* (id: string) {
256 const existing = proxyInstances.get(id);
257 if (existing) {
258 yield* Scope.close(existing.scope, Exit.void);
259 proxyInstances.delete(id);
260 }
261 });
262
263 const maybeStartProxy = Effect.fn(function* (
264 id: string,
265 serverOptions: DevServerOptions,
266 ) {
267 const existing = proxyInstances.get(id);
268 if (existing) {
269 if (Equal.equals(existing.serverOptions, serverOptions)) {
270 return existing.instance;
271 }
272 yield* stopProxy(id);
273 }
274 return yield* startProxy(id, serverOptions);
275 });
276
277 const toRuntimeModules = Effect.fn(function* (
278 bundle: Bundle.BundleOutput,
279 ) {
280 const modules: Module[] = [];
281 for (const file of bundle.files) {
282 // Vendored Python packages are opaque Data modules named by their
283 // `python_modules/<relpath>` — mirroring the deploy path — except
284 // the `workers-runtime-sdk` JS shims, which the runtime imports
285 // as ES modules via `import_from_javascript()`.
286 if (file.path.startsWith("python_modules/")) {
287 const isJsShim =
288 file.path.startsWith("python_modules/workers/") &&
289 /\.m?js$/.test(file.path);
290 modules.push(
291 isJsShim
292 ? {
293 name: file.path,
294 type: "ESModule",
295 content:
296 typeof file.content === "string"
297 ? file.content
298 : new TextDecoder().decode(file.content),
299 }
300 : {
301 name: file.path,
302 type: "Data",
303 content:
304 typeof file.content === "string"
305 ? new TextEncoder().encode(file.content)
306 : file.content,
307 },
308 );
309 continue;
310 }
311 const ext = path.extname(file.path);
312 const type = moduleTypeFromExtension(ext);
313 if (type === "SourceMap") continue;
314 if (type === "Data" || type === "Wasm") {
315 if (!(file.content instanceof Uint8Array)) {
316 return yield* new WorkerValidationError({
317 message: `Expected Uint8Array for ${file.path} (${type})`,
318 value: file.content,
319 });
320 }
321 modules.push({
322 name: file.path,
323 type,
324 content: file.content,
325 });
326 } else {
327 // Prebuilt (`bundle: false`) workers read every module as raw
328 // bytes; string-typed workerd modules (ESModule, Text, ...)
329 // are decoded here, mirroring the deploy path which uploads
330 // the same bytes with a text content type.
331 modules.push({
332 name: file.path,
333 type,
334 content:
335 typeof file.content === "string"
336 ? file.content
337 : new TextDecoder().decode(file.content),
338 });
339 }
340 }
341 return modules;
342 });
343
344 /**
345 * The restart-relevant, canonically-hashable view of a local Worker's
346 * desired state — `LocalProvider`'s `resolveConfig`. Everything here
347 * is plain data: binding *descriptors* (not `BindingHook` closures —
348 * those are materialized in `start` via {@link toRuntimeBinding}),
349 * DO-namespace/hyperdrive/container-image records, bundle options.
350 *
351 * Queue consumers are deliberately EXCLUDED: `getQueueConsumers`
352 * reads `LocalRuntimeState` at serve time and sibling `Consumer`
353 * reconciles drive restarts through the `workerRestarts` hook — they
354 * are runtime wiring observed at start, not desired-state config.
355 */
356 const resolveConfig = Effect.fn(function* ({
357 id,
358 fqn,
359 news,
360 bindings,
361 }: LocalProvider.LocalProviderInput<Worker>) {
362 const props = news as WorkerProps;
363 const name = yield* createWorkerName(id, props.name);
364 const compatibility = getCompatibility(props);
365 const bindingDescriptors: WorkerBinding[] = [];
366 const durableObjectNamespaces: Record<
367 string,
368 RuntimeDurableObject & { uniqueKey: string }
369 > = {};
370 const workflows: Record<string, RuntimeWorkflow> = {};
371 const hyperdrives: Record<string, Required<HyperdriveOrigin>> = {};
372 // Dev-only channel (like `hyperdrives`): binding name → opt-out of
373 // local emulation (the binding was piped through `Alchemy.remote()`). Read
374 // by `toRuntimeBinding` when lowering browser/images/stream/
375 // send_email descriptors. Part of the hashed config: flipping the
376 // opt-out restarts the instance.
377 const devRemote: Record<string, boolean> = {};
378 const containers: Record<string, ContainerImage> = {};
379 // Content hashes of the container images, keyed like `containers`.
380 // `ContainerImage` itself only carries stable paths (context /
381 // dockerfile / imageUri), so without the hash an image CONTENT
382 // change (a Dockerfile edit, a rebuilt bundle) would never change
383 // the config and the running container would serve stale code.
384 const containerHashes: Record<string, string> = {};
385 const boundEnv: Record<string, any> = { ...props.env };
386 for (const { data } of bindings) {
387 if (data.env) Object.assign(boundEnv, data.env);
388 for (const binding of data.bindings ?? []) {
389 if (
390 binding.type === "durable_object_namespace" &&
391 // The `durableObjectNamespaces` property is only used to declare DOs in this worker.
392 // Otherwise, it's a cross-worker durable object binding, which cloudflare-runtime handles automatically.
393 (!binding.scriptName || binding.scriptName === name)
394 ) {
395 // Reuse the existing namespace id if it was provided, otherwise generate a new one.
396 // `workerd` uses this for the object's storage path, so it must be safe to use as a file name.
397 const namespaceId =
398 binding.namespaceId ??
399 encodeURIComponent(`${name}-${binding.className}`);
400 durableObjectNamespaces[binding.className] = {
401 className: binding.className,
402 uniqueKey: namespaceId,
403 sql: true,
404 };
405 bindingDescriptors.push({ ...binding, namespaceId });
406 } else {
407 if (
408 binding.type === "workflow" &&
409 // Same ownership rule as DOs: only declare workflows hosted by
410 // this worker. Cross-script workflow bindings are routed via
411 // the registry proxy.
412 (!binding.scriptName || binding.scriptName === name)
413 ) {
414 workflows[binding.workflowName] = {
415 workflowName: binding.workflowName,
416 className: binding.className,
417 };
418 }
419 bindingDescriptors.push(binding);
420 }
421 }
422 if (data.devRemote) {
423 for (const [name, remote] of Object.entries(data.devRemote)) {
424 devRemote[name] = remote;
425 }
426 }
427 if (data.hyperdrives) {
428 for (const [id, origin] of Object.entries(data.hyperdrives)) {
429 hyperdrives[id] = {
430 scheme: origin.scheme,
431 host: origin.host,
432 port: origin.port,
433 user: origin.user,
434 database: origin.database,
435 password: unwrapRedacted(origin.password),
436 sslmode: origin.sslmode,
437 };
438 }
439 }
440 if (data.containers) {
441 for (const container of data.containers) {
442 if (!container.dev) {
443 return yield* Effect.die(
444 `Container ${container.className} has no dev image`,
445 );
446 }
447 containers[container.className] = {
448 ...container.dev,
449 env: unwrapRedacted(container.dev.env),
450 };
451 if (container.hash !== undefined) {
452 containerHashes[container.className] = container.hash;
453 }
454 }
455 }
456 }
457 for (const [className, dev] of Object.entries(containers)) {
458 if (!durableObjectNamespaces[className]) {
459 return yield* Effect.die(
460 `Durable Object namespace ${className} not found`,
461 );
462 }
463 durableObjectNamespaces[className].container = dev;
464 }
465 const dev:
466 | DevServerOptions
467 | { readonly mode: "external"; readonly url?: string } =
468 props.dev?.mode === "external"
469 ? props.dev
470 : {
471 ...props.dev,
472 mode: "worker" as const,
473 // This is the default. Vite and cloudflare-runtime will retry
474 // if unavailable, unless `strictPort` is true.
475 port: props.dev?.port ?? DEFAULT_DEV_PORT,
476 };
477 return {
478 id,
479 /** Namespace-qualified id — the display prefix for every log line. */
480 fqn,
481 name,
482 compatibility,
483 /** User env (Redacted preserved — the canonical hasher unwraps). */
484 env: Object.keys(boundEnv).length > 0 ? boundEnv : props.env,
485 /**
486 * Raw inline module source (mutually exclusive with `main`).
487 * Serves as-is without the bundler; part of the hashed config so
488 * editing the script restarts the instance.
489 */
490 script: props.script,
491 // External source providers (`props.source`) own their asset
492 // wiring: their dev servers either serve assets natively or add
493 // their own ASSETS hook against a runtime that configures
494 // `worker.assets`. Auto-injecting the hook here would fail in
495 // proxy-hosted dev modes with no assets directory (the hook
496 // errors when the Assets service is unconfigured).
497 hasAssets: !!(props.assets || props.vite) && !props.source,
498 /**
499 * Assets-only Worker (no entry module at all): served locally by
500 * a stub that delegates every request to the ASSETS binding.
501 */
502 assetsOnly:
503 props.main === undefined &&
504 props.script === undefined &&
505 !props.vite &&
506 !!props.assets,
507 bindingDescriptors,
508 durableObjectNamespaces: Object.values(durableObjectNamespaces),
509 workflows: Object.values(workflows),
510 hyperdrives,
511 /**
512 * Container image CONTENT hashes (className → hash). The DO
513 * namespace records above only carry the image's stable paths, so
514 * this is what makes an image content change — a Dockerfile edit,
515 * a rebuilt effectful bundle — restart the instance (which is
516 * what re-runs the docker build).
517 */
518 containerHashes,
519 /**
520 * External source-provider descriptor (plain JSON). Part of the
521 * hashed config so changing the provider or its options restarts
522 * the instance.
523 */
524 source: props.source,
525 /**
526 * Prebuilt worker (`bundle: false`): local dev must serve the
527 * entry + rule-matched sibling modules byte-for-byte (fs-watch +
528 * re-read) — never re-bundle the prebuilt artifact with rolldown.
529 */
530 prebuilt: props.bundle === false,
531 rules: props.rules,
532 devRemote,
533 vite: !!props.vite,
534 // Relative `vite.main` resolves from the Vite root (see the
535 // matching normalization in WorkerProvider's `viteBuild`).
536 viteMain: props.vite?.main
537 ? path.resolve(props.vite.rootDir ?? process.cwd(), props.vite.main)
538 : undefined,
539 viteEnvironments: props.vite?.viteEnvironments,
540 viteRootDir: props.vite?.rootDir,
541 bundleOptions: {
542 id,
543 main: props.main!,
544 compatibility,
545 entry: props.isExternal
546 ? { kind: "external" as const }
547 : {
548 kind: "effect" as const,
549 exports: props.exports ?? {},
550 },
551 stack: { name: stack.name, stage: stack.stage },
552 extraOptions: props.build,
553 } satisfies WorkerBundleOptions,
554 assets: props.assets,
555 dev,
556 crons: Array.from(
557 new Set([...getCronBindings(bindings), ...(props.crons ?? [])]),
558 ),
559 // Tail consumers, resolved to plain `{ service }` records exactly
560 // like the live provider hashes/uploads them (script names only,
561 // deliberately hash-safe). Restart-relevant: serve lowers the list
562 // into workerd's native `tails` service designators.
563 tailConsumers: resolveTailConsumers(props.tailConsumers),
564 // Streaming tail consumers, same resolution — serve lowers the
565 // list into workerd's `streamingTails` designators, which deliver
566 // the producer's events live via the consumer's `tailStream()`.
567 streamingTailConsumers: resolveTailConsumers(
568 props.streamingTailConsumers,
569 ),
570 };
571 });
572
573 type WorkerConfig = Effect.Success<ReturnType<typeof resolveConfig>>;
574 /** A worker-mode config with its runtime `BindingHook`s materialized. */
575 type RunnableWorkerConfig = Omit<WorkerConfig, "dev"> & {
576 dev: DevServerOptions;
577 workerBindings: BindingHook<BindingServices>[];
578 };
579
580 /**
581 * Materialize the plain binding descriptors from {@link resolveConfig}
582 * into live cloudflare-runtime `BindingHook`s. This is the non-plain
583 * half of the old `buildConfig` — kept out of the hashed config.
584 */
585 const materializeWorkerBindings = Effect.fn(function* (
586 config: WorkerConfig,
587 selfUrl: string | undefined,
588 ) {
589 const accountId = yield* localAccountId;
590 return yield* materializeRuntimeBindings(
591 {
592 ...config,
593 devAccess:
594 config.dev.mode === "external" ? undefined : config.dev.access,
595 },
596 {
597 accountId,
598 selfUrl,
599 stack: { name: stack.name, stage: stack.stage },
600 },
601 );
602 });
603
604 // Latest successful serve per worker FQN, so runtime wiring changes
605 // that arrive AFTER workerd started (e.g. a sibling `Consumer`
606 // resource registering this script as a queue consumer) can restart
607 // the instance with the same bundle.
608 const latestServes = new Map<
609 string,
610 {
611 worker: RunnableWorkerConfig;
612 bundle: Bundle.BundleOutput;
613 proxy: WorkerProxy.WorkerProxyInstance;
614 }
615 >();
616 // Serializes serves per worker FQN: a restart triggered by a sibling
617 // resource may otherwise interleave with a rebuild-triggered serve
618 // and leak a workerd scope.
619 const serveLocks = new Map<string, Semaphore.Semaphore>();
620 const serveLock = (id: string) => {
621 let lock = serveLocks.get(id);
622 if (!lock) {
623 lock = Semaphore.makeUnsafe(1);
624 serveLocks.set(id, lock);
625 }
626 return lock;
627 };
628
629 const workerdScopes = new Map<string, Scope.Closeable>();
630
631 // Serve with make-before-break semantics: start the replacement
632 // workerd while the previous instance (if any) keeps serving — and
633 // stays registered in the dev registry — then cut the proxy over and
634 // tear the previous instance down. Cross-script consumers (e.g. a DO
635 // bound via `scriptName` from another Worker) therefore never observe
636 // a window where the script has no running instance and no registry
637 // entry, even when `runtime.start` is slow (container image builds).
638 // Both instances use the same registry key; the registry's entry
639 // removal is owner-aware, so closing the old scope after the
640 // replacement has re-registered cannot delete the replacement's
641 // registration.
642 //
643 // The workerd scope is forked from the provider's `rootScope`, NOT
644 // the instance scope: a reconcile that replaces the instance (or a
645 // restart triggered from a sibling provider's fiber) tears down the
646 // bundle watcher without killing the currently serving workerd — the
647 // last good instance keeps serving until the replacement's first
648 // serve completes. Ownership is tracked in `workerdScopes`, closed by
649 // the next successful serve, by `delete`, or by provider shutdown.
650 /**
651 * One container-context watcher per worker FQN, keyed by the watched
652 * path set. Registry-held (NOT per-serve-scope): a worker restarts
653 * through several code paths that overlap scopes, and a watcher per
654 * serve produced DUPLICATES whose simultaneous restarts storm the
655 * instance until the DO's container link breaks.
656 */
657 const containerWatchers = new Map<
658 string,
659 { key: string; fiber: Fiber.Fiber<void> }
660 >();
661
662 const stopContainerWatcher = (id: string) =>
663 Effect.suspend(() => {
664 const existing = containerWatchers.get(id);
665 if (existing === undefined) return Effect.void;
666 containerWatchers.delete(id);
667 return Fiber.interrupt(existing.fiber).pipe(Effect.asVoid);
668 });
669
670 /**
671 * Watch a worker's Build-variant container contexts and restart the
672 * instance when their CONTENT changes — the docker build happens at
673 * instance start, so a restart IS the image rebuild.
674 *
675 * This is the reload path for USER-supplied Dockerfile/context
676 * containers: those files are not imported by the stack, so
677 * `bun --watch` never re-runs the exec child for them and no replan
678 * (hence no config-hash restart) ever fires. Generated contexts
679 * (under `.alchemy/`) are excluded — the effectful `main` variant
680 * rewrites its bundle there on every plan, which would loop; that
681 * variant reloads through the config's `containerHashes` instead.
682 *
683 * Events are debounced and gated on a content fingerprint
684 * (`node_modules`/`.git` skipped), so editor double-saves and
685 * metadata-only churn don't bounce the instance.
686 */
687 const ensureContainerWatcher = (
688 worker: RunnableWorkerConfig,
689 restart: Effect.Effect<
690 void,
691 never,
692 | ChildProcessSpawner.ChildProcessSpawner
693 | PlatformFileSystem.FileSystem
694 | Path.Path
695 >,
696 ) =>
697 Effect.gen(function* () {
698 const fs = yield* PlatformFileSystem.FileSystem;
699 const watched = new Map<string, { dockerfile: string | undefined }>();
700 for (const namespace of worker.durableObjectNamespaces) {
701 const image = namespace.container;
702 if (image === undefined || !("dockerfile" in image)) continue;
703 const context = path.resolve(runtimeBase, image.context ?? ".");
704 if (isPathWithin(dotAlchemy, context, runtimeBase)) continue;
705 watched.set(context, {
706 dockerfile:
707 image.dockerfile !== undefined
708 ? path.resolve(context, image.dockerfile)
709 : undefined,
710 });
711 }
712 const key = JSON.stringify([...watched.entries()].sort());
713 const existing = containerWatchers.get(worker.fqn);
714 if (existing !== undefined) {
715 if (existing.key === key) return; // same watcher keeps running
716 yield* stopContainerWatcher(worker.fqn);
717 }
718 if (watched.size === 0) return;
719
720 const fingerprint = Effect.gen(function* () {
721 const hashes: string[] = [];
722 const walk = (dir: string): Effect.Effect<void, any> =>
723 Effect.gen(function* () {
724 const entries = yield* fs.readDirectory(dir);
725 for (const entry of entries.sort()) {
726 if (entry === "node_modules" || entry === ".git") continue;
727 const file = path.join(dir, entry);
728 const stat = yield* fs.stat(file);
729 if (stat.type === "Directory") {
730 yield* walk(file);
731 } else {
732 hashes.push(
733 `${file}:${yield* fs.readFile(file).pipe(Effect.flatMap(sha256))}`,
734 );
735 }
736 }
737 });
738 for (const [context, { dockerfile }] of watched) {
739 yield* walk(context);
740 if (dockerfile !== undefined && !dockerfile.startsWith(context)) {
741 hashes.push(
742 `${dockerfile}:${yield* fs.readFile(dockerfile).pipe(Effect.flatMap(sha256))}`,
743 );
744 }
745 }
746 return yield* sha256(hashes.join("\n"));
747 }).pipe(Effect.orElseSucceed(() => undefined));
748
749 const streams = [...watched.entries()].flatMap(
750 ([context, { dockerfile }]) => [
751 fs.watch(context, { recursive: true }),
752 ...(dockerfile !== undefined && !dockerfile.startsWith(context)
753 ? [fs.watch(path.dirname(dockerfile))]
754 : []),
755 ],
756 );
757 const watchLoop = Effect.gen(function* () {
758 let last = yield* fingerprint;
759 yield* Stream.mergeAll(streams, {
760 concurrency: streams.length,
761 }).pipe(
762 Stream.debounce("500 millis"),
763 Stream.runForEach(() =>
764 Effect.gen(function* () {
765 const next = yield* fingerprint;
766 if (next === undefined || next === last) return;
767 last = next;
768 yield* Effect.logInfo(
769 `[${worker.fqn}] Container build context changed, restarting instance`,
770 );
771 yield* restart;
772 }),
773 ),
774 Effect.catchCause((cause) =>
775 Effect.logWarning(
776 `[${worker.fqn}] Container context watch failed`,
777 Cause.squash(cause),
778 ),
779 ),
780 );
781 });
782 const fiber = yield* watchLoop.pipe(
783 Effect.ensuring(
784 Effect.sync(() => {
785 const current = containerWatchers.get(worker.fqn);
786 if (current?.fiber === fiber) {
787 containerWatchers.delete(worker.fqn);
788 }
789 }),
790 ),
791 Effect.forkIn(rootScope),
792 );
793 containerWatchers.set(worker.fqn, { key, fiber });
794 });
795
796 const serveWith = (
797 worker: RunnableWorkerConfig,
798 bundle: Bundle.BundleOutput,
799 proxy: WorkerProxy.WorkerProxyInstance,
800 ) =>
801 Semaphore.withPermits(
802 serveLock(worker.fqn),
803 1,
804 )(
805 // The bookkeeping around `runtime.start` must not be torn in half
806 // by an interrupt: once a replacement workerd is up, it must be
807 // recorded in `workerdScopes` and the superseded instances must be
808 // closed, or one of the workerds would leak until provider
809 // shutdown while holding the shared registry key.
810 Effect.uninterruptibleMask((restore) =>
811 Effect.gen(function* () {
812 const previous = workerdScopes.get(worker.fqn);
813 // Instances whose queue-consumer wiring went stale while they
814 // were starting; never exposed via the proxy, closed together
815 // with `previous` after the cutover below.
816 const superseded: Scope.Closeable[] = [];
817 let scope!: Scope.Closeable;
818 let url!: URL;
819 // Queue-consumer wiring can change while `runtime.start` is in
820 // flight (a sibling `Consumer` reconcile), before the restart
821 // hook below exists to pick it up. We hold the serve lock, so a
822 // restart would deadlock — instead, loop and serve again until
823 // the wiring is stable across a start.
824 while (true) {
825 const queueConsumers = yield* getQueueConsumers(worker.name);
826 scope = yield* Scope.fork(rootScope);
827 // One log file per workerd generation, closed with its scope:
828 // log/{stage}/{fqn…}/{timestamp}.log. `logging.onOutput`
829 // REPLACES workerd's stdio inheritance, so this is the only
830 // path its output takes: raw chunks go to the file, complete
831 // lines go to the console with the worker's pnpm-style prefix.
832 const devLog = yield* openDevLog(
833 ...workerLogSegments(worker),
834 ).pipe(Scope.provide(scope));
835 // One splitter per channel: a shared buffer would splice a
836 // partial stdout line onto the next stderr chunk, and stderr
837 // would lose its severity on the way to the console.
838 const splitters = makeResourceOutput(worker.fqn, baseConsole);
839 yield* Scope.addFinalizer(
840 scope,
841 Effect.sync(() => {
842 splitters.stdout.flush();
843 splitters.stderr.flush();
844 }),
845 );
846 url = yield* restore(
847 runtime
848 .start({
849 name: worker.name,
850 logging: {
851 // `(chunk, stream)` — chunk first; the stream name
852 // indexes the splitters directly.
853 onOutput: (chunk, stream) => {
854 devLog.write(chunk);
855 splitters[stream].push(chunk);
856 },
857 },
858 compatibilityDate: worker.compatibility.date,
859 compatibilityFlags: worker.compatibility.flags,
860 bindings: worker.workerBindings as never,
861 hyperdrives: worker.hyperdrives,
862 durableObjectNamespaces: worker.durableObjectNamespaces,
863 workflows: worker.workflows,
864 queueConsumers,
865 // Cron triggers: the runtime starts a Node-side timer
866 // per expression and exposes the Miniflare-compatible
867 // manual trigger route `/cdn-cgi/handler/scheduled`.
868 crons: worker.crons,
869 // Tail consumers by script name — each resolves through
870 // the dev registry proxy exactly like cross-worker
871 // service bindings; a consumer that isn't running yet
872 // drops events with a `[registry]` warning until it
873 // registers (wrangler dev-registry semantics).
874 tails: worker.tailConsumers?.map((c) => c.service),
875 // Streaming tail consumers by script name — resolved
876 // through the same registry proxy; the consumer's
877 // `tailStream()` receives the onset while the producer
878 // is still executing (dropped with a `[registry]`
879 // warning until the consumer registers).
880 streamingTails: worker.streamingTailConsumers?.map(
881 (c) => c.service,
882 ),
883 // Cache API opt-out (`dev: { cache: false }`) — matches
884 // production workers.dev, where the Cache API is a no-op.
885 cache: worker.dev.cache,
886 // Per-worker request.cf override (`dev: { cf: {...} }`).
887 cf: worker.dev.cf,
888 modules: yield* toRuntimeModules(bundle),
889 assets: yield* toRuntimeAssets(worker.assets),
890 })
891 .pipe(Scope.provide(scope)),
892 ).pipe(
893 // The scope hangs off `rootScope`, so a failed or
894 // interrupted start must close it here — nothing else owns
895 // it yet.
896 Effect.onExit((exit) =>
897 exit._tag === "Failure"
898 ? Scope.close(scope, exit)
899 : Effect.void,
900 ),
901 );
902 workerdScopes.set(worker.fqn, scope);
903 latestServes.set(worker.fqn, { worker, bundle, proxy });
904 // Register the restart hook before the re-check below: changes
905 // landing after the re-check find the hook; changes before it
906 // are caught by the re-check. Nothing falls in between.
907 MutableHashMap.set(
908 localRuntimeState.workerRestarts,
909 worker.name,
910 restartWorker(worker.fqn),
911 );
912 // Idempotent: the registry keeps ONE watcher per worker FQN
913 // across restarts (a new one only when the watched path set
914 // changed).
915 yield* ensureContainerWatcher(
916 worker,
917 Effect.suspend(() => restartWorker(worker.fqn)),
918 );
919 const currentConsumers = yield* getQueueConsumers(worker.name);
920 if (
921 JSON.stringify(currentConsumers) !==
922 JSON.stringify(queueConsumers)
923 ) {
924 // Wiring changed while workerd was starting — serve again
925 // with the fresh consumers before exposing the instance.
926 superseded.push(scope);
927 continue;
928 }
929 break;
930 }
931 yield* proxy.set(url);
932 // Only now tear the replaced instances down: `previous` kept
933 // serving — and stayed registered in the dev registry — until
934 // the cutover above. The registry's entry removal is
935 // owner-aware, so these closes cannot delete the replacement's
936 // registration.
937 for (const replaced of previous
938 ? [...superseded, previous]
939 : superseded) {
940 yield* Scope.close(replaced, Exit.void).pipe(
941 Effect.catchCause((cause) =>
942 Effect.logWarning(
943 `[${worker.fqn}] Failed to stop previous local worker instance`,
944 Cause.squash(cause),
945 ),
946 ),
947 );
948 }
949 return url;
950 }),
951 ),
952 );
953
954 /**
955 * Restart a running worker with its latest bundle (or its latest Vite
956 * dev-server child) so start-time runtime wiring (queue consumers) is
957 * re-read from {@link LocalRuntimeState}. No-op if the worker hasn't
958 * served yet — the pending first serve will already observe the
959 * updated state.
960 */
961 const logRestartFailure = (fqn: string) =>
962 Effect.catchCause((cause: Cause.Cause<unknown>) =>
963 Effect.logWarning(
964 `[${fqn}] Failed to restart local worker`,
965 Cause.squash(cause),
966 ),
967 );
968
969 const restartWorker = (
970 id: string,
971 ): Effect.Effect<
972 void,
973 never,
974 | ChildProcessSpawner.ChildProcessSpawner
975 | PlatformFileSystem.FileSystem
976 | Path.Path
977 > =>
978 Effect.suspend(() => {
979 const latest = latestServes.get(id);
980 if (latest) {
981 return serveWith(latest.worker, latest.bundle, latest.proxy).pipe(
982 Effect.asVoid,
983 logRestartFailure(latest.worker.fqn),
984 );
985 }
986 const vite = latestViteServes.get(id);
987 if (vite) {
988 // A Vite boot is expensive, so skip the restart if the running
989 // child already serves the current wiring (the in-flight serve's
990 // re-check loop may have picked it up while this restart waited
991 // on the serve lock).
992 return serveVite(vite, { onlyIfConsumersChanged: true }).pipe(
993 Effect.asVoid,
994 logRestartFailure(vite.worker.fqn),
995 );
996 }
997 return Effect.void;
998 });
999
1000 // Tear down the running workerd for a worker FQN, if any. Used when the
1001 // Worker is deleted or handed off to an external dev process —
1002 // instance replacement does NOT go through this: the previous workerd
1003 // keeps serving until the replacement's first serve closes it.
1004 const closeWorkerd = Effect.fn(function* (id: string) {
1005 yield* stopContainerWatcher(id);
1006 const scope = workerdScopes.get(id);
1007 if (scope) {
1008 workerdScopes.delete(id);
1009 yield* Scope.close(scope, Exit.void);
1010 }
1011 });
1012
1013 // Note: `serveLocks` entries are intentionally retained — an
1014 // in-flight restart may still hold the semaphore when the instance
1015 // is torn down, and a same-id re-create must serialize against it.
1016 const dropServeState = (id: string) => {
1017 const latest = latestServes.get(id) ?? latestViteServes.get(id);
1018 if (latest) {
1019 MutableHashMap.remove(
1020 localRuntimeState.workerRestarts,
1021 latest.worker.name,
1022 );
1023 latestServes.delete(id);
1024 latestViteServes.delete(id);
1025 servedViteConsumers.delete(id);
1026 }
1027 };
1028
1029 /**
1030 * Drain a stream of bundle-watch events into the make-before-break
1031 * `serveWith` path, forked into the instance scope: queue requests
1032 * while a rebuild is in flight, serve every successful bundle, and
1033 * log build/serve errors without tearing the instance down.
1034 *
1035 * Shared by the built-in bundler (`runWorker`) and bundle-mode
1036 * external source providers (`runSource`) — both speak the same
1037 * {@link Bundle.BundleWatchEvent} protocol.
1038 */
1039 const serveBundleStream = Effect.fn(function* <E, R>(
1040 worker: RunnableWorkerConfig,
1041 proxy: WorkerProxy.WorkerProxyInstance,
1042 bundles: Stream.Stream<Bundle.BundleWatchEvent, E, R>,
1043 ) {
1044 let start = Date.now();
1045 let status: "start" | "update" = "start";
1046 yield* bundles.pipe(
1047 Stream.tap((event) => {
1048 if (event._tag === "Start") {
1049 start = Date.now();
1050 if (status === "update") {
1051 return Effect.all([
1052 Effect.log(`[${worker.fqn}] Rebuilding`),
1053 // Tells the proxy to queue requests until the updated
1054 // worker is ready.
1055 proxy.unset(),
1056 ]);
1057 }
1058 } else if (event._tag === "Error") {
1059 return Effect.all([
1060 Effect.logError(`[${worker.fqn}] Bundle error`, event.error),
1061 // No updated worker is coming from this build: answer
1062 // parked requests with the error now instead of after the
1063 // pending timeout.
1064 proxy.fail(event.error.message),
1065 ]);
1066 }
1067 return Effect.void;
1068 }),
1069 Stream.filterMap((event) =>
1070 event._tag === "Success"
1071 ? Result.succeed(event.output)
1072 : Result.failVoid,
1073 ),
1074 Stream.mapEffect((bundle) =>
1075 serveWith(worker, bundle, proxy).pipe(
1076 Effect.exit,
1077 Effect.tap((exit) => {
1078 if (exit._tag === "Success") {
1079 // URL on first start only — vite-banner style; rebuild
1080 // lines stay compact.
1081 const message = Effect.log(
1082 status === "update"
1083 ? workerUpdatedMessage(worker.fqn, Date.now() - start)
1084 : workerStartedMessage(
1085 worker.fqn,
1086 Date.now() - start,
1087 proxy.url,
1088 workerLogDir(worker),
1089 ),
1090 );
1091 status = "update";
1092 return message;
1093 } else {
1094 return Effect.all([
1095 Effect.logError(
1096 `[${worker.fqn}] Error`,
1097 Cause.squash(exit.cause),
1098 ),
1099 proxy.fail(Cause.pretty(exit.cause)),
1100 ]);
1101 }
1102 }),
1103 ),
1104 ),
1105 Stream.runDrain,
1106 Effect.forkScoped,
1107 );
1108 });
1109
1110 const runWorker = Effect.fn(function* (worker: RunnableWorkerConfig) {
1111 const start = Date.now();
1112 const proxy = yield* maybeStartProxy(worker.fqn, worker.dev);
1113 // Inline `script` workers bypass the bundler entirely — the string
1114 // IS the module (mirroring the deploy path, which uploads it as a
1115 // single `main.js`). There is nothing to watch: script changes flow
1116 // through the hashed config and restart the instance.
1117 if (worker.script !== undefined) {
1118 yield* serveWith(
1119 worker,
1120 {
1121 files: [{ path: "main.js", content: worker.script, hash: "" }],
1122 hash: "",
1123 },
1124 proxy,
1125 );
1126 yield* Effect.log(
1127 workerStartedMessage(
1128 worker.fqn,
1129 Date.now() - start,
1130 proxy.url,
1131 workerLogDir(worker),
1132 ),
1133 );
1134 return proxy.url;
1135 }
1136 const bundles: Stream.Stream<
1137 Bundle.BundleWatchEvent,
1138 Bundle.BundleError,
1139 | ChildProcessSpawner.ChildProcessSpawner
1140 | FileSystem.FileSystem
1141 | Path.Path
1142 | Scope.Scope
1143 > = isPythonMain(worker.bundleOptions.main)
1144 ? watchPythonWorkerBundle({
1145 id: worker.bundleOptions.id,
1146 fqn: worker.fqn,
1147 main: worker.bundleOptions.main,
1148 compatibility: worker.compatibility,
1149 })
1150 : worker.prebuilt
1151 ? // Prebuilt (`bundle: false`): fs-watch the entry directory
1152 // and re-read the module graph byte-for-byte — running the
1153 // rolldown watcher would re-bundle the prebuilt artifact
1154 // and violate the deploy path's byte-for-byte contract.
1155 watchPrebuiltWorkerBundle({
1156 main: worker.bundleOptions.main,
1157 rules: worker.rules,
1158 })
1159 : bundler.watch(worker.bundleOptions);
1160 yield* serveBundleStream(worker, proxy, bundles);
1161 return proxy.url;
1162 });
1163
1164 // Assets-only Worker: there is no entry module to bundle or watch.
1165 // The local runtime requires a user worker module, so serve a stub
1166 // that delegates every request to the ASSETS binding — the assets
1167 // worker applies `htmlHandling` / `notFoundHandling` (including SPA
1168 // fallback) itself, matching Cloudflare's deployed assets-only
1169 // behavior.
1170 const assetsOnlyBundle: Bundle.BundleOutput = {
1171 files: [
1172 {
1173 path: "main.js",
1174 content:
1175 "export default { fetch: (request, env) => env.ASSETS.fetch(request) };",
1176 hash: "assets-only-stub",
1177 },
1178 ],
1179 hash: "assets-only-stub",
1180 };
1181
1182 const runAssetsOnly = Effect.fn(function* (worker: RunnableWorkerConfig) {
1183 const start = Date.now();
1184 const proxy = yield* maybeStartProxy(worker.fqn, worker.dev);
1185 yield* serveWith(worker, assetsOnlyBundle, proxy);
1186 yield* Effect.log(
1187 workerStartedMessage(
1188 worker.fqn,
1189 Date.now() - start,
1190 proxy.url,
1191 workerLogDir(worker),
1192 ),
1193 );
1194 return proxy.url;
1195 });
1196
1197 /** Everything needed to serve (or re-serve) a Vite dev-server child. */
1198 interface ViteServeArgs {
1199 worker: RunnableWorkerConfig;
1200 rootDir: string | undefined;
1201 invalidate: Effect.Effect<void>;
1202 source: NonNullable<ViteChildConfig["source"]> | undefined;
1203 proxy: WorkerProxy.WorkerProxyInstance;
1204 }
1205
1206 /** The Vite analogue of `latestServes`: everything needed to restart
1207 * the dev-server child when a sibling `Consumer` reconcile changes the
1208 * queue-consumer wiring (see `workerRestarts`). */
1209 const latestViteServes = new Map<string, ViteServeArgs>();
1210
1211 /** The queue-consumer wiring (canonical JSON) each running Vite child
1212 * was started with, for the restart path's changed-wiring check. */
1213 const servedViteConsumers = new Map<string, string>();
1214
1215 // Serve a Vite dev-server child with the same two guarantees
1216 // `serveWith` gives a plain worker: a `workerRestarts` hook so sibling
1217 // `Consumer` reconciles can restart the child with fresh queue-consumer
1218 // wiring, and a post-start re-check so wiring that lands while the
1219 // child is starting is not lost. Unlike `serveWith` this is
1220 // break-before-make — two Vite dev servers rooted at the same app
1221 // would race file watchers and Durable Object storage — so the proxy
1222 // queues requests across the gap. The child's scope is forked from
1223 // `rootScope` and tracked in `workerdScopes`, so it survives instance
1224 // restarts and is reclaimed by `stop`/handoff/`serveWith` like any
1225 // other running instance.
1226 const serveVite = (
1227 args: ViteServeArgs,
1228 options?: { onlyIfConsumersChanged?: boolean },
1229 ) =>
1230 Semaphore.withPermits(
1231 serveLock(args.worker.fqn),
1232 1,
1233 )(
1234 // The bookkeeping around `startViteChild` must not be torn in half
1235 // by an interrupt: once a replacement child is up it must be
1236 // recorded in `workerdScopes`, or it would leak until provider
1237 // shutdown while holding the worker's registry key.
1238 Effect.uninterruptibleMask((restore) =>
1239 Effect.gen(function* () {
1240 const { worker, rootDir, invalidate, source, proxy } = args;
1241 if (options?.onlyIfConsumersChanged) {
1242 const current = yield* getQueueConsumers(worker.name);
1243 if (
1244 workerdScopes.has(worker.fqn) &&
1245 JSON.stringify(current) ===
1246 servedViteConsumers.get(worker.fqn)
1247 ) {
1248 return;
1249 }
1250 }
1251 // Queue requests while the child is (re)starting.
1252 yield* proxy.unset();
1253 // The dev server and its workerd run in a child process rooted
1254 // at the app.
1255 const root = path.resolve(rootDir ?? process.cwd());
1256 const accountId = yield* localAccountId;
1257 // Queue-consumer wiring can change while the child is starting
1258 // (a sibling `Consumer` reconcile), before the restart hook
1259 // below exists to pick it up. We hold the serve lock, so a
1260 // restart would deadlock — instead, loop and serve again until
1261 // the wiring is stable across a start.
1262 while (true) {
1263 const queueConsumers = yield* getQueueConsumers(worker.name);
1264 // Break-before-make: tear the previous child down before
1265 // starting its replacement (also covers a superseded child
1266 // from the previous loop iteration).
1267 yield* closeWorkerd(worker.fqn);
1268 const scope = yield* Scope.fork(rootScope);
1269 const devLog = yield* openDevLog(
1270 ...workerLogSegments(worker),
1271 ).pipe(Scope.provide(scope));
1272 const logResourceOutput = makeResourceLogger(worker.fqn);
1273 const child = yield* restore(
1274 startViteChild(
1275 {
1276 rootDir: root,
1277 publicUrl: proxy.url.toString().replace(/\/$/, ""),
1278 accountId,
1279 storageDirectory,
1280 stack: { name: stack.name, stage: stack.stage },
1281 // Already resolved to plain values by `start`
1282 // (`Worker.URL` sentinels substituted); Redacted
1283 // unwraps at the process boundary inside
1284 // `startViteChild`.
1285 env: worker.env ?? {},
1286 source,
1287 worker: {
1288 name: worker.name,
1289 compatibility: worker.compatibility,
1290 main: worker.viteMain,
1291 viteEnvironments: worker.viteEnvironments,
1292 hasAssets: worker.hasAssets,
1293 bindingDescriptors: worker.bindingDescriptors,
1294 devRemote: worker.devRemote,
1295 devAccess: worker.dev.access,
1296 durableObjectNamespaces: worker.durableObjectNamespaces,
1297 workflows: worker.workflows,
1298 hyperdrives: worker.hyperdrives,
1299 queueConsumers,
1300 crons: worker.crons,
1301 assets: yield* toRuntimeAssets(worker.assets),
1302 },
1303 },
1304 (channel, line) =>
1305 logResourceOutput(channel, line).pipe(
1306 Effect.andThen(
1307 Effect.sync(() => devLog.writeLine(line)),
1308 ),
1309 ),
1310 ).pipe(Scope.provide(scope)),
1311 ).pipe(
1312 // The scope hangs off `rootScope`, so a failed or
1313 // interrupted start must close it here — nothing else owns
1314 // it yet.
1315 Effect.onExit((exit) =>
1316 exit._tag === "Failure"
1317 ? Scope.close(scope, exit)
1318 : Effect.void,
1319 ),
1320 );
1321 workerdScopes.set(worker.fqn, scope);
1322 latestViteServes.set(worker.fqn, args);
1323 // Unexpected child death: log, fail the proxy so requests
1324 // see why instead of parking, and mark the instance for
1325 // update on the next plan. Forked into the child's scope so
1326 // a deliberate restart or teardown interrupts the watcher
1327 // before the process is killed.
1328 yield* child.exitCode.pipe(
1329 Effect.flatMap((exitCode) => {
1330 const message = `[${worker.fqn}] Dev server child exited unexpectedly with code ${exitCode}`;
1331 return Effect.all([
1332 Effect.logWarning(message),
1333 proxy.fail(message),
1334 ]);
1335 }),
1336 Effect.andThen(invalidate),
1337 Effect.forkIn(scope),
1338 );
1339 // Register the restart hook before the re-check below:
1340 // changes landing after the re-check find the hook; changes
1341 // before it are caught by the re-check. Nothing falls in
1342 // between.
1343 MutableHashMap.set(
1344 localRuntimeState.workerRestarts,
1345 worker.name,
1346 restartWorker(worker.fqn),
1347 );
1348 const currentConsumers = yield* getQueueConsumers(worker.name);
1349 if (
1350 JSON.stringify(currentConsumers) !==
1351 JSON.stringify(queueConsumers)
1352 ) {
1353 // Wiring changed while the child was starting — serve
1354 // again with the fresh consumers before exposing it.
1355 continue;
1356 }
1357 servedViteConsumers.set(
1358 worker.fqn,
1359 JSON.stringify(queueConsumers),
1360 );
1361 yield* proxy.set(child.url);
1362 return;
1363 }
1364 }),
1365 ),
1366 );
1367
1368 const runVite = Effect.fn(function* (
1369 worker: RunnableWorkerConfig,
1370 rootDir: string | undefined,
1371 invalidate: Effect.Effect<void>,
1372 source?: NonNullable<ViteChildConfig["source"]>,
1373 ) {
1374 const start = Date.now();
1375 const proxy = yield* maybeStartProxy(worker.fqn, worker.dev);
1376 yield* serveVite({ worker, rootDir, invalidate, source, proxy });
1377 // The programmatic vite API never prints the CLI's URL banner — and
1378 // vite's own URL is the internal (port-shuffled) server behind the
1379 // stable proxy, so advertise the proxy instead.
1380 yield* Effect.log(
1381 workerStartedMessage(
1382 worker.fqn,
1383 Date.now() - start,
1384 proxy.url,
1385 workerLogDir(worker),
1386 ),
1387 );
1388 return proxy.url;
1389 });
1390
1391 // External source provider (`props.source`): the provider owns the
1392 // dev story. Two modes (see `SourceDevHandle`):
1393 // - `server`: the source runs its own dev server (framework dev);
1394 // the proxy is pointed at its URL.
1395 // - `bundle`: the source supplies rebuild events; each successful
1396 // bundle is served through the same make-before-break `serveWith`
1397 // path the built-in bundler uses.
1398 const runSource = Effect.fn(function* (worker: RunnableWorkerConfig) {
1399 const proxy = yield* maybeStartProxy(worker.fqn, worker.dev);
1400 // Queue requests until the source's first output is served —
1401 // whether that's the first workerd serve (bundle mode) or the dev
1402 // server URL (server mode).
1403 yield* proxy.unset();
1404 // `loadSource` is typed against the full `SourceServices` union
1405 // (which includes the per-run Artifacts cache the live provider
1406 // supplies); local dev has no run-scoped cache, so hand the
1407 // module a fresh in-memory one.
1408 const source = yield* loadSource(worker.source!).pipe(
1409 Effect.provideService(
1410 AlchemyArtifacts,
1411 makeScopedArtifacts(createArtifactStore(), worker.fqn),
1412 ),
1413 );
1414 const devCtx: DevContext = {
1415 dotAlchemy,
1416 id: worker.id,
1417 fqn: worker.fqn,
1418 workerName: worker.name,
1419 compatibility: worker.compatibility,
1420 entry: worker.bundleOptions.entry,
1421 stack: { name: stack.name, stage: stack.stage },
1422 env: worker.env,
1423 extraOptions: worker.bundleOptions.extraOptions,
1424 assets: worker.assets,
1425 worker: {
1426 bindings: worker.workerBindings,
1427 durableObjectNamespaces: worker.durableObjectNamespaces,
1428 hyperdrives: worker.hyperdrives,
1429 queueConsumers: getQueueConsumers(worker.name),
1430 assets: yield* toRuntimeAssets(worker.assets),
1431 },
1432 runtimeContext: context,
1433 };
1434 const handle = yield* source.dev(devCtx);
1435 if (handle.mode !== "bundle") {
1436 return yield* Effect.fail(
1437 new SourceProviderError({
1438 provider: worker.source!.provider,
1439 message:
1440 "A source declared devMode 'bundle' but returned a server-mode dev handle.",
1441 }),
1442 );
1443 }
1444 yield* serveBundleStream(worker, proxy, handle.bundles);
1445 return proxy.url;
1446 });
1447
1448 return {
1449 resolveConfig,
1450
1451 // The physical name only survives an update when it isn't changing.
1452 stables: ({ output, config }) =>
1453 Effect.succeed(
1454 output?.workerName === config.name
1455 ? (["workerName"] as ["workerName"])
1456 : undefined,
1457 ),
1458
1459 precreate: Effect.fn(function* ({ id, fqn, news, bindings }) {
1460 const name = yield* createWorkerName(id, news.name);
1461 const durableObjectNamespaces: Record<string, string> = {};
1462 for (const { data } of bindings) {
1463 for (const binding of data?.bindings ?? []) {
1464 if (binding.type === "durable_object_namespace") {
1465 durableObjectNamespaces[binding.className] =
1466 binding.namespaceId ??
1467 encodeURIComponent(
1468 `${binding.scriptName!}-${binding.className}`,
1469 );
1470 }
1471 }
1472 }
1473 const accountId = yield* localAccountId;
1474 const urls =
1475 news.dev?.mode === "external"
1476 ? // news.dev.url may be an unresolved output; avoid trying to resolve it here.
1477 []
1478 : yield* maybeStartProxy(fqn, {
1479 ...news.dev,
1480 mode: "worker" as const,
1481 port: news.dev?.port ?? DEFAULT_DEV_PORT,
1482 }).pipe(Effect.flatMap((proxy) => resolveLocalUrls(proxy.url)));
1483 return {
1484 // No cloud script exists in dev — fabricate a `dev:`-marked
1485 // identity (the shared local-provider convention, and the
1486 // legacy-row mode marker; see LOCAL_ID_PREFIX).
1487 workerId: `dev:${name}`,
1488 workerName: name,
1489 namespace: undefined,
1490 logpush: undefined,
1491 url: urls[0],
1492 urls,
1493 domain: undefined,
1494 tags: [],
1495 durableObjectNamespaces,
1496 routes: [],
1497 crons: Array.from(
1498 new Set([...getCronBindings(bindings), ...(news.crons ?? [])]),
1499 ),
1500 accountId,
1501 };
1502 }),
1503
1504 start: Effect.fn(function* ({ fqn, config, invalidate }) {
1505 const accountId = yield* localAccountId;
1506
1507 // `dev: { mode: "external" }` opts out of running a local Worker
1508 // entirely — typically because an external dev process
1509 // (Command.Dev) is serving requests. The instance exists in the
1510 // registry (with an empty scope) but has no workerd behind it. A
1511 // previous worker-mode instance for this FQN may have registered
1512 // serve/restart state — drop it, and tear down its workerd (with
1513 // make-before-break the running workerd outlives instance scopes
1514 // and must be closed explicitly on handoff).
1515 if (config.dev.mode === "external") {
1516 dropServeState(fqn);
1517 yield* closeWorkerd(fqn);
1518 const urls = config.dev.url ? [config.dev.url] : [];
1519 return {
1520 workerId: `dev:${config.name}`,
1521 workerName: config.name,
1522 namespace: undefined,
1523 logpush: undefined,
1524 url: urls[0],
1525 urls,
1526 domain: undefined,
1527 tags: [],
1528 durableObjectNamespaces: {},
1529 accountId,
1530 routes: [],
1531 crons: config.crons,
1532 tailConsumers: config.tailConsumers,
1533 streamingTailConsumers: config.streamingTailConsumers,
1534 } satisfies Worker["Attributes"];
1535 }
1536
1537 // `Worker.URL` locally resolves to the worker's dev-proxy URL —
1538 // the proxy is stable per worker FQN (the same instance `runWorker`
1539 // / `runVite` attach to below), so the URL is known before workerd
1540 // starts. Trailing slash stripped to match the cloud value's shape.
1541 const needsSelfUrl =
1542 config.bindingDescriptors.some(
1543 (b) => b.type === "self_url" || b.type === "r2_s3_credentials",
1544 ) || Object.values(config.env ?? {}).some(isSelfUrl);
1545 const selfUrl = needsSelfUrl
1546 ? (yield* maybeStartProxy(fqn, config.dev)).url
1547 .toString()
1548 .replace(/\/$/, "")
1549 : undefined;
1550 // Substitute `Worker.URL` sentinels once, up front — the runtime
1551 // bindings, the Vite define entries, and the child-process config
1552 // all consume the resolved URL as a plain string.
1553 const env =
1554 config.env && selfUrl !== undefined
1555 ? Object.fromEntries(
1556 Object.entries(config.env).map(([key, value]) => [
1557 key,
1558 isSelfUrl(value) ? selfUrl : value,
1559 ]),
1560 )
1561 : config.env;
1562 const workerBindings = yield* materializeWorkerBindings(
1563 { ...config, env },
1564 selfUrl,
1565 );
1566 const worker: RunnableWorkerConfig = {
1567 ...config,
1568 env,
1569 dev: config.dev,
1570 workerBindings,
1571 };
1572 const serverUrl = yield* config.source
1573 ? config.source.devMode === "server"
1574 ? runVite(worker, config.source.rootDir, invalidate, {
1575 descriptor: config.source,
1576 id: worker.id,
1577 fqn: worker.fqn,
1578 assets: worker.assets,
1579 })
1580 : runSource(worker)
1581 : config.vite
1582 ? runVite(worker, config.viteRootDir, invalidate)
1583 : config.assetsOnly
1584 ? runAssetsOnly(worker)
1585 : runWorker(worker);
1586 // In dev, `urls` is the dev server's actual surface — localhost
1587 // first, then LAN addresses for wildcard hosts. Deployed domains
1588 // are not served by this session, so they don't appear.
1589 const urls = yield* resolveLocalUrls(serverUrl);
1590 return {
1591 workerId: `dev:${config.name}`,
1592 workerName: config.name,
1593 namespace: undefined,
1594 logpush: undefined,
1595 url: urls[0],
1596 urls,
1597 domain: undefined,
1598 tags: [],
1599 durableObjectNamespaces: Object.fromEntries(
1600 config.durableObjectNamespaces.map((namespace) => [
1601 namespace.className,
1602 namespace.uniqueKey,
1603 ]),
1604 ),
1605 routes: [],
1606 crons: config.crons,
1607 tailConsumers: config.tailConsumers,
1608 streamingTailConsumers: config.streamingTailConsumers,
1609 accountId,
1610 } satisfies Worker["Attributes"];
1611 }),
1612
1613 stop: Effect.fn(function* ({ fqn }) {
1614 // Cross-restart state: the serve/restart bookkeeping, the running
1615 // workerd (which outlives instance scopes for make-before-break),
1616 // and the URL proxy live outside instance scopes and are only
1617 // reclaimed on a real delete.
1618 dropServeState(fqn);
1619 yield* closeWorkerd(fqn);
1620 yield* stopProxy(fqn);
1621 }),
1622 } satisfies LocalProvider.LocalProviderSpec<
1623 Worker,
1624 Effect.Success<ReturnType<typeof resolveConfig>>,
1625 // The Python-bundle watcher spawns `uv` (ChildProcessSpawner) and
1626 // reads vendored modules (FileSystem) from inside `start`;
1627 // `readAssetsConfigFiles` (assets `_headers`/`_redirects`) needs
1628 // FileSystem + Path.
1629 | ChildProcessSpawner.ChildProcessSpawner
1630 | FileSystem.FileSystem
1631 | Path.Path
1632 >;
1633 }),
1634 );
1635
1636const toRuntimeAssets = Effect.fn(function* (
1637 assets: WorkerAssetsConfig | undefined,
1638) {
1639 if (!assets) return undefined;
1640 // Mirror the deploy path: the special `_headers` / `_redirects` files
1641 // in the assets directory carry the rules unless overridden by
1642 // explicit `headers` / `redirects` props. The local runtime parses
1643 // the raw string contents just like Cloudflare does.
1644 //
1645 // A Vite website's `assets` is config-only (`{ runWorkerFirst: true }`) —
1646 // the client output directory is the build's business, and in `dev` the
1647 // vite plugin serves assets from the dev server, so there is no directory
1648 // to read here.
1649 const directory: string | undefined =
1650 typeof assets === "string" ? assets : assets.directory;
1651 // An unreadable file just means no rules here — the assets plugin
1652 // reports directory problems itself.
1653 const files = yield* readAssetsConfigFiles(directory).pipe(
1654 Effect.orElseSucceed(() => ({
1655 _headers: undefined,
1656 _redirects: undefined,
1657 })),
1658 );
1659 if (typeof assets === "string") {
1660 return {
1661 directory: assets,
1662 headers: files._headers,
1663 redirects: files._redirects,
1664 };
1665 }
1666 return {
1667 directory: assets.directory,
1668 headers: assets.headers ?? files._headers,
1669 redirects: assets.redirects ?? files._redirects,
1670 htmlHandling: assets.htmlHandling,
1671 notFoundHandling: assets.notFoundHandling,
1672 // The deployed default is assets-first (`run_worker_first` omitted =
1673 // false on the cloud API), but the runtime's router inverts it — it
1674 // treats anything `!== false` (including `undefined`) as worker-first.
1675 // Pin the cloud default explicitly so a Worker with an assets directory
1676 // and a `main` serves asset paths from the asset layer locally exactly
1677 // like it does deployed.
1678 runWorkerFirst: assets.runWorkerFirst ?? false,
1679 serveDirectly: assets.serveDirectly,
1680 };
1681});
1682
1683const moduleTypeFromExtension = (ext: string): Module["type"] | "SourceMap" => {
1684 switch (ext) {
1685 case ".wasm":
1686 return "Wasm";
1687 case ".txt":
1688 case ".html":
1689 case ".sql":
1690 case ".custom":
1691 return "Text";
1692 case ".bin":
1693 return "Data";
1694 case ".mjs":
1695 case ".js":
1696 return "ESModule";
1697 case ".cjs":
1698 return "CommonJsModule";
1699 case ".py":
1700 return "PythonModule";
1701 case ".map":
1702 return "SourceMap";
1703 default:
1704 return "Text";
1705 }
1706};