| 1 | import { |
| 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"; |
| 12 | import type { ContainerImage } from "@alchemy.run/cloudflare-runtime/core/Docker"; |
| 13 | import * as WorkerProxy from "@alchemy.run/cloudflare-runtime/core/proxy/WorkerProxy"; |
| 14 | import * as Cause from "effect/Cause"; |
| 15 | import * as ConsoleService from "effect/Console"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Equal from "effect/Equal"; |
| 18 | import * as Exit from "effect/Exit"; |
| 19 | import * as Fiber from "effect/Fiber"; |
| 20 | import type * as FileSystem from "effect/FileSystem"; |
| 21 | import * as PlatformFileSystem from "effect/FileSystem"; |
| 22 | import * as MutableHashMap from "effect/MutableHashMap"; |
| 23 | import * as Option from "effect/Option"; |
| 24 | import * as Path from "effect/Path"; |
| 25 | import { dotAlchemyDirectory } from "../../AlchemyContext.ts"; |
| 26 | import { isPathWithin } from "../../Util/isPathWithin.ts"; |
| 27 | import * as Result from "effect/Result"; |
| 28 | import * as Scope from "effect/Scope"; |
| 29 | import * as Semaphore from "effect/Semaphore"; |
| 30 | import * as Stream from "effect/Stream"; |
| 31 | import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 32 | import * as os from "node:os"; |
| 33 | import type * as Bundle from "../../Bundle/Bundle.ts"; |
| 34 | import { ANSI_RESET, ansiFg, colorsEnabled } from "../../Util/Terminal.ts"; |
| 35 | import { theme } from "../../Util/Theme.ts"; |
| 36 | import { |
| 37 | formatResourceTag, |
| 38 | makeResourceLogger, |
| 39 | makeResourceOutput, |
| 40 | } from "../../Util/ResourceOutput.ts"; |
| 41 | import { makeDevLogDirectory, makeDevLogOpener } from "../../Local/DevLog.ts"; |
| 42 | import { FQN_SEPARATOR } from "../../FQN.ts"; |
| 43 | import * as LocalProvider from "../../Local/LocalProvider.ts"; |
| 44 | import { Stack } from "../../Stack.ts"; |
| 45 | import { unwrapRedacted } from "../../Util/index.ts"; |
| 46 | import { sha256 } from "../../Util/sha256.ts"; |
| 47 | import { localAccountId } from "../LocalAccount.ts"; |
| 48 | import { |
| 49 | isLiveId, |
| 50 | LOCAL_PROVIDERS_URL, |
| 51 | LocalRuntimeState, |
| 52 | localStorageDirectory, |
| 53 | } from "../LocalRuntime.ts"; |
| 54 | import type { ConsumerSettings } from "../Queues/Consumer.ts"; |
| 55 | import type { WorkerAssetsConfig, WorkerProps } from "../Workers/Worker.ts"; |
| 56 | import { readAssetsConfigFiles } from "./Assets.ts"; |
| 57 | import { getCompatibility } from "./Compatibility.ts"; |
| 58 | import { watchPrebuiltWorkerBundle } from "./Sources/Prebuilt.ts"; |
| 59 | import { isPythonMain, watchPythonWorkerBundle } from "./Sources/Python.ts"; |
| 60 | import { |
| 61 | Artifacts as AlchemyArtifacts, |
| 62 | createArtifactStore, |
| 63 | makeScopedArtifacts, |
| 64 | } from "../../Artifacts.ts"; |
| 65 | import { loadSource, SourceProviderError, type DevContext } from "./Source.ts"; |
| 66 | import { isSelfUrl, Worker } from "./Worker.ts"; |
| 67 | import { getCronBindings } from "./WorkerAsyncBindings.ts"; |
| 68 | import type { WorkerBinding } from "./WorkerBinding.ts"; |
| 69 | import { WorkerBundle, type WorkerBundleOptions } from "./Sources/Rolldown.ts"; |
| 70 | import { createWorkerName } from "./WorkerName.ts"; |
| 71 | import { resolveTailConsumers } from "./WorkerProvider.ts"; |
| 72 | import { |
| 73 | materializeRuntimeBindings, |
| 74 | WorkerValidationError, |
| 75 | } from "./RuntimeBindings.ts"; |
| 76 | import { startViteChild } from "./ViteChild.ts"; |
| 77 | import { DEFAULT_DEV_PORT, type ViteChildConfig } from "./ViteChild.shared.ts"; |
| 78 | |
| 79 | /** Local dev-server options (the worker-mode arm of `WorkerProps["dev"]`). */ |
| 80 | type DevServerOptions = Extract<WorkerProps["dev"], { mode?: "worker" }> & { |
| 81 | port: number; |
| 82 | }; |
| 83 | |
| 84 | const 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 | |
| 97 | const 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. |
| 106 | const 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 | */ |
| 116 | const 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 | |
| 137 | export 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 | |
| 1636 | const 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 | |
| 1683 | const 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 | }; |