lopata 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/types/api/dispatch.d.ts +2 -2
  2. package/dist/types/api/handlers/workflows.d.ts +2 -2
  3. package/dist/types/bindings/container-cleanup.d.ts +43 -0
  4. package/dist/types/bindings/container-docker.d.ts +24 -0
  5. package/dist/types/bindings/container.d.ts +4 -2
  6. package/dist/types/bindings/do-executor-worker.d.ts +73 -89
  7. package/dist/types/bindings/do-executor.d.ts +22 -1
  8. package/dist/types/bindings/do-worker-env.d.ts +16 -7
  9. package/dist/types/bindings/durable-object.d.ts +66 -4
  10. package/dist/types/bindings/queue.d.ts +4 -1
  11. package/dist/types/bindings/rpc-stub.d.ts +23 -1
  12. package/dist/types/bindings/scheduled.d.ts +13 -3
  13. package/dist/types/bindings/service-binding.d.ts +13 -4
  14. package/dist/types/bindings/static-assets.d.ts +1 -1
  15. package/dist/types/bindings/websocket-pair.d.ts +12 -3
  16. package/dist/types/bindings/workflow.d.ts +29 -0
  17. package/dist/types/config.d.ts +2 -0
  18. package/dist/types/env.d.ts +6 -0
  19. package/dist/types/error-page-render.d.ts +6 -0
  20. package/dist/types/execution-context.d.ts +8 -0
  21. package/dist/types/generation-manager.d.ts +21 -2
  22. package/dist/types/generation.d.ts +16 -21
  23. package/dist/types/import-graph.d.ts +28 -0
  24. package/dist/types/lopata-config.d.ts +3 -3
  25. package/dist/types/plugin.d.ts +4 -1
  26. package/dist/types/rpc-validate.d.ts +9 -0
  27. package/dist/types/setup-globals.d.ts +5 -1
  28. package/dist/types/tracing/context.d.ts +21 -0
  29. package/dist/types/tracing/span.d.ts +21 -3
  30. package/dist/types/tracing/store.d.ts +17 -0
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/dist/types/vite-plugin/index.d.ts +8 -1
  33. package/dist/types/worker-registry.d.ts +11 -4
  34. package/dist/types/worker-thread/do-protocol.d.ts +256 -0
  35. package/dist/types/worker-thread/entry.d.ts +2 -0
  36. package/dist/types/worker-thread/execution-context.d.ts +22 -0
  37. package/dist/types/worker-thread/executor.d.ts +123 -0
  38. package/dist/types/worker-thread/protocol.d.ts +553 -0
  39. package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
  40. package/dist/types/worker-thread/rpc-client.d.ts +4 -0
  41. package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
  42. package/dist/types/worker-thread/serialize.d.ts +12 -0
  43. package/dist/types/worker-thread/stream-shared.d.ts +147 -0
  44. package/dist/types/worker-thread/thread-env.d.ts +42 -0
  45. package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
  46. package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
  47. package/package.json +1 -1
  48. package/src/api/handlers/containers.ts +2 -1
  49. package/src/api/handlers/workflows.ts +39 -34
  50. package/src/bindings/container-cleanup.ts +125 -0
  51. package/src/bindings/container-docker.ts +49 -34
  52. package/src/bindings/container.ts +24 -9
  53. package/src/bindings/do-executor-inprocess.ts +9 -5
  54. package/src/bindings/do-executor-worker.ts +386 -158
  55. package/src/bindings/do-executor.ts +23 -1
  56. package/src/bindings/do-worker-entry.ts +242 -60
  57. package/src/bindings/do-worker-env.ts +296 -11
  58. package/src/bindings/durable-object.ts +231 -27
  59. package/src/bindings/email.ts +6 -0
  60. package/src/bindings/queue.ts +11 -1
  61. package/src/bindings/rpc-stub.ts +79 -10
  62. package/src/bindings/scheduled.ts +37 -30
  63. package/src/bindings/service-binding.ts +96 -35
  64. package/src/bindings/static-assets.ts +3 -2
  65. package/src/bindings/websocket-pair.ts +19 -3
  66. package/src/bindings/workflow.ts +91 -0
  67. package/src/cli/dev.ts +106 -41
  68. package/src/config.ts +6 -3
  69. package/src/db.ts +1 -0
  70. package/src/env.ts +40 -21
  71. package/src/error-page-render.ts +21 -0
  72. package/src/execution-context.ts +13 -3
  73. package/src/generation-manager.ts +144 -143
  74. package/src/generation.ts +150 -306
  75. package/src/import-graph.ts +140 -0
  76. package/src/lopata-config.ts +3 -3
  77. package/src/plugin.ts +7 -0
  78. package/src/rpc-validate.ts +29 -0
  79. package/src/setup-globals.ts +6 -17
  80. package/src/testing/durable-object.ts +5 -3
  81. package/src/testing/index.ts +8 -4
  82. package/src/tracing/context.ts +28 -0
  83. package/src/tracing/span.ts +88 -56
  84. package/src/tracing/store.ts +41 -3
  85. package/src/virtual-modules.ts +2 -0
  86. package/src/vite-plugin/dev-server-plugin.ts +4 -0
  87. package/src/vite-plugin/index.ts +8 -1
  88. package/src/worker-registry.ts +15 -2
  89. package/src/worker-thread/do-protocol.ts +237 -0
  90. package/src/worker-thread/entry.ts +453 -0
  91. package/src/worker-thread/execution-context.ts +53 -0
  92. package/src/worker-thread/executor.ts +595 -0
  93. package/src/worker-thread/protocol.ts +552 -0
  94. package/src/worker-thread/remote-trace-store.ts +90 -0
  95. package/src/worker-thread/rpc-client.ts +5 -0
  96. package/src/worker-thread/rpc-shared.ts +503 -0
  97. package/src/worker-thread/serialize.ts +37 -0
  98. package/src/worker-thread/stream-shared.ts +414 -0
  99. package/src/worker-thread/thread-env.ts +350 -0
  100. package/src/worker-thread/wire-handlers.ts +80 -0
  101. package/src/worker-thread/ws-bridge-shared.ts +482 -0
  102. package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
  103. package/dist/types/module-cache.d.ts +0 -23
  104. package/src/bindings/do-websocket-bridge.ts +0 -79
  105. package/src/module-cache.ts +0 -58
  106. package/src/tracing/global.d.ts +0 -50
