lopata 0.18.4 → 0.19.1

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 (109) 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-entry.d.ts +1 -1
  9. package/dist/types/bindings/do-worker-env.d.ts +16 -7
  10. package/dist/types/bindings/durable-object.d.ts +66 -4
  11. package/dist/types/bindings/queue.d.ts +4 -1
  12. package/dist/types/bindings/rpc-stub.d.ts +23 -1
  13. package/dist/types/bindings/scheduled.d.ts +13 -3
  14. package/dist/types/bindings/service-binding.d.ts +13 -4
  15. package/dist/types/bindings/static-assets.d.ts +1 -1
  16. package/dist/types/bindings/websocket-pair.d.ts +12 -3
  17. package/dist/types/bindings/workflow.d.ts +29 -0
  18. package/dist/types/config.d.ts +2 -0
  19. package/dist/types/env.d.ts +6 -0
  20. package/dist/types/error-page-render.d.ts +6 -0
  21. package/dist/types/execution-context.d.ts +8 -0
  22. package/dist/types/generation-manager.d.ts +21 -2
  23. package/dist/types/generation.d.ts +16 -21
  24. package/dist/types/import-graph.d.ts +28 -0
  25. package/dist/types/lopata-config.d.ts +3 -3
  26. package/dist/types/plugin.d.ts +4 -1
  27. package/dist/types/rpc-validate.d.ts +9 -0
  28. package/dist/types/setup-globals.d.ts +5 -1
  29. package/dist/types/tracing/context.d.ts +12 -0
  30. package/dist/types/tracing/span.d.ts +21 -3
  31. package/dist/types/tracing/store.d.ts +17 -0
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/dist/types/vite-plugin/index.d.ts +8 -1
  34. package/dist/types/worker-registry.d.ts +11 -4
  35. package/dist/types/worker-thread/do-protocol.d.ts +256 -0
  36. package/dist/types/worker-thread/entry.d.ts +2 -0
  37. package/dist/types/worker-thread/execution-context.d.ts +22 -0
  38. package/dist/types/worker-thread/executor.d.ts +123 -0
  39. package/dist/types/worker-thread/protocol.d.ts +553 -0
  40. package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
  41. package/dist/types/worker-thread/request-clone-fix.d.ts +24 -0
  42. package/dist/types/worker-thread/rpc-client.d.ts +4 -0
  43. package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
  44. package/dist/types/worker-thread/serialize.d.ts +12 -0
  45. package/dist/types/worker-thread/stream-shared.d.ts +147 -0
  46. package/dist/types/worker-thread/thread-env.d.ts +42 -0
  47. package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
  48. package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
  49. package/package.json +1 -1
  50. package/src/api/handlers/containers.ts +2 -1
  51. package/src/api/handlers/workflows.ts +39 -34
  52. package/src/bindings/container-cleanup.ts +125 -0
  53. package/src/bindings/container-docker.ts +49 -34
  54. package/src/bindings/container.ts +24 -9
  55. package/src/bindings/do-executor-inprocess.ts +9 -5
  56. package/src/bindings/do-executor-worker.ts +386 -158
  57. package/src/bindings/do-executor.ts +23 -1
  58. package/src/bindings/do-worker-entry.ts +243 -60
  59. package/src/bindings/do-worker-env.ts +296 -11
  60. package/src/bindings/durable-object.ts +231 -27
  61. package/src/bindings/email.ts +6 -0
  62. package/src/bindings/queue.ts +11 -1
  63. package/src/bindings/rpc-stub.ts +79 -10
  64. package/src/bindings/scheduled.ts +37 -30
  65. package/src/bindings/service-binding.ts +87 -33
  66. package/src/bindings/static-assets.ts +3 -2
  67. package/src/bindings/websocket-pair.ts +19 -3
  68. package/src/bindings/workflow.ts +91 -0
  69. package/src/cli/dev.ts +106 -41
  70. package/src/config.ts +6 -3
  71. package/src/db.ts +1 -0
  72. package/src/env.ts +40 -21
  73. package/src/error-page-render.ts +21 -0
  74. package/src/execution-context.ts +13 -3
  75. package/src/generation-manager.ts +144 -143
  76. package/src/generation.ts +150 -306
  77. package/src/import-graph.ts +140 -0
  78. package/src/lopata-config.ts +3 -3
  79. package/src/plugin.ts +7 -0
  80. package/src/rpc-validate.ts +29 -0
  81. package/src/setup-globals.ts +6 -17
  82. package/src/testing/durable-object.ts +5 -3
  83. package/src/testing/index.ts +8 -4
  84. package/src/tracing/context.ts +18 -0
  85. package/src/tracing/span.ts +83 -57
  86. package/src/tracing/store.ts +41 -3
  87. package/src/virtual-modules.ts +2 -0
  88. package/src/vite-plugin/dev-server-plugin.ts +4 -0
  89. package/src/vite-plugin/index.ts +8 -1
  90. package/src/worker-registry.ts +15 -2
  91. package/src/worker-thread/do-protocol.ts +237 -0
  92. package/src/worker-thread/entry.ts +454 -0
  93. package/src/worker-thread/execution-context.ts +53 -0
  94. package/src/worker-thread/executor.ts +595 -0
  95. package/src/worker-thread/protocol.ts +552 -0
  96. package/src/worker-thread/remote-trace-store.ts +90 -0
  97. package/src/worker-thread/request-clone-fix.ts +69 -0
  98. package/src/worker-thread/rpc-client.ts +5 -0
  99. package/src/worker-thread/rpc-shared.ts +503 -0
  100. package/src/worker-thread/serialize.ts +37 -0
  101. package/src/worker-thread/stream-shared.ts +414 -0
  102. package/src/worker-thread/thread-env.ts +350 -0
  103. package/src/worker-thread/wire-handlers.ts +80 -0
  104. package/src/worker-thread/ws-bridge-shared.ts +482 -0
  105. package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
  106. package/dist/types/module-cache.d.ts +0 -23
  107. package/src/bindings/do-websocket-bridge.ts +0 -79
  108. package/src/module-cache.ts +0 -58
  109. package/src/tracing/global.d.ts +0 -50
