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,595 @@
1
+ /**
2
+ * Main-thread side of the worker-thread runtime.
3
+ *
4
+ * Spawns a Bun Worker that hosts the user module graph (see `entry.ts`)
5
+ * and exposes `executeFetch()` to the rest of lopata. Lifecycle is
6
+ * one-shot: each Generation owns its own executor and `dispose()` is
7
+ * called when the generation is stopped (i.e. on every reload).
8
+ */
9
+
10
+ import { dirname, resolve } from 'node:path'
11
+ import { DurableObjectIdImpl } from '../bindings/durable-object'
12
+ import { CFWebSocket, type ResponseWithWebSocket } from '../bindings/websocket-pair'
13
+ import type { WranglerConfig } from '../config'
14
+ import { getDataDir } from '../db'
15
+ import { getActiveContext } from '../tracing/context'
16
+ import { getTraceStore } from '../tracing/store'
17
+ import type {
18
+ BindingTarget,
19
+ ParentSpanContext,
20
+ SerializedRequest,
21
+ SerializedResponse,
22
+ WorkerCommand,
23
+ WorkerMessage,
24
+ WorkflowControlOp,
25
+ WorkflowControlResult,
26
+ } from './protocol'
27
+ import { deserializeError } from './protocol'
28
+ import { RpcHostChannel } from './rpc-shared'
29
+ import { deserializeResponse, serializeRequestShell } from './serialize'
30
+ import { OutboundStreamRegistry, pumpStream, STREAM_BACKPRESSURE_WINDOW, StreamReceiver } from './stream-shared'
31
+ import { WsHostBridge } from './ws-bridge-shared'
32
+
33
+ const WORKER_ENTRY = resolve(dirname(new URL(import.meta.url).pathname), 'entry.ts')
34
+
35
+ function isTraceMessage(msg: WorkerMessage): msg is Extract<WorkerMessage, { type: `trace-${string}` }> {
36
+ return msg.type.startsWith('trace-')
37
+ }
38
+
39
+ interface Pending<T> {
40
+ resolve: (value: T) => void
41
+ reject: (error: Error) => void
42
+ }
43
+
44
+ type HandlerResult = { ok: true } | { ok: false; noHandler: true }
45
+
46
+ export interface WorkerThreadExecutorOptions {
47
+ modulePath: string
48
+ config: WranglerConfig
49
+ baseDir: string
50
+ workerName?: string
51
+ browserConfig?: { wsEndpoint?: string; executablePath?: string; headless?: boolean }
52
+ /** Main-thread env holding the stateful binding instances the worker calls into via RPC. */
53
+ mainEnv: Record<string, unknown>
54
+ }
55
+
56
+ export interface WorkerReadyInfo {
57
+ /** className → whether the user's DO class defines an `alarm()` handler. */
58
+ doAlarmHandlers: Record<string, boolean>
59
+ }
60
+
61
+ export class WorkerThreadExecutor {
62
+ private _worker: Worker
63
+ private _ready: Promise<WorkerReadyInfo>
64
+ private _readyResolve!: (info: WorkerReadyInfo) => void
65
+ private _readyReject!: (err: Error) => void
66
+ private _pending = new Map<number, Pending<SerializedResponse>>()
67
+ private _pendingHandlers = new Map<number, Pending<HandlerResult>>()
68
+ private _pendingRpc = new Map<number, Pending<unknown>>()
69
+ private _pendingRpcGet = new Map<number, Pending<{ kind: 'value'; value: unknown } | { kind: 'function' }>>()
70
+ private _pendingWorkflowControl = new Map<number, Pending<WorkflowControlResult>>()
71
+ private _nextId = 1
72
+ private _disposed = false
73
+ private _initConfig: WorkerThreadExecutorOptions
74
+ private _mainEnv: Record<string, unknown>
75
+ private _pendingWaitUntil = new Set<number>()
76
+ private _wsBridge: WsHostBridge<WorkerCommand>
77
+ /** Main-side bridge for upstream CFWebSockets adopted from env-binding fetches
78
+ * the user worker initiated (`env.DO.fetch('/ws')` returning 101). The worker
79
+ * side holds the user-facing peer; events flow both ways so user code can
80
+ * consume the socket (`.accept()`/`.send()`), not just reship it. */
81
+ private _envBindingWsBridge!: WsHostBridge<WorkerCommand>
82
+ /** Reconstructed response bodies fed by `stream-chunk` from the worker
83
+ * (top-level fetch response → main). */
84
+ private _responseStreams = new StreamReceiver(
85
+ (streamId) => {
86
+ if (this._disposed) return
87
+ this._send({ type: 'stream-cancel', id: streamId })
88
+ },
89
+ {
90
+ window: STREAM_BACKPRESSURE_WINDOW,
91
+ onCredit: (streamId) => {
92
+ if (this._disposed) return
93
+ this._send({ type: 'stream-ack', id: streamId })
94
+ },
95
+ },
96
+ )
97
+ /** Main-side host of the unified cross-thread RPC channel — binding call/get/
98
+ * fetch from the user's worker, with response/request-body streaming. Shared
99
+ * with the DO-worker executor; channel-specifics injected as hooks. */
100
+ private _rpcChannel = new RpcHostChannel({
101
+ resolveBinding: (target) => this._resolveBinding(target),
102
+ post: (reply) => this._send(reply),
103
+ isAlive: () => !this._disposed,
104
+ decorateResponse: (response, serialized) => {
105
+ const ws = (response as ResponseWithWebSocket).webSocket
106
+ if (response.status === 101 && ws instanceof CFWebSocket) {
107
+ // `bridgeEvents: true` forwards the upstream socket's events to the
108
+ // worker so user code can consume the WS (the CF-documented
109
+ // `(await env.DO.fetch(req)).webSocket.accept()` pattern). Reship still
110
+ // works: the worker re-registers the bridged peer on its top-level WS
111
+ // bridge, double-bridging through to the real client.
112
+ serialized.webSocketId = this._envBindingWsBridge.adoptExisting(ws, { bridgeEvents: true })
113
+ }
114
+ },
115
+ })
116
+ /** Outbound request-body pumps for the top-level fetch path (main → worker).
117
+ * A `req-stream-cancel` from the worker (user code cancelled `request.body`)
118
+ * stops the source reader. */
119
+ private _topRequestStreams = new OutboundStreamRegistry()
120
+
121
+ constructor(options: WorkerThreadExecutorOptions) {
122
+ this._initConfig = options
123
+ this._mainEnv = options.mainEnv
124
+ this._ready = new Promise<WorkerReadyInfo>((res, rej) => {
125
+ this._readyResolve = res
126
+ this._readyReject = rej
127
+ })
128
+
129
+ this._worker = new Worker(WORKER_ENTRY)
130
+ this._wsBridge = new WsHostBridge<WorkerCommand>(cmd => this._send(cmd), {
131
+ clientMessage: (wsId, data) => ({ type: 'ws-client-message', wsId, data }),
132
+ clientClose: (wsId, code, reason, wasClean) => ({ type: 'ws-client-close', wsId, code, reason, wasClean }),
133
+ })
134
+ this._envBindingWsBridge = new WsHostBridge<WorkerCommand>(cmd => this._send(cmd), {
135
+ clientMessage: (wsId, data) => ({ type: 'env-ws-incoming', wsId, data }),
136
+ clientClose: (wsId, code, reason, wasClean) => ({ type: 'env-ws-close-in', wsId, code, reason, wasClean }),
137
+ })
138
+ this._worker.onmessage = (event: MessageEvent<WorkerMessage>) => this._handleMessage(event.data)
139
+ this._worker.onerror = (event: ErrorEvent) => {
140
+ if (this._disposed) return
141
+ this._disposed = true
142
+ // Bun does NOT auto-terminate a Worker that crashed via an uncaught error
143
+ // (unhandledrejection, async errors in waitUntil/queue/timer callbacks).
144
+ // Terminate it here — otherwise `dispose()` early-returns on `_disposed`
145
+ // and the thread (with its queue-consumer `setInterval`s, see
146
+ // `startThreadQueueConsumers`) lives until process exit, polling the shared
147
+ // SQLite queue forever. Mirrors `WorkerExecutor.onerror` for DO workers.
148
+ this._worker.terminate()
149
+ // `event.message` is frequently empty for Bun worker errors — prefer the
150
+ // real stack when the runtime attaches one so the user gets a usable trace.
151
+ const detail = event.error?.stack ?? event.message ?? 'unknown'
152
+ this._failAll(new Error(`Worker thread error: ${detail}`))
153
+ }
154
+ }
155
+
156
+ /** Reject every outstanding promise and tear down bridges. Shared by `onerror`
157
+ * (worker crashed) and `dispose()` (planned teardown). */
158
+ private _failAll(err: Error): void {
159
+ this._readyReject(err)
160
+ for (const [, pending] of this._pending) pending.reject(err)
161
+ for (const [, pending] of this._pendingHandlers) pending.reject(err)
162
+ for (const [, pending] of this._pendingRpc) pending.reject(err)
163
+ for (const [, pending] of this._pendingRpcGet) pending.reject(err)
164
+ for (const [, pending] of this._pendingWorkflowControl) pending.reject(err)
165
+ this._pending.clear()
166
+ this._pendingHandlers.clear()
167
+ this._pendingRpc.clear()
168
+ this._pendingRpcGet.clear()
169
+ this._pendingWorkflowControl.clear()
170
+ this._pendingWaitUntil.clear()
171
+ this._responseStreams.disposeAll(err)
172
+ this._rpcChannel.disposeAll(err)
173
+ this._topRequestStreams.disposeAll()
174
+ this._wsBridge.disposeAll()
175
+ this._envBindingWsBridge.disposeAll()
176
+ }
177
+
178
+ private _send(cmd: WorkerCommand): void {
179
+ this._worker.postMessage(cmd)
180
+ }
181
+
182
+ private _handleMessage(msg: WorkerMessage): void {
183
+ if (isTraceMessage(msg)) {
184
+ // Trace writes target the shared (process-wide) TraceStore + dashboard
185
+ // subscribers — they never touch this (possibly disposed) generation.
186
+ // Apply them even after dispose so a `trace-span-end` (or attrs/event)
187
+ // queued just before teardown still finalizes the span instead of
188
+ // leaving it dangling 'unset' for the dying generation.
189
+ this._applyTrace(msg)
190
+ return
191
+ }
192
+ if (this._disposed) return
193
+ if (this._rpcChannel.handle(msg)) return
194
+ switch (msg.type) {
195
+ case 'need-init':
196
+ this._send({
197
+ type: 'init',
198
+ config: {
199
+ modulePath: this._initConfig.modulePath,
200
+ config: this._initConfig.config,
201
+ baseDir: this._initConfig.baseDir,
202
+ // Same physical .lopata dir main + DO workers use, NOT baseDir —
203
+ // otherwise multi-worker mode splits binding state into a second db.
204
+ dataDir: getDataDir(),
205
+ workerName: this._initConfig.workerName,
206
+ browserConfig: this._initConfig.browserConfig,
207
+ },
208
+ })
209
+ break
210
+ case 'ready':
211
+ this._readyResolve({ doAlarmHandlers: msg.doAlarmHandlers })
212
+ break
213
+ case 'init-error': {
214
+ this._readyReject(deserializeError(msg.error))
215
+ break
216
+ }
217
+ case 'fetch-result': {
218
+ const pending = this._pending.get(msg.id)
219
+ if (pending) {
220
+ this._pending.delete(msg.id)
221
+ pending.resolve(msg.response)
222
+ }
223
+ break
224
+ }
225
+ case 'fetch-error': {
226
+ const pending = this._pending.get(msg.id)
227
+ if (pending) {
228
+ this._pending.delete(msg.id)
229
+ const err = deserializeError(msg.error)
230
+ pending.reject(err)
231
+ }
232
+ break
233
+ }
234
+ case 'scheduled-result':
235
+ case 'email-result': {
236
+ const p = this._pendingHandlers.get(msg.id)
237
+ if (p) {
238
+ this._pendingHandlers.delete(msg.id)
239
+ p.resolve({ ok: true })
240
+ }
241
+ break
242
+ }
243
+ case 'entrypoint-rpc-result': {
244
+ const p = this._pendingRpc.get(msg.id)
245
+ if (p) {
246
+ this._pendingRpc.delete(msg.id)
247
+ p.resolve(msg.value)
248
+ }
249
+ break
250
+ }
251
+ case 'entrypoint-rpc-error': {
252
+ const p = this._pendingRpc.get(msg.id)
253
+ if (!p) break
254
+ this._pendingRpc.delete(msg.id)
255
+ p.reject(deserializeError(msg.error))
256
+ break
257
+ }
258
+ case 'entrypoint-rpc-get-result': {
259
+ const p = this._pendingRpcGet.get(msg.id)
260
+ if (p) {
261
+ this._pendingRpcGet.delete(msg.id)
262
+ p.resolve(msg.kind === 'function' ? { kind: 'function' } : { kind: 'value', value: msg.value })
263
+ }
264
+ break
265
+ }
266
+ case 'entrypoint-rpc-get-error': {
267
+ const p = this._pendingRpcGet.get(msg.id)
268
+ if (!p) break
269
+ this._pendingRpcGet.delete(msg.id)
270
+ p.reject(deserializeError(msg.error))
271
+ break
272
+ }
273
+ case 'workflow-control-result': {
274
+ const p = this._pendingWorkflowControl.get(msg.id)
275
+ if (p) {
276
+ this._pendingWorkflowControl.delete(msg.id)
277
+ p.resolve(msg.result)
278
+ }
279
+ break
280
+ }
281
+ case 'workflow-control-error': {
282
+ const p = this._pendingWorkflowControl.get(msg.id)
283
+ if (!p) break
284
+ this._pendingWorkflowControl.delete(msg.id)
285
+ p.reject(deserializeError(msg.error))
286
+ break
287
+ }
288
+ case 'scheduled-error':
289
+ case 'email-error': {
290
+ const p = this._pendingHandlers.get(msg.id)
291
+ if (!p) break
292
+ this._pendingHandlers.delete(msg.id)
293
+ if (msg.noHandler) {
294
+ p.resolve({ ok: false, noHandler: true })
295
+ } else {
296
+ const err = deserializeError(msg.error)
297
+ p.reject(err)
298
+ }
299
+ break
300
+ }
301
+ case 'req-stream-cancel':
302
+ this._topRequestStreams.cancel(msg.streamId)
303
+ break
304
+ case 'req-stream-ack':
305
+ this._topRequestStreams.grantCredit(msg.streamId)
306
+ break
307
+ case 'wait-until-add':
308
+ this._pendingWaitUntil.add(msg.id)
309
+ break
310
+ case 'wait-until-settle':
311
+ this._pendingWaitUntil.delete(msg.id)
312
+ break
313
+ case 'ws-worker-send':
314
+ this._wsBridge.deliverRemoteMessage(msg.wsId, msg.data)
315
+ break
316
+ case 'ws-worker-close':
317
+ this._wsBridge.deliverRemoteClose(msg.wsId, msg.code, msg.reason, true)
318
+ break
319
+ case 'env-ws-outgoing':
320
+ this._envBindingWsBridge.deliverRemoteMessage(msg.wsId, msg.data)
321
+ break
322
+ case 'env-ws-close-out':
323
+ this._envBindingWsBridge.deliverRemoteClose(msg.wsId, msg.code, msg.reason, msg.wasClean)
324
+ break
325
+ case 'stream-chunk':
326
+ this._responseStreams.push(msg.id, msg.chunk)
327
+ break
328
+ case 'stream-end':
329
+ this._responseStreams.end(msg.id)
330
+ break
331
+ case 'stream-error':
332
+ this._responseStreams.error(msg.id, deserializeError(msg.error))
333
+ break
334
+ }
335
+ }
336
+
337
+ /** Apply a forwarded trace-store write on main. Wrapped in try/catch because
338
+ * these run inside `worker.onmessage`: a write that throws (a `BigInt` /
339
+ * circular value `JSON.stringify` chokes on, a transient DB error) would be
340
+ * an uncaught exception that takes down the whole dev server. A failed trace
341
+ * write is diagnostic-only — never worth crashing for. */
342
+ private _applyTrace(msg: Extract<WorkerMessage, { type: `trace-${string}` }>): void {
343
+ try {
344
+ const store = getTraceStore()
345
+ switch (msg.type) {
346
+ case 'trace-span-insert':
347
+ store.insertSpan(msg.span)
348
+ break
349
+ case 'trace-span-end':
350
+ store.endSpan(msg.spanId, msg.endTime, msg.status, msg.statusMessage ?? undefined)
351
+ break
352
+ case 'trace-span-status':
353
+ store.setSpanStatus(msg.spanId, msg.status, msg.statusMessage)
354
+ break
355
+ case 'trace-span-attrs':
356
+ store.updateAttributes(msg.spanId, msg.attrs)
357
+ break
358
+ case 'trace-span-event':
359
+ store.addEvent(msg.event)
360
+ break
361
+ case 'trace-error':
362
+ store.insertError(msg.error)
363
+ break
364
+ }
365
+ } catch (err) {
366
+ console.error('[lopata] trace store write failed (ignored):', err)
367
+ }
368
+ }
369
+
370
+ /** Background `waitUntil` promises still in flight on the worker side. */
371
+ pendingWaitUntil(): number {
372
+ return this._pendingWaitUntil.size
373
+ }
374
+
375
+ /** In-flight scheduled/email handlers and inbound entrypoint RPC/property-get
376
+ * calls (another worker calling `env.THIS.method()`). Reload drain consults
377
+ * this so a cron/email handler or inbound RPC firing just before reload isn't
378
+ * force-terminated mid-execution. */
379
+ pendingHandlerWork(): number {
380
+ return this._pendingHandlers.size + this._pendingRpc.size + this._pendingRpcGet.size + this._pendingWorkflowControl.size
381
+ }
382
+
383
+ /** In-flight streamed bodies for the top-level fetch path: response bodies the
384
+ * client is still downloading (`_responseStreams`, incl. SSE) plus request-body
385
+ * pumps still uploading (`_topRequestStreams`). `executeFetch` resolves at the
386
+ * headers, so these aren't reflected by `pendingFetch()` — reload drain must
387
+ * consult them or it cuts off an active download/SSE with zero grace. */
388
+ openStreamCount(): number {
389
+ return this._responseStreams.activeCount() + this._topRequestStreams.activeCount()
390
+ }
391
+
392
+ /** In-flight top-level `executeFetch` calls. A cross-worker service-binding
393
+ * fetch (`env.OTHER.fetch()`) lands here directly via the registry without
394
+ * touching the target `Generation.activeRequests`, so the drain must consult
395
+ * this too — otherwise reloading the target severs the request mid-flight. */
396
+ pendingFetch(): number {
397
+ return this._pending.size
398
+ }
399
+
400
+ private _resolveBinding = (target: BindingTarget): Record<string, unknown> => {
401
+ const binding = this._mainEnv[target.binding]
402
+ if (binding == null) {
403
+ throw new Error(`Binding "${target.binding}" not found on main env`)
404
+ }
405
+ if (target.instanceId === undefined) {
406
+ return binding as Record<string, unknown>
407
+ }
408
+ const get = (binding as Record<string, unknown>).get
409
+ if (typeof get !== 'function') {
410
+ throw new Error(`Binding "${target.binding}" cannot resolve instance "${target.instanceId}" — no .get() method`)
411
+ }
412
+ const doId = new DurableObjectIdImpl(target.instanceId, target.instanceName)
413
+ return (get as (id: DurableObjectIdImpl) => Record<string, unknown>).call(binding, doId)
414
+ }
415
+
416
+ private _pumpTopRequestBody(streamId: number, body: ReadableStream<Uint8Array>): void {
417
+ type Chunk = Extract<WorkerCommand, { type: 'req-stream-chunk' }>
418
+ type End = Extract<WorkerCommand, { type: 'req-stream-end' }>
419
+ type Err = Extract<WorkerCommand, { type: 'req-stream-error' }>
420
+ pumpStream<Chunk, End, Err>(
421
+ streamId,
422
+ body,
423
+ this._topRequestStreams,
424
+ cmd => this._send(cmd),
425
+ {
426
+ chunk: (id, chunk) => ({ type: 'req-stream-chunk', streamId: id, chunk }),
427
+ end: (id) => ({ type: 'req-stream-end', streamId: id }),
428
+ error: (id, error) => ({ type: 'req-stream-error', streamId: id, error }),
429
+ },
430
+ () => !this._disposed,
431
+ STREAM_BACKPRESSURE_WINDOW,
432
+ )
433
+ }
434
+
435
+ /** Resolves when the worker has imported the user module successfully. */
436
+ ready(): Promise<WorkerReadyInfo> {
437
+ return this._ready
438
+ }
439
+
440
+ /**
441
+ * Allocate an id, register a pending promise, post the command. The `build`
442
+ * callback receives the id + active span context (so sub-spans on the worker
443
+ * side link to the caller) and returns the actual `WorkerCommand`.
444
+ */
445
+ private async _sendAndAwait<T>(
446
+ map: Map<number, Pending<T>>,
447
+ build: (id: number, parent: ParentSpanContext | undefined) => WorkerCommand,
448
+ afterPost?: () => void,
449
+ ): Promise<T> {
450
+ if (this._disposed) throw new Error('Worker-thread executor disposed')
451
+ await this._ready
452
+ // `dispose()` may have run during `await this._ready` — `_failAll` already
453
+ // cleared the maps, so registering a fresh pending entry here would post to a
454
+ // terminated Worker (a silent no-op) and the promise would never settle.
455
+ // Re-check after the await (mirrors the DO channel's `_sendCommand`).
456
+ if (this._disposed) throw new Error('Worker-thread executor disposed')
457
+ const active = getActiveContext()
458
+ const parent: ParentSpanContext | undefined = active ? { traceId: active.traceId, spanId: active.spanId } : undefined
459
+ const id = this._nextId++
460
+ return new Promise<T>((resolve, reject) => {
461
+ map.set(id, { resolve, reject })
462
+ try {
463
+ this._send(build(id, parent))
464
+ afterPost?.()
465
+ } catch (e) {
466
+ // A synchronous postMessage throw (DataCloneError on a non-cloneable
467
+ // arg) would otherwise leave the pending entry registered forever,
468
+ // pinning the generation non-idle. Drop it and reject the caller.
469
+ map.delete(id)
470
+ reject(e instanceof Error ? e : new Error(String(e)))
471
+ }
472
+ })
473
+ }
474
+
475
+ async executeFetch(request: Request, props?: Record<string, unknown>): Promise<Response> {
476
+ const shell = serializeRequestShell(request)
477
+ const body = request.body
478
+ const reqStreamId = body ? this._topRequestStreams.allocateId() : undefined
479
+ const req: SerializedRequest = reqStreamId !== undefined
480
+ ? { ...shell, body: null, streamId: reqStreamId }
481
+ : { ...shell, body: null }
482
+ // `afterPost` fires after `_sendAndAwait` posts the 'fetch' command so
483
+ // the worker sees the streamId before any `req-stream-chunk` arrives.
484
+ // (The receiver buffers events for unknown streamIds, but ordering the
485
+ // pump after the post keeps the slow-path off the hot path.)
486
+ // Propagate client disconnect (Bun.serve `request.signal`) to the worker so
487
+ // the rebuilt Request's signal fires for user code listening on it (SSE /
488
+ // long-poll cleanup). The fetch id is known once `build` runs (post-ready).
489
+ let fetchId: number | undefined
490
+ const signal = request.signal
491
+ const onAbort = () => {
492
+ if (fetchId !== undefined && !this._disposed) this._send({ type: 'fetch-abort', id: fetchId })
493
+ }
494
+ const resultPromise = this._sendAndAwait(
495
+ this._pending,
496
+ (id, parent) => {
497
+ fetchId = id
498
+ return { type: 'fetch', id, request: req, parent, props }
499
+ },
500
+ () => {
501
+ // Wire the signal only AFTER the fetch command is posted: a client
502
+ // that already disconnected fires onAbort synchronously, and posting
503
+ // fetch-abort ahead of the fetch command would no-op in the worker
504
+ // (no controller for the id yet) — the rebuilt Request's signal
505
+ // would then never fire. postMessage is FIFO, so abort-after-fetch
506
+ // is ordered.
507
+ if (signal) {
508
+ if (signal.aborted) onAbort()
509
+ else signal.addEventListener('abort', onAbort, { once: true })
510
+ }
511
+ if (body && reqStreamId !== undefined) this._pumpTopRequestBody(reqStreamId, body)
512
+ },
513
+ )
514
+ if (body && reqStreamId !== undefined) {
515
+ // If the handler errors mid-upload, stop the request-body pump so it
516
+ // doesn't keep reading the source into the worker's receiver buffer for a
517
+ // request that already failed. Mirrors the DO-fetch path.
518
+ resultPromise.catch(() => this._topRequestStreams.cancel(reqStreamId))
519
+ }
520
+ const serialized = await resultPromise
521
+
522
+ let response: ResponseWithWebSocket
523
+ if (serialized.streamId !== undefined) {
524
+ // Streamed body — hand Bun.serve a ReadableStream fed by the worker's
525
+ // `stream-chunk` messages, so the response resolves on headers (TTFB
526
+ // preserved) and the body flows incrementally.
527
+ response = new Response(this._responseStreams.open(serialized.streamId), {
528
+ status: serialized.status,
529
+ statusText: serialized.statusText,
530
+ headers: serialized.headers,
531
+ }) as ResponseWithWebSocket
532
+ } else {
533
+ response = deserializeResponse(serialized) as ResponseWithWebSocket
534
+ }
535
+ if (serialized.webSocketId) {
536
+ // Always a fresh guest-side id: the worker registers the response's
537
+ // CFWebSocket on its top-level WS bridge in serializeResponse — even a
538
+ // socket that originally came from a DO/service binding fetch was
539
+ // reconstructed worker-side and re-registered (its env-binding adoption
540
+ // lives on `_envBindingWsBridge`, not here).
541
+ response.webSocket = this._wsBridge.register(serialized.webSocketId)
542
+ }
543
+ return response
544
+ }
545
+
546
+ executeScheduled(cronExpr: string, scheduledTime: number): Promise<HandlerResult> {
547
+ return this._sendAndAwait(this._pendingHandlers, (id, parent) => ({ type: 'scheduled', id, cronExpr, scheduledTime, parent }))
548
+ }
549
+
550
+ executeEntrypointRpc(entrypoint: string | undefined, method: string, args: unknown[], props?: Record<string, unknown>): Promise<unknown> {
551
+ return this._sendAndAwait(this._pendingRpc, (id, parent) => ({ type: 'entrypoint-rpc', id, entrypoint, method, args, props, parent }))
552
+ }
553
+
554
+ executeEntrypointPropertyGet(
555
+ entrypoint: string | undefined,
556
+ property: string,
557
+ props?: Record<string, unknown>,
558
+ ): Promise<{ kind: 'value'; value: unknown } | { kind: 'function' }> {
559
+ return this._sendAndAwait(this._pendingRpcGet, (id, parent) => ({
560
+ type: 'entrypoint-rpc-get',
561
+ id,
562
+ entrypoint,
563
+ property,
564
+ props,
565
+ parent,
566
+ }))
567
+ }
568
+
569
+ executeEmail(messageId: string, from: string, to: string, raw: Uint8Array): Promise<HandlerResult> {
570
+ return this._sendAndAwait(this._pendingHandlers, (id, parent) => ({ type: 'email', id, messageId, from, to, raw, parent }))
571
+ }
572
+
573
+ /** Run a dashboard workflow control op against the live worker-side binding. */
574
+ executeWorkflowControl(binding: string, op: WorkflowControlOp): Promise<WorkflowControlResult> {
575
+ return this._sendAndAwait(this._pendingWorkflowControl, (id, parent) => ({ type: 'workflow-control', id, binding, op, parent }))
576
+ }
577
+
578
+ /** Tell the worker to stop its queue consumers from claiming new messages
579
+ * (reload drain). In-flight batches finish and are awaited via wait-until. */
580
+ stopQueueConsumers(): void {
581
+ if (this._disposed) return
582
+ try {
583
+ this._send({ type: 'stop-queue-consumers' })
584
+ } catch {
585
+ // Worker already gone — nothing to stop.
586
+ }
587
+ }
588
+
589
+ dispose(): void {
590
+ if (this._disposed) return
591
+ this._disposed = true
592
+ this._worker.terminate()
593
+ this._failAll(new Error('Worker thread terminated'))
594
+ }
595
+ }