lopata 0.18.4 → 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 +12 -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 +87 -33
  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 +18 -0
  83. package/src/tracing/span.ts +83 -57
  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
@@ -6,42 +6,47 @@
6
6
  */
7
7
 
8
8
  import { dirname, resolve } from 'node:path'
9
+ import type { WranglerConfig } from '../config'
10
+ import type {
11
+ DOCommand,
12
+ DOMainMessage,
13
+ DoReqStreamChunk,
14
+ DoReqStreamEnd,
15
+ DoReqStreamError,
16
+ DOResult,
17
+ DOWorkerMessage,
18
+ } from '../worker-thread/do-protocol'
19
+ import type { BindingTarget, RpcReply } from '../worker-thread/protocol'
20
+ import { deserializeError } from '../worker-thread/protocol'
21
+ import { RpcHostChannel } from '../worker-thread/rpc-shared'
22
+ import { OutboundStreamRegistry, pumpStream, STREAM_BACKPRESSURE_WINDOW, StreamReceiver } from '../worker-thread/stream-shared'
23
+ import { WsHostBridge } from '../worker-thread/ws-bridge-shared'
24
+ import { registerContainer, unregisterContainer } from './container-cleanup'
9
25
  import type { DOExecutor, DOExecutorFactory, ExecutorConfig } from './do-executor'
10
- import type { WsBridgeOutbound } from './do-websocket-bridge'
11
-
12
- // --- Message protocol ---
13
-
14
- /** Commands sent from main thread to worker */
15
- export type DOCommand =
16
- | { type: 'fetch'; url: string; method: string; headers: [string, string][]; body: ArrayBuffer | null }
17
- | { type: 'rpc-call'; method: string; args: unknown[] }
18
- | { type: 'rpc-get'; prop: string }
19
- | { type: 'alarm'; retryCount: number }
20
- | { type: 'ws-create'; wsId: string }
21
-
22
- /** Results returned from worker to main thread */
23
- export type DOResult =
24
- | { type: 'fetch'; status: number; statusText: string; headers: [string, string][]; body: ArrayBuffer | null }
25
- | { type: 'rpc-call'; value: unknown }
26
- | { type: 'rpc-get'; value: unknown }
27
- | { type: 'alarm' }
28
- | { type: 'ws-created'; wsId: string }
29
- | { type: 'error'; message: string; stack?: string; name?: string }
30
-
31
- /** Messages from main thread → worker */
32
- export type DOWorkerMessage =
33
- | { type: 'command'; id: number; command: DOCommand }
34
- | { type: 'ws-message'; wsId: string; data: string | ArrayBuffer }
35
- | { type: 'ws-close'; wsId: string; code: number; reason: string; wasClean: boolean }
36
- | { type: 'ws-error'; wsId: string }
37
-
38
- /** Messages from worker → main thread */
39
- export type DOMainMessage =
40
- | { type: 'need-init' }
41
- | { type: 'ready' }
42
- | { type: 'result'; id: number; result: DOResult }
43
- | { type: 'alarm-set'; time: number | null }
44
- | { type: 'ws-bridge'; payload: WsBridgeOutbound }
26
+ import { DurableObjectIdImpl } from './durable-object'
27
+ import { CFWebSocket, type ResponseWithWebSocket } from './websocket-pair'
28
+
29
+ // The DO-channel message protocol (DOCommand / DOResult / DOWorkerMessage /
30
+ // DOMainMessage / Do*Stream* / WsAcceptSignal) lives in
31
+ // `worker-thread/do-protocol.ts`, beside the user-worker channel's `protocol.ts`.
32
+ // Re-exported here so existing `import from './do-executor-worker'` sites keep
33
+ // working.
34
+ export type {
35
+ DOCommand,
36
+ DOMainMessage,
37
+ DoReqStreamAck,
38
+ DoReqStreamCancel,
39
+ DoReqStreamChunk,
40
+ DoReqStreamEnd,
41
+ DoReqStreamError,
42
+ DOResult,
43
+ DoStreamAck,
44
+ DoStreamCancel,
45
+ DoStreamChunk,
46
+ DoStreamEnd,
47
+ DoStreamError,
48
+ DOWorkerMessage,
49
+ } from '../worker-thread/do-protocol'
45
50
 