@@ -1,4 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite'
2
+ import type { WranglerConfig } from '../config'
2
3
  import type { ContainerConfig } from './container'
3
4
  import type { DurableObjectBase, DurableObjectIdImpl, DurableObjectLimits } from './durable-object'
4
5
 
@@ -7,11 +8,24 @@ export interface ExecutorConfig {
7
8
  db: Database
8
9
  namespaceName: string
9
10
  cls: new(ctx: any, env: unknown) => DurableObjectBase
10
- env: unknown
11
+ env: Record<string, unknown>
11
12
  dataDir?: string
12
13
  limits?: DurableObjectLimits
13
14
  containerConfig?: ContainerConfig
14
15
  onAlarmSet?: (time: number | null) => void
16
+ /** @internal Worker-thread DO executors re-import the user module + config
17
+ * inside their Bun Worker; the factory injects these paths. */
18
+ _modulePath?: string
19
+ _configPath?: string
20
+ /** @internal Main's already-parsed, env-overridden wrangler config. Passed to
21
+ * the DO worker so it doesn't re-load from `_configPath` WITHOUT the `--env`
22
+ * overrides (which the re-parse silently dropped). */
23
+ _wranglerConfig?: WranglerConfig
24
+ /** @internal Disposal of the PRIOR executor for this same id, still in flight
25
+ * (its Docker container is being `docker rm`'d). A container DO awaits this
26
+ * before its first command so a fresh `docker run` for the same name doesn't
27
+ * race the teardown — which could `docker rm -f` the replacement. */
28
+ _priorDisposal?: Promise<void>
15
29
  }
16
30
 
