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