46
51
  // --- Pending command tracking ---
47
52
 
@@ -59,13 +64,75 @@ export class WorkerExecutor implements DOExecutor {
59
64
  private _worker: Worker | null = null
60
65
  private _ready: Promise<void> | null = null
61
66
  private _readyResolve: (() => void) | null = null
67
+ private _readyReject: ((err: Error) => void) | null = null
62
68
  private _pending = new Map<number, PendingCommand>()
63
69
  private _nextId = 1
64
70
  private _disposed = false
71
+ /** Swallowed copy of `_priorDisposal`, created lazily on the first command.
72
+ * Shared so CONCURRENT first commands all await the prior container's
73
+ * teardown — a boolean gate would let the second racer skip straight to
74
+ * `docker run` while the old container's `docker rm` is still in flight. */
75
+ private _priorDisposalGate: Promise<void> | null = null
65
76
  private _inFlightCount = 0
77
+ /** Mirrors of the DO worker's `state` lifecycle, fed by `do-state` signals. */
66
78
  private _blocked = false
67
- private _wsCount = 0
68
- private _bridgedWebSockets = new Map<string, WebSocket>()
79
+ private _aborted = false
80
+ /**
81
+ * `wsId`s of every open WebSocket this DO's fetch handler returned — both
82
+ * hibernation (`state.acceptWebSocket`) and plain (`new WebSocketPair`) ones.
83
+ * Each pins the executor against the idle reaper until it closes. Added when
84
+ * `executeFetch` registers the bridge socket; removed on the `fetch-ws-close-out`
85
+ * dispatch (server-side close) and the `_fetchBridge` clientClose envelope
86
+ * (client disconnect) — both delete from the same set so neither double-counts.
87
+ */
88
+ private _openFetchWsIds = new Set<string>()
89
+ /**
90
+ * Main-side WS bridge for `Response{webSocket}` returned by the DO worker's
91
+ * fetch handler. The bridge's peer forwards outgoing bytes (Bun.serve → here)
92
+ * down to the DO worker as `fetch-ws-incoming` / `fetch-ws-close-in`.
93
+ */
94
+ private _fetchBridge: WsHostBridge<DOWorkerMessage> | null = null
95
+ /**
96
+ * Main-side WS bridge for upstream CFWebSockets adopted from env-binding
97
+ * fetches the DO worker initiated (e.g. `this.env.SVC.fetch('/ws')` returning
98
+ * a 101). Direction is opposite of `_fetchBridge`: we already hold the real
99
+ * CFWebSocket from main's `resolveBinding(...).fetch()`, the DO worker side
100
+ * holds the synthetic user-facing peer.
101
+ */
102
+ private _envBindingWsBridge: WsHostBridge<DOWorkerMessage> | null = null
103
+ /** Main-side host of the unified cross-thread RPC channel — env-binding
104
+ * call/get/fetch from inside the DO worker (`this.env.SVC.…`), with
105
+ * response/request-body streaming. Shared with the user-worker executor;
106
+ * channel-specifics injected as hooks. */
107
+ private _rpcChannel = new RpcHostChannel({
108
+ resolveBinding: (target) => this._resolveBinding(target),
109
+ post: (reply) => {
110
+ this._worker?.postMessage(reply satisfies RpcReply)
111
+ },
112
+ isAlive: () => !this._disposed && this._worker !== null,
113
+ decorateResponse: (response, serialized) => {
114
+ const ws = (response as ResponseWithWebSocket).webSocket
115
+ if (response.status === 101 && ws instanceof CFWebSocket && this._envBindingWsBridge) {
116
+ serialized.webSocketId = this._envBindingWsBridge.adoptExisting(ws, { bridgeEvents: true })
117
+ }
118
+ },
119
+ })
120
+ /** Reconstruction of streamed DO-fetch response bodies (DO worker → main). */
121
+ private _fetchStreams = new StreamReceiver(
122
+ (streamId) => {
123
+ this._worker?.postMessage({ type: 'do-stream-cancel', streamId } satisfies DOWorkerMessage)
124
+ },
125
+ {
126
+ window: STREAM_BACKPRESSURE_WINDOW,
127
+ onCredit: (streamId) => {
128
+ this._worker?.postMessage({ type: 'do-stream-ack', streamId } satisfies DOWorkerMessage)
129
+ },
130
+ },
131
+ )
132
+ /** Outbound request-body pumps for DO-fetch (main → DO worker). A
133
+ * `do-req-stream-cancel` from the DO worker (instance code cancelled the
134
+ * body) stops the source reader. */
135
+ private _fetchRequestStreams = new OutboundStreamRegistry()
69
136
 
70
137
  constructor(config: ExecutorConfig) {
71
138
  this._config = config
@@ -78,12 +145,37 @@ export class WorkerExecutor implements DOExecutor {
78
145
  const config = this._config
79
146
  const worker = new Worker(WORKER_ENTRY_PATH)
80
147
 
81
- this._ready = new Promise<void>((resolve) => {
148
+ this._ready = new Promise<void>((resolve, reject) => {
82
149
  this._readyResolve = resolve
150
+ this._readyReject = reject
151
+ })
152
+
153
+ this._fetchBridge = new WsHostBridge<DOWorkerMessage>(msg => worker.postMessage(msg), {
154
+ clientMessage: (wsId, data) => ({ type: 'fetch-ws-incoming', wsId, data }),
155
+ clientClose: (wsId, code, reason, wasClean) => {
156
+ // A real client disconnect ends the WS regardless of whether user code
157
+ // calls `ws.close()` in its `webSocketClose` handler (that call is
158
+ // optional in CF). Drop the pin here so it doesn't leak and keep the DO
159
+ // worker alive past eviction. The matching `fetch-ws-close-out` (when
160
+ // the server peer also closes) then finds the id already gone and is a
161
+ // no-op, so this never double-counts.
162
+ this._openFetchWsIds.delete(wsId)
163
+ return { type: 'fetch-ws-close-in', wsId, code, reason, wasClean }
164
+ },
165
+ })
166
+ this._envBindingWsBridge = new WsHostBridge<DOWorkerMessage>(msg => worker.postMessage(msg), {
167
+ clientMessage: (wsId, data) => ({ type: 'env-ws-incoming', wsId, data }),
168
+ clientClose: (wsId, code, reason, wasClean) => ({ type: 'env-ws-close-in', wsId, code, reason, wasClean }),
83
169
  })
84
170
 
85
171
  worker.onmessage = (event: MessageEvent<DOMainMessage>) => {
172
+ // Drop late messages after dispose/onerror — the shared RPC dispatchers
173
+ // would otherwise commit side effects (KV write, R2 put, queue send)
174
+ // from a dying generation before their reply gets filtered by
175
+ // `hooks.isAlive()`. Mirrors `WorkerThreadExecutor._handleMessage`.
176
+ if (this._disposed) return
86
177
  const msg = event.data
178
+ if (this._rpcChannel.handle(msg)) return
87
179
 
88
180
  switch (msg.type) {
89
181
  case 'need-init':
@@ -93,6 +185,9 @@ export class WorkerExecutor implements DOExecutor {
93
185
  config: {
94
186
  modulePath: this._resolveModulePath(),
95
187
  configPath: this._resolveConfigPath(),
188
+ // Main's parsed, env-overridden config — the DO worker uses this
189
+ // instead of re-loading from configPath WITHOUT the --env overrides.
190
+ wranglerConfig: this._config._wranglerConfig,
96
191
  dataDir: this._resolveDataDir(),
97
192
  namespaceName: config.namespaceName,
98
193
  idStr: config.id.toString(),
@@ -107,16 +202,12 @@ export class WorkerExecutor implements DOExecutor {
107
202
 
108
203
  case 'result': {
109
204
  if (msg.id === -1 && msg.result.type === 'error') {
110
- // Worker init error — reject all pending and the ready promise
111
- const error = new Error(msg.result.message)
112
- if (msg.result.stack) error.stack = msg.result.stack
113
- for (const [, pending] of this._pending) {
114
- pending.reject(error)
115
- }
116
- this._pending.clear()
117
- // Resolve ready so _sendCommand doesn't hang forever
118
- this._readyResolve?.()
119
- this._disposed = true
205
+ // Worker init error — tear down with the real cause so awaiting
206
+ // callers surface it instead of a generic "Worker terminated"
207
+ // after `_disposed` flips. The failed thread would otherwise
208
+ // linger (SQLite handle, container/health timers) until the 30s
209
+ // idle reaper or next access disposes it.
210
+ this._teardown(deserializeError(msg.result.error))
120
211
  break
121
212
  }
122
213
  const pending = this._pending.get(msg.id)
@@ -132,19 +223,71 @@ export class WorkerExecutor implements DOExecutor {
132
223
  config.onAlarmSet?.(msg.time)
133
224
  break
134
225
 
135
- case 'ws-bridge':
136
- this._handleWsBridge(msg.payload)
226
+ case 'do-state':
227
+ // Mirror the DO instance's abort/block lifecycle so the idle reaper
228
+ // evicts an aborted instance (→ recreated fresh) and never evicts
229
+ // mid-blockConcurrencyWhile.
230
+ this._aborted = msg.aborted
231
+ this._blocked = msg.blocked
232
+ break
233
+
234
+ case 'fetch-ws-outgoing':
235
+ this._fetchBridge?.deliverRemoteMessage(msg.wsId, msg.data)
236
+ break
237
+
238
+ case 'fetch-ws-close-out':
239
+ this._fetchBridge?.deliverRemoteClose(msg.wsId, msg.code, msg.reason, msg.wasClean)
240
+ this._openFetchWsIds.delete(msg.wsId)
241
+ break
242
+
243
+ case 'env-ws-outgoing':
244
+ this._envBindingWsBridge?.deliverRemoteMessage(msg.wsId, msg.data)
245
+ break
246
+
247
+ case 'env-ws-close-out':
248
+ this._envBindingWsBridge?.deliverRemoteClose(msg.wsId, msg.code, msg.reason, msg.wasClean)
249
+ break
250
+
251
+ case 'do-stream-chunk':
252
+ this._fetchStreams.push(msg.streamId, msg.chunk)
253
+ break
254
+
255
+ case 'do-stream-end':
256
+ this._fetchStreams.end(msg.streamId)
257
+ break
258
+
259
+ case 'do-stream-error':
260
+ this._fetchStreams.error(msg.streamId, deserializeError(msg.error))
261
+ break
262
+
263
+ case 'do-req-stream-cancel':
264
+ this._fetchRequestStreams.cancel(msg.streamId)
265
+ break
266
+ case 'do-req-stream-ack':
267
+ this._fetchRequestStreams.grantCredit(msg.streamId)
268
+ break
269
+
270
+ case 'container-registered':
271
+ registerContainer(msg.name)
272
+ break
273
+
274
+ case 'container-removed':
275
+ unregisterContainer(msg.name)
137
276
  break
138
277
  }
139
278
  }
140
279
 
141
- worker.onerror = (event) => {
142
- // Reject all pending commands
143
- const error = new Error(`Worker error: ${event.message}`)
144
- for (const [id, pending] of this._pending) {
145
- pending.reject(error)
146
- }
147
- this._pending.clear()
280
+ worker.onerror = (event: ErrorEvent) => {
281
+ if (this._disposed) return
282
+ // Mark dead so the namespace drops this executor and recreates a fresh
283
+ // one on next access (see `isDisposed()` + `_getOrCreateExecutor`),
284
+ // instead of posting to a terminated Worker (which would hang). An
285
+ // uncaught worker error does NOT end the thread (Web Worker semantics),
286
+ // so `_teardown` terminating it here is what prevents the leak (SQLite
287
+ // handle, container/health timers, WS bridges). Mirrors
288
+ // WorkerThreadExecutor's onerror → _failAll.
289
+ const detail = event.error?.stack ?? event.message ?? 'unknown'
290
+ this._teardown(new Error(`Worker error: ${detail}`))
148
291
  }
149
292
 
150
293
  this._worker = worker
@@ -152,19 +295,27 @@ export class WorkerExecutor implements DOExecutor {
152
295
  }
153
296
 
154
297
  private _resolveModulePath(): string {
155
- return (this._config as any)._modulePath ?? ''
298
+ return this._config._modulePath ?? ''
156
299
  }
157
300
 
158
301
  private _resolveConfigPath(): string {
159
- return (this._config as any)._configPath ?? ''
302
+ return this._config._configPath ?? ''
160
303
  }
161
304
 
162
305
  private _resolveDataDir(): string {
163
306
  return this._config.dataDir ?? ''
164
307
  }
165
308
 
166
- private async _sendCommand(command: DOCommand): Promise<DOResult> {
309
+ private async _sendCommand(command: DOCommand, afterPost?: () => void): Promise<DOResult> {
167
310
  const worker = this._ensureWorker()
311
+ // Wait for the prior executor's container teardown (docker rm) before any
312
+ // command — a container DO's first fetch triggers `docker run` for the
313
+ // same name, which would otherwise race the teardown. Every command awaits
314
+ // the shared gate (a settled promise after the first), so concurrent first
315
+ // commands serialize too. No-op for non-container DOs (`_priorDisposal`
316
+ // undefined).
317
+ this._priorDisposalGate ??= this._config._priorDisposal?.catch(() => {}) ?? Promise.resolve()
318
+ await this._priorDisposalGate
168
319
  await this._ready
169
320
 
170
321
  if (this._disposed) throw new Error('Worker terminated')
@@ -177,10 +328,7 @@ export class WorkerExecutor implements DOExecutor {
177
328
  resolve: (result) => {
178
329
  this._inFlightCount--
179
330
  if (result.type === 'error') {
180
- const err = new Error(result.message)
181
- err.name = result.name ?? 'Error'
182
- if (result.stack) err.stack = result.stack
183
- reject(err)
331
+ reject(deserializeError(result.error))
184
332
  } else {
185
333
  resolve(result)
186
334
  }
@@ -190,98 +338,126 @@ export class WorkerExecutor implements DOExecutor {
190
338
  reject(err)
191
339
  },
192
340
  })
193
- worker.postMessage({ type: 'command', id, command } satisfies DOWorkerMessage)
194
- })
195
- }
196
-
197
- private _handleWsBridge(payload: WsBridgeOutbound): void {
198
- switch (payload.type) {
199
- case 'ws-send': {
200
- const ws = this._bridgedWebSockets.get(payload.wsId)
201
- if (ws && ws.readyState === 1) {
202
- ws.send(payload.data)
203
- }
204
- break
205
- }
206
- case 'ws-close': {
207
- const ws = this._bridgedWebSockets.get(payload.wsId)
208
- if (ws) {
209
- ws.close(payload.code, payload.reason)
210
- this._bridgedWebSockets.delete(payload.wsId)
211
- this._wsCount--
212
- }
213
- break
341
+ try {
342
+ worker.postMessage({ type: 'command', id, command } satisfies DOWorkerMessage)
343
+ // Runs only AFTER the command is posted, which is after `await this._ready`
344
+ // — so the DO worker's full message handler is installed (it's set right
345
+ // before the worker posts `ready`). Starting the request-body pump here
346
+ // (not synchronously in executeFetch) means its chunks can't race ahead
347
+ // of `ready` and get dropped by the init-phase handler.
348
+ afterPost?.()
349
+ } catch (e) {
350
+ // A synchronous postMessage throw (DataCloneError on non-cloneable
351
+ // args) would otherwise leak the pending entry and the `_inFlightCount`
352
+ // bump, leaving isActive() true forever and exempting the executor from
353
+ // idle eviction (thread + DB connection pinned until reload).
354
+ this._pending.delete(id)
355
+ this._inFlightCount--
356
+ reject(e instanceof Error ? e : new Error(String(e)))
214
357
  }
215
- case 'ws-accept': {
216
- // WebSocket was accepted by the DO — increment count
217
- this._wsCount++
218
- break
219
- }
220
- }
221
- }
222
-
223
- /** Register a real WebSocket for bridging to the worker */
224
- _bridgeWebSocket(wsId: string, ws: WebSocket): void {
225
- this._bridgedWebSockets.set(wsId, ws)
226
-
227
- // Forward events from real WS to worker
228
- ws.addEventListener('message', (event: MessageEvent) => {
229
- this._worker?.postMessage(
230
- {
231
- type: 'ws-message',
232
- wsId,
233
- data: event.data,
234
- } satisfies DOWorkerMessage,
235
- )
236
- })
237
-
238
- ws.addEventListener('close', (event: CloseEvent) => {
239
- this._worker?.postMessage(
240
- {
241
- type: 'ws-close',
242
- wsId,
243
- code: event.code,
244
- reason: event.reason,
245
- wasClean: event.wasClean,
246
- } satisfies DOWorkerMessage,
247
- )
248
- this._bridgedWebSockets.delete(wsId)
249
- })
250
-
251
- ws.addEventListener('error', () => {
252
- this._worker?.postMessage(
253
- {
254
- type: 'ws-error',
255
- wsId,
256
- } satisfies DOWorkerMessage,
257
- )
258
358
  })
259
359
  }
260
360
 
261
361
  // --- DOExecutor interface ---
262
362
 
263
363
  async executeFetch(request: Request): Promise<Response> {
264
- // Serialize Request
265
364
  const headers: [string, string][] = []
266
365
  request.headers.forEach((v, k) => headers.push([k, v]))
267
- const body = request.body ? await request.arrayBuffer() : null
366
+ const body = request.body
367
+ const streamId = body ? this._fetchRequestStreams.allocateId() : undefined
368
+
369
+ // The request-body pump starts in `afterPost` — i.e. AFTER `_sendCommand`
370
+ // has awaited `_ready` and posted the 'fetch' command. On a COLD worker the
371
+ // init-phase handler drops every message except `init`, so starting the pump
372
+ // synchronously here (as before) dropped the head chunks — and a dropped
373
+ // `end` left `await request.text()` hanging forever, pinning the executor.
374
+ let pumpStarted = false
375
+ const resultPromise = this._sendCommand(
376
+ {
377
+ type: 'fetch',
378
+ url: request.url,
379
+ method: request.method,
380
+ headers,
381
+ body: null,
382
+ streamId,
383
+ },
384
+ body && streamId !== undefined
385
+ ? () => {
386
+ pumpStarted = true
387
+ this._pumpFetchRequestBody(streamId, body)
388
+ }
389
+ : undefined,
390
+ )
391
+
392
+ if (body && streamId !== undefined) {
393
+ // If `resultPromise` rejects (worker disposed before `_ready`, or errored
394
+ // mid-flight), release the source body: cancel via the registry if the
395
+ // pump started (the reader is locked there), else cancel the body itself
396
+ // so it isn't left dangling unread.
397
+ resultPromise.catch(() => {
398
+ if (pumpStarted) this._fetchRequestStreams.cancel(streamId)
399
+ else body.cancel().catch(() => {})
400
+ })
401
+ }
268
402
 
269
- const result = await this._sendCommand({
270
- type: 'fetch',
271
- url: request.url,
272
- method: request.method,
273
- headers,
274
- body,
275
- })
403
+ const result = await resultPromise
276
404
 
277
405
  if (result.type !== 'fetch') throw new Error('Unexpected result type')
278
406
 
279
- // Reconstruct Response
280
- return new Response(result.body, {
407
+ const init: ResponseInit & { webSocket?: CFWebSocket } = {
281
408
  status: result.status,
282
409
  statusText: result.statusText,
283
410
  headers: result.headers,
284
- })
411
+ }
412
+
413
+ if (result.fetchWebSocketId) {
414
+ if (!this._fetchBridge) throw new Error('Fetch WS bridge not initialised — worker must be alive')
415
+ init.webSocket = this._fetchBridge.register(result.fetchWebSocketId)
416
+ // Pin the executor for as long as the WS is open — hibernation AND plain
417
+ // sockets alike (a chatty non-hibernation WS bypasses _sendCommand, so
418
+ // without this the idle reaper would evict it and 1012 the client).
419
+ this._openFetchWsIds.add(result.fetchWebSocketId)
420
+ }
421
+
422
+ if (result.streamId !== undefined) {
423
+ const stream = this._fetchStreams.open(result.streamId)
424
+ return new Response(stream, init)
425
+ }
426
+
427
+ return new Response(result.body, init)
428
+ }
429
+
430
+ private _pumpFetchRequestBody(streamId: number, body: ReadableStream<Uint8Array>): void {
431
+ pumpStream<DoReqStreamChunk, DoReqStreamEnd, DoReqStreamError>(
432
+ streamId,
433
+ body,
434
+ this._fetchRequestStreams,
435
+ msg => this._worker?.postMessage(msg satisfies DOWorkerMessage),
436
+ {
437
+ chunk: (id, chunk) => ({ type: 'do-req-stream-chunk', streamId: id, chunk }),
438
+ end: (id) => ({ type: 'do-req-stream-end', streamId: id }),
439
+ error: (id, error) => ({ type: 'do-req-stream-error', streamId: id, error }),
440
+ },
441
+ () => !this._disposed,
442
+ STREAM_BACKPRESSURE_WINDOW,
443
+ )
444
+ }
445
+
446
+ /**
447
+ * Resolve a binding from main's env. Mirrors the user-worker channel:
448
+ * when `target.instanceId` is set, route through the namespace's `.get()`
449
+ * so DO-stub redirection works (cross-DO and self-DO access via env-RPC).
450
+ */
451
+ private _resolveBinding = (target: BindingTarget): Record<string, unknown> => {
452
+ const binding = this._config.env[target.binding] as Record<string, unknown> | undefined
453
+ if (!binding) throw new Error(`Binding "${target.binding}" not found on main env`)
454
+ if (target.instanceId === undefined) return binding
455
+ const get = binding.get
456
+ if (typeof get !== 'function') {
457
+ throw new Error(`Binding "${target.binding}" cannot resolve instance "${target.instanceId}" — no .get() method`)
458
+ }
459
+ const doId = new DurableObjectIdImpl(target.instanceId, target.instanceName)
460
+ return (get as (id: DurableObjectIdImpl) => Record<string, unknown>).call(binding, doId)
285
461
  }
286
462
 
287
463
  async executeRpc(method: string, args: unknown[]): Promise<unknown> {
@@ -303,7 +479,7 @@ export class WorkerExecutor implements DOExecutor {
303
479
 
304
480
  if (result.type !== 'rpc-get') throw new Error('Unexpected result type')
305
481
  // Functions can't cross the boundary — return a callable stub
306
- if (result.value === '__function__') {
482
+ if (result.kind === 'function') {
307
483
  return (...args: unknown[]) => this.executeRpc(prop, args)
308
484
  }
309
485
  return result.value
@@ -315,14 +491,17 @@ export class WorkerExecutor implements DOExecutor {
315
491
  retryCount,
316
492
  })
317
493
  if (result.type === 'error') {
318
- const err = new Error(result.message)
319
- if (result.stack) err.stack = result.stack
320
- throw err
494
+ throw deserializeError(result.error)
321
495
  }
322
496
  }
323
497
 
324
498
  isActive(): boolean {
325
- return this._inFlightCount > 0
499
+ // Open body streams keep the instance active: the fetch promise settles
500
+ // at response HEADERS, so a long-lived body (SSE, slow download) would
501
+ // otherwise look idle and get evicted mid-stream — dispose() error()s
502
+ // the body the caller is still reading. Mirrors the top-level
503
+ // generation drain's openStreamCount() guard.
504
+ return this._inFlightCount > 0 || this._fetchStreams.activeCount() > 0 || this._fetchRequestStreams.activeCount() > 0
326
505
  }
327
506
 
328
507
  isBlocked(): boolean {
@@ -330,26 +509,70 @@ export class WorkerExecutor implements DOExecutor {
330
509
  }
331
510
 
332
511
  activeWebSocketCount(): number {
333
- return this._wsCount
512
+ return this._openFetchWsIds.size
334
513
  }
335
514
 
336
515
  isAborted(): boolean {
337
- return false // Worker-thread DOs don't support abort
516
+ return this._aborted
517
+ }
518
+
519
+ isDisposed(): boolean {
520
+ return this._disposed
521
+ }
522
+
523
+ /**
524
+ * Ask the DO worker to tear down its Docker container before we terminate the
525
+ * thread. terminate() kills the worker's activity/health timers but leaves the
526
+ * Docker process running — mirrors `InProcessExecutor.dispose()`'s
527
+ * `containerRuntime.cleanup()`. Bounded so a hung `docker rm` can't block
528
+ * reload; the thread is terminated right after regardless.
529
+ */
530
+ private _cleanupContainer(): Promise<void> {
531
+ let timer: ReturnType<typeof setTimeout>
532
+ const timeout = new Promise<void>((res) => {
533
+ timer = setTimeout(res, 3000)
534
+ })
535
+ return Promise.race([
536
+ this._sendCommand({ type: 'cleanup' }).then(() => {}, () => {}),
537
+ timeout,
538
+ ]).finally(() => clearTimeout(timer))
338
539
  }
339
540
 
340
541
  async dispose(): Promise<void> {
542
+ // Container DOs: stop the Docker container before terminating the thread.
543
+ // Non-container DOs skip the round-trip so reload stays fast.
544
+ if (!this._disposed && this._worker && this._config.containerConfig) {
545
+ try {
546
+ await this._cleanupContainer()
547
+ } catch {}
548
+ }
549
+ this._teardown(new Error('Worker terminated'))
550
+ }
551
+
552
+ /**
553
+ * The one teardown sequence — shared by `dispose()` (planned), `onerror`
554
+ * (worker crashed) and the init-error path, so the three can't drift: mark
555
+ * disposed, terminate the thread, reject every pending command and the ready
556
+ * promise with `error`, and dispose every bridge/stream/rpc registry.
557
+ * Idempotent. Counterpart of `WorkerThreadExecutor._failAll`.
558
+ */
559
+ private _teardown(error: Error): void {
341
560
  this._disposed = true
342
561
  if (this._worker) {
343
562
  this._worker.terminate()
344
563
  this._worker = null
345
564
  }
346
- // Reject all pending commands
347
- const error = new Error('Worker terminated')
565
+ this._readyReject?.(error)
348
566
  for (const [, pending] of this._pending) {
349
567
  pending.reject(error)
350
568
  }
351
569
  this._pending.clear()
352
- this._bridgedWebSockets.clear()
570
+ this._openFetchWsIds.clear()
571
+ this._fetchBridge?.disposeAll()
572
+ this._envBindingWsBridge?.disposeAll()
573
+ this._rpcChannel.disposeAll(error)
574
+ this._fetchStreams.disposeAll(error)
575
+ this._fetchRequestStreams.disposeAll()
353
576
  }
354
577
  }
355
578
 
@@ -358,21 +581,26 @@ export class WorkerExecutor implements DOExecutor {
358
581
  export class WorkerExecutorFactory implements DOExecutorFactory {
359
582
  private _modulePath?: string
360
583
  private _configPath?: string
584
+ private _wranglerConfig?: WranglerConfig
361
585
 
362
586
  /**
363
- * Set the module and config paths for all executors created by this factory.
364
- * Called by the generation manager after loading config.
587
+ * Set the module + config paths and the parsed env-overridden config for all
588
+ * executors created by this factory. Called by the generation manager after
589
+ * loading config.
365
590
  */
366
- configure(modulePath: string, configPath: string): void {
591
+ configure(modulePath: string, configPath: string, wranglerConfig?: WranglerConfig): void {
367
592
  this._modulePath = modulePath
368
593
  this._configPath = configPath
594
+ this._wranglerConfig = wranglerConfig
369
595
  }
370
596
 
371
597
  create(config: ExecutorConfig): DOExecutor {
372
- // Attach paths to the config for the executor to use
373
- const extendedConfig = config as any
374
- extendedConfig._modulePath = this._modulePath ?? ''
375
- extendedConfig._configPath = this._configPath ?? ''
376
- return new WorkerExecutor(extendedConfig)
598
+ // Attach paths + parsed config to the config for the executor to use
599
+ return new WorkerExecutor({
600
+ ...config,
601
+ _modulePath: this._modulePath ?? '',
602
+ _configPath: this._configPath ?? '',
603
+ _wranglerConfig: this._wranglerConfig,
604
+ })
377
605
  }
378
606
  }