17
31
  export interface DOExecutor {
@@ -39,6 +53,11 @@ export interface DOExecutor {
39
53
  /** Whether the instance has been aborted */
40
54
  isAborted(): boolean
41
55
 
56
+ /** Whether the underlying executor is dead (e.g. its Worker crashed). A
57
+ * disposed executor is unusable — the namespace drops it and recreates a
58
+ * fresh one on next access. Optional: executors that can't die report `false`. */
59
+ isDisposed?(): boolean
60
+
42
61
  /** Hot-swap the DO class and env without disposing (preserves WebSocket connections) */
43
62
  reloadClass?(cls: new(ctx: any, env: unknown) => DurableObjectBase, env: unknown): void
44
63
 
@@ -48,4 +67,7 @@ export interface DOExecutor {
48
67
 
49
68
  export interface DOExecutorFactory {
50
69
  create(config: ExecutorConfig): DOExecutor
70
+ /** Tell the factory where user code lives + the already-parsed env-overridden
71
+ * config (only used by worker-thread DO executors). */
72
+ configure?(modulePath: string, configPath: string, wranglerConfig?: WranglerConfig): void
51
73
  }
@@ -5,6 +5,10 @@
5
5
  * postMessage from the main thread (handshake), then initializes.
6
6
  */
7
7
 
8
+ import '../worker-thread/request-clone-fix' // global Request shim — must load before DO code
9
+ import { dirname } from 'node:path'
10
+ import { deserializeError, serializeError } from '../worker-thread/protocol'
11
+ import { OutboundStreamRegistry, pumpStream, STREAM_BACKPRESSURE_WINDOW, StreamReceiver } from '../worker-thread/stream-shared'
8
12
  import type { DOCommand, DOMainMessage, DOResult, DOWorkerMessage } from './do-executor-worker'
9
13
 
10
14
  declare var self: Worker
@@ -12,6 +16,10 @@ declare var self: Worker
12
16
  interface WorkerConfig {
13
17
  modulePath: string
14
18
  configPath: string
19
+ /** Main's parsed, env-overridden config. When present it's used verbatim so
20
+ * the DO env honors `--env` overrides; absent (e.g. standalone test factory)
21
+ * we fall back to re-loading from `configPath`. */
22
+ wranglerConfig?: import('../config').WranglerConfig
15
23
  dataDir: string
16
24
  namespaceName: string
17
25
  idStr: string
@@ -28,17 +36,12 @@ self.onmessage = async (event: MessageEvent) => {
28
36
  try {
29
37
  await initWorker(workerConfig)
30
38
  } catch (e) {
31
- const error = e instanceof Error ? e : new Error(String(e))
39
+ const message = e instanceof Error ? e.message : String(e)
32
40
  postMessage(
33
41
  {
34
42
  type: 'result',
35
43
  id: -1,
36
- result: {
37
- type: 'error',
38
- message: `Worker init failed: ${error.message}`,
39
- stack: error.stack,
40
- name: error.name,
41
- },
44
+ result: { type: 'error', error: serializeError(new Error(`Worker init failed: ${message}`, { cause: e })) },
42
45
  } satisfies DOMainMessage,
43
46
  )
44
47
  }
@@ -51,25 +54,44 @@ async function initWorker(workerConfig: WorkerConfig) {
51
54
  // Register Bun plugins for cloudflare:workers etc.
52
55
  await import('../plugin')
53
56
 
54
- const { loadConfig } = await import('../config')
55
- const { buildWorkerEnv } = await import('./do-worker-env')
57
+ const { buildWorkerEnv, createDoEnvRpc } = await import('./do-worker-env')
56
58
  const { DurableObjectStateImpl, DurableObjectIdImpl } = await import('./durable-object')
57
- const { BridgeWebSocket } = await import('./do-websocket-bridge')
59
+ const { CFWebSocket } = await import('./websocket-pair')
60
+ const { WsGuestBridge } = await import('../worker-thread/ws-bridge-shared')
61
+ const { ContainerBase, ContainerContext, ContainerRuntime } = await import('./container')
62
+ const { DockerManager } = await import('./container-docker')
63
+ const { containerLabels } = await import('./container-cleanup')
58
64
 
59
- const config = await loadConfig(workerConfig.configPath)
60
- const { db, env, doNamespaces } = buildWorkerEnv(config, workerConfig.dataDir)
65
+ // Prefer main's already-parsed, env-overridden config; only re-load from disk
66
+ // (WITHOUT --env overrides) when no parsed config was threaded through — e.g.
67
+ // the standalone test factory.
68
+ const config = workerConfig.wranglerConfig ?? await (await import('../config')).loadConfig(workerConfig.configPath)
69
+ // Per-worker dir for `.dev.vars`/`.env`/assets — the config file's directory.
70
+ const baseDir = dirname(workerConfig.configPath)
71
+ const envRpc = createDoEnvRpc(msg => postMessage(msg))
72
+
73
+ /**
74
+ * Bridge for `Response{webSocket}` returned from env-binding fetches the
75
+ * DO worker calls (e.g. `this.env.SVC.fetch('/ws')`). The user-facing peer
76
+ * lives here; bytes sent / closes on it travel through this bridge back to
77
+ * the upstream CFWebSocket adopted on main.
78
+ */
79
+ const envWsBridge = new WsGuestBridge<DOMainMessage>(msg => postMessage(msg), {
80
+ remoteMessage: (wsId, data) => ({ type: 'env-ws-outgoing', wsId, data }),
81
+ remoteClose: (wsId, code, reason, wasClean) => ({ type: 'env-ws-close-out', wsId, code, reason, wasClean }),
82
+ })
83
+
84
+ const { db, env } = buildWorkerEnv(config, workerConfig.dataDir, baseDir, envRpc, workerConfig.namespaceName, envWsBridge)
85
+
86
+ // Publish env to `globalEnv` so top-level `import { env } from
87
+ // 'cloudflare:workers'` in the user module sees this DO worker's env (not
88
+ // an empty default). Must happen BEFORE the dynamic import below.
89
+ const { setGlobalEnv } = await import('../env')
90
+ setGlobalEnv(env)
61
91
 
62
92
  // Import user's worker module
63
93
  const workerModule = await import(workerConfig.modulePath)
64
94
 
65
- // Wire DO classes for nested DOs
66
- for (const entry of doNamespaces) {
67
- const cls = workerModule[entry.className]
68
- if (cls) {
69
- entry.namespace._setClass(cls as any, env)
70
- }
71
- }
72
-
73
95
  // Create this DO's instance
74
96
  const id = new DurableObjectIdImpl(workerConfig.idStr, workerConfig.idName)
75
97
  const cls = workerModule[workerConfig.namespaceName]
@@ -78,11 +100,106 @@ async function initWorker(workerConfig: WorkerConfig) {
78
100
  }
79
101
 
80
102
  const state = new DurableObjectStateImpl(id, db, workerConfig.namespaceName, workerConfig.dataDir)
103
+
104
+ // Mirror the instance's abort/block lifecycle to main so the idle reaper
105
+ // evicts an aborted instance (every subsequent command throws — it must be
106
+ // recreated fresh) and never evicts mid-blockConcurrencyWhile. Main only sees
107
+ // commands/WS traffic, so without these signals it can't observe either.
108
+ const postState = () => {
109
+ postMessage({ type: 'do-state', aborted: state._isAborted(), blocked: state._isBlocked() } satisfies DOMainMessage)
110
+ }
111
+ const originalAbort = state.abort.bind(state)
112
+ state.abort = (reason?: string) => {
113
+ originalAbort(reason)
114
+ postState()
115
+ }
116
+ const originalBlock = state.blockConcurrencyWhile.bind(state)
117
+ state.blockConcurrencyWhile = <T>(cb: () => Promise<T>): Promise<T> => {
118
+ const p = originalBlock(cb)
119
+ postState() // entered the block
120
+ // left it — both-callbacks form so a rejecting callback (the caller's
121
+ // to handle via the returned `p`) can't float an unhandled rejection
122
+ p.then(postState, postState)
123
+ return p
124
+ }
125
+
126
+ // Mirrors the `ContainerRuntime` wiring `InProcessExecutor` did on main —
127
+ // without it, `ContainerBase` instances fail with "Container runtime not
128
+ // initialized" on the first `startAndWaitForPorts` call.
129
+ const containerEntry = config.containers?.find(c => c.class_name === workerConfig.namespaceName)
130
+ let containerRuntime: InstanceType<typeof ContainerRuntime> | undefined
131
+ if (containerEntry) {
132
+ // `DockerManager` itself stays per-worker (the hot path is `docker
133
+ // inspect` polling — no point routing every health check through
134
+ // main). Cleanup tracking, however, lives on main: the worker posts
135
+ // `container-registered` / `container-removed` so main's `exit`
136
+ // handler can rm -f everything regardless of which worker spawned it.
137
+ const dockerManager = new DockerManager({
138
+ onRegister: name => postMessage({ type: 'container-registered', name } satisfies DOMainMessage),
139
+ onRemove: name => postMessage({ type: 'container-removed', name } satisfies DOMainMessage),
140
+ labels: containerLabels(),
141
+ })
142
+ containerRuntime = new ContainerRuntime(
143
+ workerConfig.namespaceName,
144
+ id.toString(),
145
+ containerEntry.image,
146
+ dockerManager,
147
+ )
148
+ state.container = new ContainerContext(containerRuntime)
149
+ }
150
+
81
151
  const instance = new (cls as any)(state, env)
82
152
 
153
+ if (containerRuntime && instance instanceof ContainerBase) {
154
+ instance._wireRuntime(containerRuntime)
155
+ }
156
+
83
157
  state._setInstanceResolver(() => instance)
84
158
 
85
- const bridgedWebSockets = new Map<string, InstanceType<typeof BridgeWebSocket>>()
159
+ /**
160
+ * Bridge for `Response{webSocket}` returned by the DO's own fetch(). Forwards
161
+ * the user-facing peer's events up to main and dispatches inbound real-client
162
+ * events onto the user-facing peer.
163
+ */
164
+ const fetchWsBridge = new WsGuestBridge<DOMainMessage>(msg => postMessage(msg), {
165
+ remoteMessage: (wsId, data) => ({ type: 'fetch-ws-outgoing', wsId, data }),
166
+ remoteClose: (wsId, code, reason, wasClean) => ({ type: 'fetch-ws-close-out', wsId, code, reason, wasClean }),
167
+ })
168
+
169
+ /** Active body pumps for streamed DO-fetch responses, keyed by `streamId`,
170
+ * so an inbound `do-stream-cancel` can stop the source reader. */
171
+ const fetchStreams = new OutboundStreamRegistry()
172
+
173
+ /** Active reconstructed DO-fetch request bodies (main → DO worker). Chunks
174
+ * arriving before the controller registers queue inside the receiver. */
175
+ const requestStreams = new StreamReceiver(
176
+ (streamId) => {
177
+ postMessage({ type: 'do-req-stream-cancel', streamId } satisfies DOMainMessage)
178
+ },
179
+ {
180
+ window: STREAM_BACKPRESSURE_WINDOW,
181
+ onCredit: (streamId) => postMessage({ type: 'do-req-stream-ack', streamId } satisfies DOMainMessage),
182
+ },
183
+ )
184
+
185
+ function pumpFetchBody(streamId: number, body: ReadableStream<Uint8Array>): void {
186
+ type Chunk = Extract<DOMainMessage, { type: 'do-stream-chunk' }>
187
+ type End = Extract<DOMainMessage, { type: 'do-stream-end' }>
188
+ type Err = Extract<DOMainMessage, { type: 'do-stream-error' }>
189
+ pumpStream<Chunk, End, Err>(
190
+ streamId,
191
+ body,
192
+ fetchStreams,
193
+ msg => postMessage(msg),
194
+ {
195
+ chunk: (id, chunk) => ({ type: 'do-stream-chunk', streamId: id, chunk }),
196
+ end: (id) => ({ type: 'do-stream-end', streamId: id }),
197
+ error: (id, error) => ({ type: 'do-stream-error', streamId: id, error }),
198
+ },
199
+ undefined,
200
+ STREAM_BACKPRESSURE_WINDOW,
201
+ )
202
+ }
86
203
 
87
204
  // Wire alarm callback
88
205
  state.storage._setAlarmCallback((time: number | null) => {
@@ -91,7 +208,20 @@ async function initWorker(workerConfig: WorkerConfig) {
91
208
 
92
209
  // --- Command handler ---
93
210
 
94
- async function handleCommand(cmd: DOCommand): Promise<DOResult> {
211
+ /**
212
+ * Some commands need to perform side effects *after* their result message is
213
+ * posted — specifically, streamed fetch responses must wait for main to see
214
+ * the `streamId` on the `result` before chunk messages arrive (otherwise the
215
+ * first chunks land before `start()` registers the controller and have to
216
+ * sit in `_pendingStreamEvents`). The handler returns a continuation so the
217
+ * dispatcher can post the result first and then start the pump.
218
+ */
219
+ interface HandledCommand {
220
+ result: DOResult
221
+ afterPost?: () => void
222
+ }
223
+
224
+ async function handleCommand(cmd: DOCommand): Promise<HandledCommand> {
95
225
  switch (cmd.type) {
96
226
  case 'fetch': {
97
227
  await state._enter()
@@ -100,21 +230,61 @@ async function initWorker(workerConfig: WorkerConfig) {
100
230
  if (typeof fetchFn !== 'function') {
101
231
  throw new Error('Durable Object does not implement fetch()')
102
232
  }
233
+ const reqBody = cmd.streamId !== undefined ? requestStreams.open(cmd.streamId) : cmd.body
103
234
  const request = new Request(cmd.url, {
104
235
  method: cmd.method,
105
236
  headers: cmd.headers,
106
- body: cmd.body,
237
+ body: reqBody,
107
238
  })
108
- const response = await fetchFn.call(instance, request)
109
- const resBody = response.body ? await response.arrayBuffer() : null
239
+ let response: Response
240
+ try {
241
+ response = await fetchFn.call(instance, request)
242
+ } catch (e) {
243
+ // The instance's fetch errored without draining the streamed
244
+ // request body — cancel the receiver so main stops pumping and
245
+ // buffered chunks are dropped. Mirrors dispatchRpcFetch's contract.
246
+ if (cmd.streamId !== undefined) requestStreams.cancel(cmd.streamId)
247
+ throw e
248
+ }
249
+ // Resolved but the body wasn't consumed (e.g. a 401 before reading) —
250
+ // cancel the receiver so main's pump doesn't park forever holding the
251
+ // source. DO fetch has no ctx.waitUntil, so nothing reads it later.
252
+ if (cmd.streamId !== undefined && !request.bodyUsed) {
253
+ requestStreams.cancel(cmd.streamId)
254
+ }
255
+ const clientWs = (response as { webSocket?: unknown }).webSocket
256
+ const hasWebSocket = response.status === 101 && clientWs instanceof CFWebSocket
110
257
  const resHeaders: [string, string][] = []
111
258
  response.headers.forEach((v: string, k: string) => resHeaders.push([k, v]))
259
+
260
+ let fetchWebSocketId: string | undefined
261
+ if (hasWebSocket) {
262
+ const cw = clientWs as InstanceType<typeof CFWebSocket>
263
+ // Main pins the executor on this id (hibernation or plain socket
264
+ // alike) when it processes the result and unpins on close, so no
265
+ // separate accept signal is needed.
266
+ fetchWebSocketId = fetchWsBridge.register(cw)
267
+ }
268
+
269
+ let streamId: number | undefined
270
+ let afterPost: (() => void) | undefined
271
+ if (!hasWebSocket && response.body) {
272
+ streamId = fetchStreams.allocateId()
273
+ const body = response.body
274
+ afterPost = () => pumpFetchBody(streamId!, body)
275
+ }
276
+
112
277
  return {
113
- type: 'fetch',
114
- status: response.status,
115
- statusText: response.statusText,
116
- headers: resHeaders,
117
- body: resBody,
278
+ result: {
279
+ type: 'fetch',
280
+ status: response.status,
281
+ statusText: response.statusText,
282
+ headers: resHeaders,
283
+ body: null,
284
+ fetchWebSocketId,
285
+ streamId,
286
+ },
287
+ afterPost,
118
288
  }
119
289
  } finally {
120
290
  state._exit()
@@ -129,7 +299,7 @@ async function initWorker(workerConfig: WorkerConfig) {
129
299
  throw new Error(`"${cmd.method}" is not a method on the Durable Object`)
130
300
  }
131
301
  const result = await val.call(instance, ...cmd.args)
132
- return { type: 'rpc-call', value: result }
302
+ return { result: { type: 'rpc-call', value: result } }
133
303
  } finally {
134
304
  state._exit()
135
305
  }
@@ -140,9 +310,9 @@ async function initWorker(workerConfig: WorkerConfig) {
140
310
  try {
141
311
  const val = (instance as any)[cmd.prop]
142
312
  if (typeof val === 'function') {
143
- return { type: 'rpc-get', value: '__function__' }
313
+ return { result: { type: 'rpc-get', kind: 'function' } }
144
314
  }
145
- return { type: 'rpc-get', value: val }
315
+ return { result: { type: 'rpc-get', kind: 'value', value: val } }
146
316
  } finally {
147
317
  state._exit()
148
318
  }
@@ -158,18 +328,17 @@ async function initWorker(workerConfig: WorkerConfig) {
158
328
  isRetry: cmd.retryCount > 0,
159
329
  })
160
330
  }
161
- return { type: 'alarm' }
331
+ return { result: { type: 'alarm' } }
162
332
  } finally {
163
333
  state._exit()
164
334
  }
165
335
  }
166
336
 
167
- case 'ws-create': {
168
- const bridgeWs = new BridgeWebSocket(cmd.wsId, (msg: any) => {
169
- postMessage({ type: 'ws-bridge', payload: msg } satisfies DOMainMessage)
170
- })
171
- bridgedWebSockets.set(cmd.wsId, bridgeWs)
172
- return { type: 'ws-created', wsId: cmd.wsId }
337
+ case 'cleanup': {
338
+ // Tear down the Docker container (rm -f + stop timers) before main
339
+ // terminates this thread. No-op for non-container DOs.
340
+ await containerRuntime?.cleanup()
341
+ return { result: { type: 'cleanup' } }
173
342
  }
174
343
 
175
344
  default:
@@ -181,37 +350,51 @@ async function initWorker(workerConfig: WorkerConfig) {
181
350
  self.onmessage = async (event: MessageEvent<DOWorkerMessage>) => {
182
351
  const msg = event.data
183
352
 
353
+ // Env-binding RPC replies from main (service-binding fetches, etc.)
354
+ if (envRpc.handle(msg as { type: string })) return
355
+
184
356
  if (msg.type === 'command') {
185
357
  try {
186
- const result = await handleCommand(msg.command)
358
+ const { result, afterPost } = await handleCommand(msg.command)
187
359
  postMessage({ type: 'result', id: msg.id, result } satisfies DOMainMessage)
360
+ // Post-result side effects: for streamed fetch responses, start the
361
+ // body pump *after* `result` ships so main has registered the
362
+ // `streamId` before any chunk arrives.
363
+ afterPost?.()
364
+ // Surface any abort/block transition this command caused (e.g. it
365
+ // called state.abort()). The wrappers above catch abort/block from
366
+ // outside a command (timers, WS handlers); this catches in-command.
367
+ postState()
188
368
  } catch (e) {
189
- const error = e instanceof Error ? e : new Error(String(e))
190
369
  postMessage(
191
370
  {
192
371
  type: 'result',
193
372
  id: msg.id,
194
- result: {
195
- type: 'error',
196
- message: error.message,
197
- stack: error.stack,
198
- name: error.name,
199
- },
373
+ result: { type: 'error', error: serializeError(e) },
200
374
  } satisfies DOMainMessage,
201
375
  )
376
+ // The command may have thrown because the instance was aborted
377
+ // (state._enter() rejects once aborted) — make sure main learns of it.
378
+ postState()
202
379
  }
203
- } else if (msg.type === 'ws-message') {
204
- const ws = bridgedWebSockets.get(msg.wsId)
205
- if (ws) ws._onMessage(msg.data)
206
- } else if (msg.type === 'ws-close') {
207
- const ws = bridgedWebSockets.get(msg.wsId)
208
- if (ws) {
209
- ws._onClose(msg.code, msg.reason, msg.wasClean)
210
- bridgedWebSockets.delete(msg.wsId)
211
- }
212
- } else if (msg.type === 'ws-error') {
213
- const ws = bridgedWebSockets.get(msg.wsId)
214
- if (ws) ws._onError()
380
+ } else if (msg.type === 'do-stream-ack') {
381
+ fetchStreams.grantCredit(msg.streamId)
382
+ } else if (msg.type === 'do-stream-cancel') {
383
+ fetchStreams.cancel(msg.streamId)
384
+ } else if (msg.type === 'do-req-stream-chunk') {
385
+ requestStreams.push(msg.streamId, msg.chunk)
386
+ } else if (msg.type === 'do-req-stream-end') {
387
+ requestStreams.end(msg.streamId)
388
+ } else if (msg.type === 'do-req-stream-error') {
389
+ requestStreams.error(msg.streamId, deserializeError(msg.error))
390
+ } else if (msg.type === 'fetch-ws-incoming') {
391
+ fetchWsBridge.deliverClientMessage(msg.wsId, msg.data)
392
+ } else if (msg.type === 'fetch-ws-close-in') {
393
+ fetchWsBridge.deliverClientClose(msg.wsId, msg.code, msg.reason, msg.wasClean)
394
+ } else if (msg.type === 'env-ws-incoming') {
395
+ envWsBridge.deliverClientMessage(msg.wsId, msg.data)
396
+ } else if (msg.type === 'env-ws-close-in') {
397
+ envWsBridge.deliverClientClose(msg.wsId, msg.code, msg.reason, msg.wasClean)
215
398
  }
216
399
  }
217
400