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.
- package/dist/types/api/dispatch.d.ts +2 -2
- package/dist/types/api/handlers/workflows.d.ts +2 -2
- package/dist/types/bindings/container-cleanup.d.ts +43 -0
- package/dist/types/bindings/container-docker.d.ts +24 -0
- package/dist/types/bindings/container.d.ts +4 -2
- package/dist/types/bindings/do-executor-worker.d.ts +73 -89
- package/dist/types/bindings/do-executor.d.ts +22 -1
- package/dist/types/bindings/do-worker-env.d.ts +16 -7
- package/dist/types/bindings/durable-object.d.ts +66 -4
- package/dist/types/bindings/queue.d.ts +4 -1
- package/dist/types/bindings/rpc-stub.d.ts +23 -1
- package/dist/types/bindings/scheduled.d.ts +13 -3
- package/dist/types/bindings/service-binding.d.ts +13 -4
- package/dist/types/bindings/static-assets.d.ts +1 -1
- package/dist/types/bindings/websocket-pair.d.ts +12 -3
- package/dist/types/bindings/workflow.d.ts +29 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/env.d.ts +6 -0
- package/dist/types/error-page-render.d.ts +6 -0
- package/dist/types/execution-context.d.ts +8 -0
- package/dist/types/generation-manager.d.ts +21 -2
- package/dist/types/generation.d.ts +16 -21
- package/dist/types/import-graph.d.ts +28 -0
- package/dist/types/lopata-config.d.ts +3 -3
- package/dist/types/plugin.d.ts +4 -1
- package/dist/types/rpc-validate.d.ts +9 -0
- package/dist/types/setup-globals.d.ts +5 -1
- package/dist/types/tracing/context.d.ts +21 -0
- package/dist/types/tracing/span.d.ts +21 -3
- package/dist/types/tracing/store.d.ts +17 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/index.d.ts +8 -1
- package/dist/types/worker-registry.d.ts +11 -4
- package/dist/types/worker-thread/do-protocol.d.ts +256 -0
- package/dist/types/worker-thread/entry.d.ts +2 -0
- package/dist/types/worker-thread/execution-context.d.ts +22 -0
- package/dist/types/worker-thread/executor.d.ts +123 -0
- package/dist/types/worker-thread/protocol.d.ts +553 -0
- package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
- package/dist/types/worker-thread/rpc-client.d.ts +4 -0
- package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
- package/dist/types/worker-thread/serialize.d.ts +12 -0
- package/dist/types/worker-thread/stream-shared.d.ts +147 -0
- package/dist/types/worker-thread/thread-env.d.ts +42 -0
- package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
- package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
- package/package.json +1 -1
- package/src/api/handlers/containers.ts +2 -1
- package/src/api/handlers/workflows.ts +39 -34
- package/src/bindings/container-cleanup.ts +125 -0
- package/src/bindings/container-docker.ts +49 -34
- package/src/bindings/container.ts +24 -9
- package/src/bindings/do-executor-inprocess.ts +9 -5
- package/src/bindings/do-executor-worker.ts +386 -158
- package/src/bindings/do-executor.ts +23 -1
- package/src/bindings/do-worker-entry.ts +242 -60
- package/src/bindings/do-worker-env.ts +296 -11
- package/src/bindings/durable-object.ts +231 -27
- package/src/bindings/email.ts +6 -0
- package/src/bindings/queue.ts +11 -1
- package/src/bindings/rpc-stub.ts +79 -10
- package/src/bindings/scheduled.ts +37 -30
- package/src/bindings/service-binding.ts +96 -35
- package/src/bindings/static-assets.ts +3 -2
- package/src/bindings/websocket-pair.ts +19 -3
- package/src/bindings/workflow.ts +91 -0
- package/src/cli/dev.ts +106 -41
- package/src/config.ts +6 -3
- package/src/db.ts +1 -0
- package/src/env.ts +40 -21
- package/src/error-page-render.ts +21 -0
- package/src/execution-context.ts +13 -3
- package/src/generation-manager.ts +144 -143
- package/src/generation.ts +150 -306
- package/src/import-graph.ts +140 -0
- package/src/lopata-config.ts +3 -3
- package/src/plugin.ts +7 -0
- package/src/rpc-validate.ts +29 -0
- package/src/setup-globals.ts +6 -17
- package/src/testing/durable-object.ts +5 -3
- package/src/testing/index.ts +8 -4
- package/src/tracing/context.ts +28 -0
- package/src/tracing/span.ts +88 -56
- package/src/tracing/store.ts +41 -3
- package/src/virtual-modules.ts +2 -0
- package/src/vite-plugin/dev-server-plugin.ts +4 -0
- package/src/vite-plugin/index.ts +8 -1
- package/src/worker-registry.ts +15 -2
- package/src/worker-thread/do-protocol.ts +237 -0
- package/src/worker-thread/entry.ts +453 -0
- package/src/worker-thread/execution-context.ts +53 -0
- package/src/worker-thread/executor.ts +595 -0
- package/src/worker-thread/protocol.ts +552 -0
- package/src/worker-thread/remote-trace-store.ts +90 -0
- package/src/worker-thread/rpc-client.ts +5 -0
- package/src/worker-thread/rpc-shared.ts +503 -0
- package/src/worker-thread/serialize.ts +37 -0
- package/src/worker-thread/stream-shared.ts +414 -0
- package/src/worker-thread/thread-env.ts +350 -0
- package/src/worker-thread/wire-handlers.ts +80 -0
- package/src/worker-thread/ws-bridge-shared.ts +482 -0
- package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
- package/dist/types/module-cache.d.ts +0 -23
- package/src/bindings/do-websocket-bridge.ts +0 -79
- package/src/module-cache.ts +0 -58
- 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
|
+
}
|