@@ -0,0 +1,453 @@
1
+ /** Worker-thread entry: imports user module, builds env, dispatches fetch. */
2
+
3
+ import { ForwardableEmailMessage } from '../bindings/email'
4
+ import { createScheduledController } from '../bindings/scheduled'
5
+ import { resolveEntrypointTarget } from '../bindings/service-binding'
6
+ import { CFWebSocket, type ResponseWithWebSocket } from '../bindings/websocket-pair'
7
+ import { getDatabase } from '../db'
8
+ import { getActiveContext, runWithParentContext } from '../tracing/context'
9
+ import { setTraceStoreOverride } from '../tracing/store'
10
+ import { trackBackgroundWork, WorkerExecutionContext } from './execution-context'
11
+ import type {
12
+ ParentSpanContext,
13
+ SerializedResponse,
14
+ WorkerCommand,
15
+ WorkerHandlerName,
16
+ WorkerInitConfig,
17
+ WorkerMessage,
18
+ WorkflowControlOp,
19
+ WorkflowControlResult,
20
+ } from './protocol'
21
+ import { deserializeError, serializeError } from './protocol'
22
+ import { RemoteTraceStore } from './remote-trace-store'
23
+ import { RpcClient } from './rpc-shared'
24
+ import { deserializeRequest } from './serialize'
25
+ import { OutboundStreamRegistry, pumpStream, STREAM_BACKPRESSURE_WINDOW, StreamReceiver } from './stream-shared'
26
+ import { buildThreadEnv } from './thread-env'
27
+ import { startThreadQueueConsumers, wireWorkflows } from './wire-handlers'
28
+ import { WsGuestBridge } from './ws-bridge-shared'
29
+
30
+ declare var self: Worker
31
+
32
+ function post(msg: WorkerMessage): void {
33
+ postMessage(msg)
34
+ }
35
+
36
+ /** Outbound response-body pumps for the top-level fetch response (worker →
37
+ * main). Inbound `stream-cancel` (client disconnected) stops the source. */
38
+ const responseStreams = new OutboundStreamRegistry()
39
+ /** Active reconstructed top-level request bodies (main → worker). Chunks
40
+ * arriving before user code pulls the body queue inside the receiver. */
41
+ const requestStreams = new StreamReceiver(
42
+ (streamId) => {
43
+ post({ type: 'req-stream-cancel', streamId })
44
+ },
45
+ {
46
+ window: STREAM_BACKPRESSURE_WINDOW,
47
+ onCredit: (streamId) => post({ type: 'req-stream-ack', streamId }),
48
+ },
49
+ )
50
+
51
+ /**
52
+ * Serialize response status + headers only — the body is never buffered here.
53
+ * WS upgrades carry a `webSocketId`; every other body is streamed via a
54
+ * `streamId` (see `pumpResponseBody`). Buffering bodies via `arrayBuffer()` is
55
+ * what made unbounded responses (SSE, chunked) hang forever.
56
+ */
57
+ function serializeResponse(response: Response, ws: WsGuestBridge<WorkerMessage>): SerializedResponse {
58
+ const cfSocket = (response as ResponseWithWebSocket).webSocket
59
+ const headers: [string, string][] = []
60
+ response.headers.forEach((v, k) => headers.push([k, v]))
61
+ const base = { status: response.status, statusText: response.statusText, headers, body: null }
62
+ if (response.status === 101 && cfSocket) {
63
+ // Always a CFWebSocket: `new WebSocketPair()` peers are, and a peer that
64
+ // came back through a binding fetch was reconstructed as one by
65
+ // `createBridgedSocket` (thread-env's proxyFetch) — reshipping it just
66
+ // re-registers the bridged peer here (double-bridge to the client).
67
+ if (!(cfSocket instanceof CFWebSocket)) {
68
+ throw new Error(
69
+ 'Response.webSocket is not a CFWebSocket — '
70
+ + 'lopata can only ship WebSockets created via `new WebSocketPair()` or returned from a binding fetch.',
71
+ )
72
+ }
73
+ return { ...base, webSocketId: ws.register(cfSocket) }
74
+ }
75
+ if (response.body) {
76
+ return { ...base, streamId: responseStreams.allocateId() }
77
+ }
78
+ return base
79
+ }
80
+
81
+ type StreamChunkMsg = Extract<WorkerMessage, { type: 'stream-chunk' }>
82
+ type StreamEndMsg = Extract<WorkerMessage, { type: 'stream-end' }>
83
+ type StreamErrorMsg = Extract<WorkerMessage, { type: 'stream-error' }>
84
+
85
+ /** Per-in-flight-fetch AbortControllers, keyed by the main-side fetch id. Main
86
+ * posts `fetch-abort` on client disconnect; aborting fires the rebuilt Request's
87
+ * `signal` so user code's `request.signal.addEventListener('abort', …)` runs. */
88
+ const fetchAbortControllers = new Map<number, AbortController>()
89
+
90
+ /** Pump a response body to main as `stream-chunk`s, terminated by `stream-end`
91
+ * or `stream-error`. Started only after `fetch-result` is posted so main has
92
+ * the `streamId` registered before chunks arrive. */
93
+ function pumpResponseBody(streamId: number, body: ReadableStream<Uint8Array>, onComplete?: () => void): void {
94
+ pumpStream<StreamChunkMsg, StreamEndMsg, StreamErrorMsg>(
95
+ streamId,
96
+ body,
97
+ responseStreams,
98
+ post,
99
+ {
100
+ chunk: (id, chunk) => ({ type: 'stream-chunk', id, chunk }),
101
+ end: (id) => ({ type: 'stream-end', id }),
102
+ error: (id, error) => ({ type: 'stream-error', id, error }),
103
+ },
104
+ undefined,
105
+ STREAM_BACKPRESSURE_WINDOW,
106
+ onComplete,
107
+ )
108
+ }
109
+
110
+ self.onmessage = async (event: MessageEvent<WorkerCommand>) => {
111
+ const msg = event.data
112
+ if (msg.type !== 'init') return
113
+
114
+ try {
115
+ await initRuntime(msg.config)
116
+ } catch (e) {
117
+ post({ type: 'init-error', error: serializeError(e) })
118
+ }
119
+ }
120
+
121
+ post({ type: 'need-init' })
122
+
123
+ /**
124
+ * Dispatch a request to a legacy service-worker `fetch` handler via a minimal
125
+ * `FetchEvent` shim. `respondWith` must be called synchronously during dispatch
126
+ * (the spec allows passing it a promise); resolve with whatever it's handed.
127
+ */
128
+ function dispatchServiceWorkerFetch(
129
+ handler: (event: unknown) => void,
130
+ request: Request,
131
+ ctx: WorkerExecutionContext,
132
+ ): Promise<Response> {
133
+ return new Promise<Response>((resolve, reject) => {
134
+ let responded = false
135
+ const event = {
136
+ type: 'fetch',
137
+ request,
138
+ respondWith(r: Response | Promise<Response>) {
139
+ responded = true
140
+ Promise.resolve(r).then(resolve, reject)
141
+ },
142
+ waitUntil(p: Promise<unknown>) {
143
+ ctx.waitUntil(p)
144
+ },
145
+ passThroughOnException() {
146
+ ctx.passThroughOnException()
147
+ },
148
+ }
149
+ try {
150
+ handler(event)
151
+ } catch (e) {
152
+ reject(e)
153
+ return
154
+ }
155
+ if (!responded) {
156
+ reject(new Error('Service worker "fetch" handler did not call event.respondWith()'))
157
+ }
158
+ })
159
+ }
160
+
161
+ async function initRuntime(init: WorkerInitConfig) {
162
+ // Plugin import must run before user code so Bun.plugin().module() intercepts
163
+ // `cloudflare:workers` etc. and `globalThis.caches` is patched in.
164
+ const plugin = await import('../plugin')
165
+
166
+ // Route all tracing operations through main so the dashboard's subscribers fire.
167
+ setTraceStoreOverride(new RemoteTraceStore(post))
168
+
169
+ const getParent = (): ParentSpanContext | undefined => {
170
+ const active = getActiveContext()
171
+ return active ? { traceId: active.traceId, spanId: active.spanId } : undefined
172
+ }
173
+ const rpc = new RpcClient(post, getParent)
174
+ const wsBridge = new WsGuestBridge<WorkerMessage>(post, {
175
+ remoteMessage: (wsId, data) => ({ type: 'ws-worker-send', wsId, data }),
176
+ // User-worker channel doesn't propagate wasClean — drop it (matches the
177
+ // pre-refactor wire format in `protocol.ts`).
178
+ remoteClose: (wsId, code, reason, _wasClean) => ({ type: 'ws-worker-close', wsId, code, reason }),
179
+ })
180
+ // Bridge for `Response{webSocket}` returned by env-binding fetches the worker
181
+ // calls (`env.DO.fetch('/ws')`). The user-facing peer lives here; user code can
182
+ // consume it, and reshipping it registers it on `wsBridge` (double-bridge).
183
+ const envWsBridge = new WsGuestBridge<WorkerMessage>(post, {
184
+ remoteMessage: (wsId, data) => ({ type: 'env-ws-outgoing', wsId, data }),
185
+ remoteClose: (wsId, code, reason, wasClean) => ({ type: 'env-ws-close-out', wsId, code, reason, wasClean }),
186
+ })
187
+ const built = buildThreadEnv({
188
+ config: init.config,
189
+ baseDir: init.baseDir,
190
+ dataDir: init.dataDir,
191
+ rpc,
192
+ browserConfig: init.browserConfig,
193
+ envWsBridge,
194
+ })
195
+ const { env } = built
196
+
197
+ // Make env visible to top-level `import { env } from 'cloudflare:workers'`
198
+ // in the user module — that import resolves to `globalEnv` from `src/env.ts`,
199
+ // which is empty until we publish it. Must happen BEFORE the dynamic import
200
+ // below.
201
+ const { setGlobalEnv } = await import('../env')
202
+ setGlobalEnv(env)
203
+
204
+ // Route binding-RPC replies BEFORE importing the user module. A module that
205
+ // touches a stateful binding at top level (`await env.SVC.fetch()`,
206
+ // `env.QUEUE.send()`, a DO stub call) posts an RPC during `import()`; until the
207
+ // full handler below is installed `self.onmessage` is the bootstrap handler that
208
+ // drops everything but `init`, so the reply would never arrive, the import's
209
+ // await would hang, `ready` would never post, and `GenerationManager.reload()`
210
+ // would wait on `executor.ready()` forever. fetch/scheduled/email never arrive
211
+ // pre-ready (main awaits `ready` before sending them), so handling RPC replies
212
+ // is enough.
213
+ self.onmessage = (event: MessageEvent<WorkerCommand>) => {
214
+ rpc.handle(event.data as { type: string })
215
+ }
216
+
217
+ const workerModule = await import(init.modulePath)
218
+ const defaultExport = workerModule.default
219
+
220
+ // Introspect DO + container classes for `alarm()` so main's
221
+ // `DurableObjectNamespaceImpl.hasAlarmHandler()` returns the right value in
222
+ // thread mode (main itself doesn't load the user module, so without this
223
+ // hint it would shortcircuit to `false`).
224
+ const doAlarmHandlers: Record<string, boolean> = {}
225
+ const collectAlarmHandler = (className: string) => {
226
+ if (doAlarmHandlers[className] !== undefined) return
227
+ const cls = (workerModule as Record<string, unknown>)[className] as
228
+ | { prototype?: { alarm?: unknown } }
229
+ | undefined
230
+ doAlarmHandlers[className] = typeof cls?.prototype?.alarm === 'function'
231
+ }
232
+ for (const binding of init.config.durable_objects?.bindings ?? []) {
233
+ collectAlarmHandler(binding.class_name)
234
+ }
235
+ for (const container of init.config.containers ?? []) {
236
+ collectAlarmHandler(container.class_name)
237
+ }
238
+
239
+ wireWorkflows(built, workerModule)
240
+ // Queue consumers run as setIntervals in this thread. A reload drains the old
241
+ // generation: main posts `stop-queue-consumers` so we stop claiming NEW
242
+ // messages (instead of competing with the new generation for the whole grace
243
+ // period), while in-flight batches finish — each registered with reload drain
244
+ // via `trackBackgroundWork` so `isIdle()` waits for them.
245
+ const queueConsumers = startThreadQueueConsumers(
246
+ init.config,
247
+ built.db,
248
+ env,
249
+ workerModule,
250
+ init.workerName,
251
+ (p) => trackBackgroundWork(post, p),
252
+ )
253
+
254
+ const invokeEntrypointRpc = async (
255
+ entrypoint: string | undefined,
256
+ method: string,
257
+ args: unknown[],
258
+ props?: Record<string, unknown>,
259
+ ): Promise<unknown> => {
260
+ const ctx = new WorkerExecutionContext(post, props)
261
+ const target = resolveEntrypointTarget(workerModule, entrypoint, ctx, env)
262
+ const member = target?.[method]
263
+ if (typeof member !== 'function') {
264
+ throw new Error(`Service binding RPC: "${method}" is not a function on the ${entrypoint ?? 'default'} entrypoint`)
265
+ }
266
+ return await (member as (...a: unknown[]) => unknown).call(target, ...args)
267
+ }
268
+
269
+ const invokeEntrypointPropertyGet = (
270
+ entrypoint: string | undefined,
271
+ property: string,
272
+ props?: Record<string, unknown>,
273
+ ): { kind: 'value'; value: unknown } | { kind: 'function' } => {
274
+ const ctx = new WorkerExecutionContext(post, props)
275
+ const target = resolveEntrypointTarget(workerModule, entrypoint, ctx, env)
276
+ const member = target?.[property]
277
+ if (typeof member === 'function') return { kind: 'function' }
278
+ return { kind: 'value', value: member }
279
+ }
280
+
281
+ const invokeWorkflowControl = async (bindingName: string, op: WorkflowControlOp): Promise<WorkflowControlResult> => {
282
+ const wf = built.workflows.find(w => w.bindingName === bindingName)
283
+ if (!wf) throw new Error(`Workflow binding "${bindingName}" not found`)
284
+ return wf.binding.executeControl(op)
285
+ }
286
+
287
+ const callFetch = async (request: Request, props?: Record<string, unknown>): Promise<Response> => {
288
+ const ctx = new WorkerExecutionContext(post, props)
289
+ if (typeof defaultExport === 'function' && defaultExport.prototype?.fetch) {
290
+ const Ctor = defaultExport as new(ctx: unknown, env: unknown) => { fetch: (r: Request) => Promise<Response> }
291
+ const instance = new Ctor(ctx, env)
292
+ return instance.fetch(request)
293
+ }
294
+ if (defaultExport && typeof defaultExport.fetch === 'function') {
295
+ return defaultExport.fetch(request, env, ctx) as Promise<Response>
296
+ }
297
+ // Legacy service-worker syntax: `addEventListener('fetch', e => e.respondWith(...))`.
298
+ // The plugin shim captured the handler at module-import time.
299
+ const swFetch = plugin.getServiceWorkerFetchHandler()
300
+ if (swFetch) {
301
+ return dispatchServiceWorkerFetch(swFetch, request, ctx)
302
+ }
303
+ throw new Error('Worker module does not export a fetch handler (and no addEventListener("fetch") handler was registered)')
304
+ }
305
+
306
+ /** Resolve a named handler honoring class- vs object-based exports. */
307
+ function resolveHandler(name: WorkerHandlerName, ctx: WorkerExecutionContext): ((...args: unknown[]) => Promise<unknown>) | null {
308
+ if (typeof defaultExport === 'function' && defaultExport.prototype) {
309
+ const fn = defaultExport.prototype[name]
310
+ if (typeof fn !== 'function') return null
311
+ const Ctor = defaultExport as new(ctx: unknown, env: unknown) => Record<string, (...args: unknown[]) => Promise<unknown>>
312
+ const instance = new Ctor(ctx, env)
313
+ return instance[name]!.bind(instance)
314
+ }
315
+ const obj = defaultExport as Record<string, unknown> | null | undefined
316
+ const fn = obj?.[name]
317
+ return typeof fn === 'function' ? (fn as (...a: unknown[]) => Promise<unknown>).bind(obj) : null
318
+ }
319
+
320
+ const callScheduled = async (cronExpr: string, scheduledTime: number): Promise<{ ok: boolean; noHandler?: boolean }> => {
321
+ const ctx = new WorkerExecutionContext(post)
322
+ const handler = resolveHandler('scheduled', ctx)
323
+ if (!handler) return { ok: false, noHandler: true }
324
+ const controller = createScheduledController(cronExpr, scheduledTime)
325
+ await handler(controller, env, ctx)
326
+ return { ok: true }
327
+ }
328
+
329
+ const callEmail = async (messageId: string, from: string, to: string, raw: Uint8Array): Promise<{ ok: boolean; noHandler?: boolean }> => {
330
+ const ctx = new WorkerExecutionContext(post)
331
+ const handler = resolveHandler('email', ctx)
332
+ if (!handler) return { ok: false, noHandler: true }
333
+ const message = new ForwardableEmailMessage(getDatabase(), messageId, from, to, raw)
334
+ await handler(message, env, ctx)
335
+ return { ok: true }
336
+ }
337
+
338
+ // When `noHandler:true` the `error.message` field is a wire-format placeholder —
339
+ // the main-side executor resolves with `ok:false` rather than rejecting, so the
340
+ // message is never surfaced to user code.
341
+ self.onmessage = async (event: MessageEvent<WorkerCommand>) => {
342
+ const cmd = event.data
343
+ if (rpc.handle(cmd as { type: string })) return
344
+ switch (cmd.type) {
345
+ case 'fetch': {
346
+ const abortController = new AbortController()
347
+ fetchAbortControllers.set(cmd.id, abortController)
348
+ try {
349
+ const reqBody = cmd.request.streamId !== undefined ? requestStreams.open(cmd.request.streamId) : undefined
350
+ const request = deserializeRequest(cmd.request, reqBody, abortController.signal)
351
+ const response = await runWithParentContext(cmd.parent, () => callFetch(request, cmd.props))
352
+ const serialized = serializeResponse(response, wsBridge)
353
+ post({ type: 'fetch-result', id: cmd.id, response: serialized })
354
+ if (serialized.streamId !== undefined && response.body) {
355
+ // Keep the AbortController alive until the streamed body finishes
356
+ // (an SSE loop may still observe request.signal); drop it on end.
357
+ pumpResponseBody(serialized.streamId, response.body, () => fetchAbortControllers.delete(cmd.id))
358
+ } else {
359
+ fetchAbortControllers.delete(cmd.id)
360
+ }
361
+ } catch (e) {
362
+ fetchAbortControllers.delete(cmd.id)
363
+ post({ type: 'fetch-error', id: cmd.id, error: serializeError(e) })
364
+ }
365
+ break
366
+ }
367
+ case 'scheduled':
368
+ try {
369
+ const result = await runWithParentContext(cmd.parent, () => callScheduled(cmd.cronExpr, cmd.scheduledTime))
370
+ if (!result.ok) post({ type: 'scheduled-error', id: cmd.id, error: { message: 'no-handler' }, noHandler: true })
371
+ else post({ type: 'scheduled-result', id: cmd.id })
372
+ } catch (e) {
373
+ post({ type: 'scheduled-error', id: cmd.id, error: serializeError(e) })
374
+ }
375
+ break
376
+ case 'email':
377
+ try {
378
+ const result = await runWithParentContext(cmd.parent, () => callEmail(cmd.messageId, cmd.from, cmd.to, cmd.raw))
379
+ if (!result.ok) post({ type: 'email-error', id: cmd.id, error: { message: 'no-handler' }, noHandler: true })
380
+ else post({ type: 'email-result', id: cmd.id })
381
+ } catch (e) {
382
+ post({ type: 'email-error', id: cmd.id, error: serializeError(e) })
383
+ }
384
+ break
385
+ case 'ws-client-message':
386
+ wsBridge.deliverClientMessage(cmd.wsId, cmd.data)
387
+ break
388
+ case 'ws-client-close':
389
+ wsBridge.deliverClientClose(cmd.wsId, cmd.code, cmd.reason, cmd.wasClean)
390
+ break
391
+ case 'env-ws-incoming':
392
+ envWsBridge.deliverClientMessage(cmd.wsId, cmd.data)
393
+ break
394
+ case 'env-ws-close-in':
395
+ envWsBridge.deliverClientClose(cmd.wsId, cmd.code, cmd.reason, cmd.wasClean)
396
+ break
397
+ case 'stream-cancel':
398
+ responseStreams.cancel(cmd.id)
399
+ break
400
+ case 'stream-ack':
401
+ responseStreams.grantCredit(cmd.id)
402
+ break
403
+ case 'req-stream-chunk':
404
+ requestStreams.push(cmd.streamId, cmd.chunk)
405
+ break
406
+ case 'req-stream-end':
407
+ requestStreams.end(cmd.streamId)
408
+ break
409
+ case 'req-stream-error':
410
+ requestStreams.error(cmd.streamId, deserializeError(cmd.error))
411
+ break
412
+ case 'stop-queue-consumers':
413
+ // Reload drain — stop polling for new messages. In-flight batches keep
414
+ // running and are awaited by main via their wait-until registration.
415
+ for (const consumer of queueConsumers) consumer.stop()
416
+ break
417
+ case 'fetch-abort':
418
+ // Client disconnected — fire the request's signal so user cleanup runs.
419
+ fetchAbortControllers.get(cmd.id)?.abort()
420
+ break
421
+ case 'entrypoint-rpc':
422
+ try {
423
+ const value = await runWithParentContext(cmd.parent, () => invokeEntrypointRpc(cmd.entrypoint, cmd.method, cmd.args, cmd.props))
424
+ post({ type: 'entrypoint-rpc-result', id: cmd.id, value })
425
+ } catch (e) {
426
+ post({ type: 'entrypoint-rpc-error', id: cmd.id, error: serializeError(e) })
427
+ }
428
+ break
429
+ case 'entrypoint-rpc-get':
430
+ try {
431
+ const result = runWithParentContext(cmd.parent, () => invokeEntrypointPropertyGet(cmd.entrypoint, cmd.property, cmd.props))
432
+ if (result.kind === 'function') {
433
+ post({ type: 'entrypoint-rpc-get-result', id: cmd.id, kind: 'function' })
434
+ } else {
435
+ post({ type: 'entrypoint-rpc-get-result', id: cmd.id, kind: 'value', value: result.value })
436
+ }
437
+ } catch (e) {
438
+ post({ type: 'entrypoint-rpc-get-error', id: cmd.id, error: serializeError(e) })
439
+ }
440
+ break
441
+ case 'workflow-control':
442
+ try {
443
+ const result = await runWithParentContext(cmd.parent, () => invokeWorkflowControl(cmd.binding, cmd.op))
444
+ post({ type: 'workflow-control-result', id: cmd.id, result })
445
+ } catch (e) {
446
+ post({ type: 'workflow-control-error', id: cmd.id, error: serializeError(e) })
447
+ }
448
+ break
449
+ }
450
+ }
451
+
452
+ post({ type: 'ready', doAlarmHandlers })
453
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Worker-thread implementation of CF's `ExecutionContext`. Tracks
3
+ * `waitUntil()` promises locally (they capture worker-side closures) and
4
+ * notifies main of each add/settle so reload drain can wait for them.
5
+ */
6
+
7
+ import { logIfRejected } from '../execution-context'
8
+ import type { WorkerMessage } from './protocol'
9
+
10
+ // Worker-thread-global wait-until id sequence. Ids never cross thread
11
+ // boundaries (each generation has its own worker), so a module-level counter
12
+ // is enough to keep main's `_pendingWaitUntil` Set unambiguous across
13
+ // concurrent fetches.
14
+ let nextWaitUntilId = 1
15
+
16
+ /**
17
+ * Register a background promise with main's reload-drain accounting (same
18
+ * `wait-until-add` / `wait-until-settle` protocol as `ctx.waitUntil`). Used for
19
+ * worker-side work that isn't tied to an `ExecutionContext` — e.g. an in-flight
20
+ * queue batch, so reload drain waits for it instead of terminating mid-batch.
21
+ */
22
+ export function trackBackgroundWork(post: (msg: WorkerMessage) => void, promise: Promise<unknown>): void {
23
+ const id = nextWaitUntilId++
24
+ post({ type: 'wait-until-add', id })
25
+ logIfRejected(promise).finally(() => {
26
+ post({ type: 'wait-until-settle', id })
27
+ })
28
+ }
29
+
30
+ export class WorkerExecutionContext {
31
+ /** `ctx.props` — carries the calling worker's service-binding `props` for
32
+ * `entrypoint-rpc` / `fetch` dispatch; `{}` for top-level HTTP. */
33
+ readonly props: Record<string, unknown>
34
+ private _post: (msg: WorkerMessage) => void
35
+
36
+ constructor(post: (msg: WorkerMessage) => void, props?: Record<string, unknown>) {
37
+ this._post = post
38
+ this.props = props ?? {}
39
+ }
40
+
41
+ waitUntil(promise: Promise<unknown>): void {
42
+ const id = nextWaitUntilId++
43
+ this._post({ type: 'wait-until-add', id })
44
+ logIfRejected(promise).finally(() => {
45
+ this._post({ type: 'wait-until-settle', id })
46
+ })
47
+ }
48
+
49
+ passThroughOnException(): void {
50
+ // No origin in local dev — no-op matches CF semantics when the runtime
51
+ // has nowhere to pass through to.
52
+ }
53
+ }