lopata 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/types/api/dispatch.d.ts +2 -2
  2. package/dist/types/api/handlers/workflows.d.ts +2 -2
  3. package/dist/types/bindings/container-cleanup.d.ts +43 -0
  4. package/dist/types/bindings/container-docker.d.ts +24 -0
  5. package/dist/types/bindings/container.d.ts +4 -2
  6. package/dist/types/bindings/do-executor-worker.d.ts +73 -89
  7. package/dist/types/bindings/do-executor.d.ts +22 -1
  8. package/dist/types/bindings/do-worker-env.d.ts +16 -7
  9. package/dist/types/bindings/durable-object.d.ts +66 -4
  10. package/dist/types/bindings/queue.d.ts +4 -1
  11. package/dist/types/bindings/rpc-stub.d.ts +23 -1
  12. package/dist/types/bindings/scheduled.d.ts +13 -3
  13. package/dist/types/bindings/service-binding.d.ts +13 -4
  14. package/dist/types/bindings/static-assets.d.ts +1 -1
  15. package/dist/types/bindings/websocket-pair.d.ts +12 -3
  16. package/dist/types/bindings/workflow.d.ts +29 -0
  17. package/dist/types/config.d.ts +2 -0
  18. package/dist/types/env.d.ts +6 -0
  19. package/dist/types/error-page-render.d.ts +6 -0
  20. package/dist/types/execution-context.d.ts +8 -0
  21. package/dist/types/generation-manager.d.ts +21 -2
  22. package/dist/types/generation.d.ts +16 -21
  23. package/dist/types/import-graph.d.ts +28 -0
  24. package/dist/types/lopata-config.d.ts +3 -3
  25. package/dist/types/plugin.d.ts +4 -1
  26. package/dist/types/rpc-validate.d.ts +9 -0
  27. package/dist/types/setup-globals.d.ts +5 -1
  28. package/dist/types/tracing/context.d.ts +21 -0
  29. package/dist/types/tracing/span.d.ts +21 -3
  30. package/dist/types/tracing/store.d.ts +17 -0
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/dist/types/vite-plugin/index.d.ts +8 -1
  33. package/dist/types/worker-registry.d.ts +11 -4
  34. package/dist/types/worker-thread/do-protocol.d.ts +256 -0
  35. package/dist/types/worker-thread/entry.d.ts +2 -0
  36. package/dist/types/worker-thread/execution-context.d.ts +22 -0
  37. package/dist/types/worker-thread/executor.d.ts +123 -0
  38. package/dist/types/worker-thread/protocol.d.ts +553 -0
  39. package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
  40. package/dist/types/worker-thread/rpc-client.d.ts +4 -0
  41. package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
  42. package/dist/types/worker-thread/serialize.d.ts +12 -0
  43. package/dist/types/worker-thread/stream-shared.d.ts +147 -0
  44. package/dist/types/worker-thread/thread-env.d.ts +42 -0
  45. package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
  46. package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
  47. package/package.json +1 -1
  48. package/src/api/handlers/containers.ts +2 -1
  49. package/src/api/handlers/workflows.ts +39 -34
  50. package/src/bindings/container-cleanup.ts +125 -0
  51. package/src/bindings/container-docker.ts +49 -34
  52. package/src/bindings/container.ts +24 -9
  53. package/src/bindings/do-executor-inprocess.ts +9 -5
  54. package/src/bindings/do-executor-worker.ts +386 -158
  55. package/src/bindings/do-executor.ts +23 -1
  56. package/src/bindings/do-worker-entry.ts +242 -60
  57. package/src/bindings/do-worker-env.ts +296 -11
  58. package/src/bindings/durable-object.ts +231 -27
  59. package/src/bindings/email.ts +6 -0
  60. package/src/bindings/queue.ts +11 -1
  61. package/src/bindings/rpc-stub.ts +79 -10
  62. package/src/bindings/scheduled.ts +37 -30
  63. package/src/bindings/service-binding.ts +96 -35
  64. package/src/bindings/static-assets.ts +3 -2
  65. package/src/bindings/websocket-pair.ts +19 -3
  66. package/src/bindings/workflow.ts +91 -0
  67. package/src/cli/dev.ts +106 -41
  68. package/src/config.ts +6 -3
  69. package/src/db.ts +1 -0
  70. package/src/env.ts +40 -21
  71. package/src/error-page-render.ts +21 -0
  72. package/src/execution-context.ts +13 -3
  73. package/src/generation-manager.ts +144 -143
  74. package/src/generation.ts +150 -306
  75. package/src/import-graph.ts +140 -0
  76. package/src/lopata-config.ts +3 -3
  77. package/src/plugin.ts +7 -0
  78. package/src/rpc-validate.ts +29 -0
  79. package/src/setup-globals.ts +6 -17
  80. package/src/testing/durable-object.ts +5 -3
  81. package/src/testing/index.ts +8 -4
  82. package/src/tracing/context.ts +28 -0
  83. package/src/tracing/span.ts +88 -56
  84. package/src/tracing/store.ts +41 -3
  85. package/src/virtual-modules.ts +2 -0
  86. package/src/vite-plugin/dev-server-plugin.ts +4 -0
  87. package/src/vite-plugin/index.ts +8 -1
  88. package/src/worker-registry.ts +15 -2
  89. package/src/worker-thread/do-protocol.ts +237 -0
  90. package/src/worker-thread/entry.ts +453 -0
  91. package/src/worker-thread/execution-context.ts +53 -0
  92. package/src/worker-thread/executor.ts +595 -0
  93. package/src/worker-thread/protocol.ts +552 -0
  94. package/src/worker-thread/remote-trace-store.ts +90 -0
  95. package/src/worker-thread/rpc-client.ts +5 -0
  96. package/src/worker-thread/rpc-shared.ts +503 -0
  97. package/src/worker-thread/serialize.ts +37 -0
  98. package/src/worker-thread/stream-shared.ts +414 -0
  99. package/src/worker-thread/thread-env.ts +350 -0
  100. package/src/worker-thread/wire-handlers.ts +80 -0
  101. package/src/worker-thread/ws-bridge-shared.ts +482 -0
  102. package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
  103. package/dist/types/module-cache.d.ts +0 -23
  104. package/src/bindings/do-websocket-bridge.ts +0 -79
  105. package/src/module-cache.ts +0 -58
  106. package/src/tracing/global.d.ts +0 -50
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Cross-thread binding RPC — shared core used by both worker channels.
3
+ *
4
+ * One frame ({@link RpcCallRequest} / {@link RpcFetchRequest} + their
5
+ * replies in `./protocol.ts`) covers both:
6
+ * - main ↔ user-worker thread (`WorkerThreadExecutor` in `./executor.ts`)
7
+ * - main ↔ DO-instance worker thread (`WorkerExecutor` in `../bindings/do-executor-worker.ts`)
8
+ *
9
+ * The two channels carry channel-specific messages on top (fetch/scheduled,
10
+ * ws-bridge events, container lifecycle, …) but the binding RPC half is
11
+ * identical: resolve binding from `target`, invoke under the caller's trace
12
+ * context, serialize the result. This module hosts that half.
13
+ */
14
+
15
+ import { runWithParentContext } from '../tracing/context'
16
+ import type {
17
+ BindingTarget,
18
+ ParentSpanContext,
19
+ RpcCallErrorReply,
20
+ RpcCallReply,
21
+ RpcCallRequest,
22
+ RpcFetchErrorReply,
23
+ RpcFetchReply,
24
+ RpcFetchRequest,
25
+ RpcGetRequest,
26
+ RpcReply,
27
+ RpcReqStreamAck,
28
+ RpcReqStreamCancel,
29
+ RpcReqStreamChunk,
30
+ RpcReqStreamEnd,
31
+ RpcReqStreamError,
32
+ RpcStreamAck,
33
+ RpcStreamCancel,
34
+ RpcStreamChunk,
35
+ RpcStreamEnd,
36
+ RpcStreamError,
37
+ SerializedRequest,
38
+ SerializedResponse,
39
+ } from './protocol'
40
+ import { deserializeError, serializeError } from './protocol'
41
+ import { deserializeRequest, serializeRequestShell } from './serialize'
42
+ import { OutboundStreamRegistry, pumpStream, STREAM_BACKPRESSURE_WINDOW, StreamReceiver } from './stream-shared'
43
+
44
+ import { CFWebSocket, type ResponseWithWebSocket } from '../bindings/websocket-pair'
45
+
46
+ /**
47
+ * Cross-thread class-identity registry. `structuredClone` (Bun postMessage)
48
+ * strips class prototypes — bindings whose RPC args/returns rely on class
49
+ * identity register a reviver here, and the sender side wraps instances with
50
+ * `tagCloneable` to ship them over the wire.
51
+ */
52
+ type Reviver = (raw: Record<string, unknown>) => unknown
53
+ const revivers = new Map<string, Reviver>()
54
+
55
+ export function registerCloneable(tag: string, revive: Reviver): void {
56
+ revivers.set(tag, revive)
57
+ }
58
+
59
+ /** Sender-side tag: produces a structured-clone-safe payload that the
60
+ * receiver rebuilds via the registered reviver. */
61
+ export function tagCloneable<T extends Record<string, unknown>>(tag: string, payload: T): T & { __lopata_class: string } {
62
+ return { ...payload, __lopata_class: tag }
63
+ }
64
+
65
+ export function reifyArgs(args: unknown[]): unknown[] {
66
+ return args.map(reifyArg)
67
+ }
68
+
69
+ function reifyArg(arg: unknown): unknown {
70
+ if (arg && typeof arg === 'object' && '__lopata_class' in arg) {
71
+ const tag = (arg as { __lopata_class: string }).__lopata_class
72
+ const revive = revivers.get(tag)
73
+ if (revive) return revive(arg as Record<string, unknown>)
74
+ }
75
+ return arg
76
+ }
77
+
78
+ export interface RpcDispatchHooks {
79
+ /** Resolve a binding from main env (channel-specific: user-worker supports
80
+ * `instanceId` namespace .get(), DO channel doesn't). */
81
+ resolveBinding(target: BindingTarget): Record<string, unknown>
82
+ /** Post a reply back through the channel's transport. */
83
+ post(reply: RpcReply): void
84
+ /** Return false once the channel is torn down so we drop late replies. */
85
+ isAlive(): boolean
86
+ /** Optional hook to add transport-specific fields (e.g. webSocketId) to a
87
+ * serialized response after fetch resolves. */
88
+ decorateResponse?(response: Response, serialized: SerializedResponse): void
89
+ }
90
+
91
+ export async function dispatchRpcCall(req: RpcCallRequest, hooks: RpcDispatchHooks): Promise<void> {
92
+ try {
93
+ const resolved = hooks.resolveBinding(req.target)
94
+ const fn = resolved[req.method]
95
+ if (typeof fn !== 'function') {
96
+ throw new Error(`Binding "${req.target.binding}" has no method "${req.method}"`)
97
+ }
98
+ const args = reifyArgs(req.args)
99
+ const value = await runWithParentContext(req.parent, () => (fn as (...a: unknown[]) => unknown).call(resolved, ...args))
100
+ if (!hooks.isAlive()) return
101
+ hooks.post({ type: 'rpc-call-result', id: req.id, value } satisfies RpcCallReply)
102
+ } catch (e) {
103
+ if (!hooks.isAlive()) return
104
+ hooks.post({ type: 'rpc-call-error', id: req.id, error: serializeError(e) } satisfies RpcCallErrorReply)
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Resolve a property read on a binding (`await env.SVC.prop`). The main-side
110
+ * binding is itself a thenable-property proxy (service-binding `toProxy`), so
111
+ * awaiting `resolved[property]` runs its property-get (including the nested
112
+ * main→target-thread hop when the target is a worker thread). The resolved
113
+ * value ships back via the same reply types as a method call. Returning a
114
+ * non-cloneable value (RpcTarget/function) surfaces as `rpc-call-error` — the
115
+ * documented cross-thread limitation, same as method returns.
116
+ */
117
+ export async function dispatchRpcGet(req: RpcGetRequest, hooks: RpcDispatchHooks): Promise<void> {
118
+ try {
119
+ const resolved = hooks.resolveBinding(req.target)
120
+ const value = await runWithParentContext(
121
+ req.parent,
122
+ () => Promise.resolve((resolved as Record<string, unknown>)[req.property]),
123
+ )
124
+ if (!hooks.isAlive()) return
125
+ hooks.post({ type: 'rpc-call-result', id: req.id, value } satisfies RpcCallReply)
126
+ } catch (e) {
127
+ if (!hooks.isAlive()) return
128
+ hooks.post({ type: 'rpc-call-error', id: req.id, error: serializeError(e) } satisfies RpcCallErrorReply)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Resolve a binding's fetch and stream the body back to the caller. Headers +
134
+ * status ship immediately (TTFB preserved); the body flows in
135
+ * `rpc-stream-chunk` messages and terminates with `rpc-stream-end` or
136
+ * `rpc-stream-error`. WS upgrades (status 101) are short-circuited — they
137
+ * carry `webSocketId`, not a body stream. Body-less responses ship without a
138
+ * `streamId` and no pump.
139
+ *
140
+ * The request body is reconstructed from the inbound request-stream messages
141
+ * when `req.request.streamId` is set, so streaming uploads / proxies reach the
142
+ * binding's `fetch()` incrementally.
143
+ */
144
+ export async function dispatchRpcFetch(
145
+ req: RpcFetchRequest,
146
+ hooks: RpcDispatchHooks,
147
+ streams: OutboundStreamRegistry,
148
+ requestStreams: StreamReceiver,
149
+ ): Promise<void> {
150
+ const reqStreamId = req.request.streamId
151
+ let response: Response
152
+ try {
153
+ const resolved = hooks.resolveBinding(req.target)
154
+ const fetch = resolved.fetch
155
+ if (typeof fetch !== 'function') {
156
+ throw new Error(`Binding "${req.target.binding}" has no fetch() method`)
157
+ }
158
+ const body = reqStreamId !== undefined ? requestStreams.open(reqStreamId) : undefined
159
+ const request = deserializeRequest(req.request, body)
160
+ response = await runWithParentContext(
161
+ req.parent,
162
+ () => (fetch as (r: Request) => Promise<Response>).call(resolved, request),
163
+ )
164
+ } catch (e) {
165
+ // Stop the sender from pumping into a request-body stream we will never
166
+ // consume — either we never opened a controller (resolve/fetch missing
167
+ // threw before requestStreams.open), or the binding's fetch errored
168
+ // without draining the body. Cancelling drops any buffered chunks and
169
+ // signals the sender to stop.
170
+ if (reqStreamId !== undefined) requestStreams.cancel(reqStreamId)
171
+ if (!hooks.isAlive()) return
172
+ hooks.post({ type: 'rpc-fetch-error', id: req.id, error: serializeError(e) } satisfies RpcFetchErrorReply)
173
+ return
174
+ }
175
+
176
+ if (!hooks.isAlive()) {
177
+ if (reqStreamId !== undefined) requestStreams.cancel(reqStreamId)
178
+ // The binding's fetch already resolved — tear down what it produced so the
179
+ // upstream socket/stream isn't leaked now that the channel is gone (the
180
+ // caller will never adopt the WS or read the body).
181
+ const ws = (response as ResponseWithWebSocket).webSocket
182
+ if (response.status === 101 && ws instanceof CFWebSocket) ws.close(1012, 'Service Restart')
183
+ else response.body?.cancel().catch(() => {})
184
+ return
185
+ }
186
+
187
+ const cfSocket = (response as ResponseWithWebSocket).webSocket
188
+ const isWsUpgrade = response.status === 101 && cfSocket instanceof CFWebSocket
189
+ const headers: [string, string][] = []
190
+ response.headers.forEach((v, k) => headers.push([k, v]))
191
+ const serialized: SerializedResponse = {
192
+ status: response.status,
193
+ statusText: response.statusText,
194
+ headers,
195
+ body: null,
196
+ }
197
+
198
+ if (!isWsUpgrade && response.body) {
199
+ serialized.streamId = streams.allocateId()
200
+ }
201
+
202
+ hooks.decorateResponse?.(response, serialized)
203
+ hooks.post({ type: 'rpc-fetch-result', id: req.id, response: serialized } satisfies RpcFetchReply)
204
+
205
+ if (serialized.streamId !== undefined && response.body) {
206
+ pumpRpcFetchBody(serialized.streamId, response.body, hooks, streams)
207
+ }
208
+ }
209
+
210
+ function pumpRpcFetchBody(
211
+ streamId: number,
212
+ body: ReadableStream<Uint8Array>,
213
+ hooks: RpcDispatchHooks,
214
+ streams: OutboundStreamRegistry,
215
+ ): void {
216
+ pumpStream<RpcStreamChunk, RpcStreamEnd, RpcStreamError>(
217
+ streamId,
218
+ body,
219
+ streams,
220
+ hooks.post,
221
+ {
222
+ chunk: (id, chunk) => ({ type: 'rpc-stream-chunk', streamId: id, chunk }),
223
+ end: (id) => ({ type: 'rpc-stream-end', streamId: id }),
224
+ error: (id, error) => ({ type: 'rpc-stream-error', streamId: id, error }),
225
+ },
226
+ hooks.isAlive,
227
+ STREAM_BACKPRESSURE_WINDOW,
228
+ )
229
+ }
230
+
231
+ /**
232
+ * Main-side host of the unified cross-thread RPC channel. Both
233
+ * `WorkerThreadExecutor` (user-worker channel) and `WorkerExecutor` (DO-instance
234
+ * channel) own one. It bundles the outbound response-body pump registry, the
235
+ * inbound request-body receiver (with backpressure), the call/get/fetch
236
+ * dispatch, and teardown — so the two executors share a single wiring +
237
+ * `isAlive`/teardown contract instead of re-implementing (and drifting on) the
238
+ * glue. The executor supplies channel-specific behavior via {@link RpcDispatchHooks}:
239
+ * `resolveBinding` (env lookup), `post` (transport), `isAlive` (liveness), and an
240
+ * optional `decorateResponse` (WS adoption). All hooks should close over the
241
+ * executor so they read live state lazily.
242
+ */
243
+ export class RpcHostChannel {
244
+ /** Outbound response-body pumps started by `dispatchRpcFetch`. */
245
+ private _streams = new OutboundStreamRegistry()
246
+ /** Inbound request-body streams (worker → main binding fetch with body). */
247
+ private _requestStreams: StreamReceiver
248
+ private _hooks: RpcDispatchHooks
249
+
250
+ constructor(hooks: RpcDispatchHooks) {
251
+ this._hooks = hooks
252
+ this._requestStreams = new StreamReceiver(
253
+ (streamId) => {
254
+ if (!hooks.isAlive()) return
255
+ hooks.post({ type: 'rpc-req-stream-cancel', streamId })
256
+ },
257
+ {
258
+ window: STREAM_BACKPRESSURE_WINDOW,
259
+ onCredit: (streamId) => {
260
+ if (!hooks.isAlive()) return
261
+ hooks.post({ type: 'rpc-req-stream-ack', streamId })
262
+ },
263
+ },
264
+ )
265
+ }
266
+
267
+ /**
268
+ * Route an inbound message if it belongs to the RPC channel; returns true if
269
+ * consumed (the caller stops processing it). Mirrors {@link RpcClient.handle}
270
+ * on the sender side.
271
+ */
272
+ handle(msg: { type: string }): boolean {
273
+ switch (msg.type) {
274
+ case 'rpc-call':
275
+ void dispatchRpcCall(msg as RpcCallRequest, this._hooks)
276
+ return true
277
+ case 'rpc-call-get':
278
+ void dispatchRpcGet(msg as RpcGetRequest, this._hooks)
279
+ return true
280
+ case 'rpc-fetch':
281
+ void dispatchRpcFetch(msg as RpcFetchRequest, this._hooks, this._streams, this._requestStreams)
282
+ return true
283
+ case 'rpc-stream-cancel':
284
+ this._streams.cancel((msg as RpcStreamCancel).streamId)
285
+ return true
286
+ case 'rpc-stream-ack':
287
+ this._streams.grantCredit((msg as RpcStreamAck).streamId)
288
+ return true
289
+ case 'rpc-req-stream-chunk': {
290
+ const m = msg as RpcReqStreamChunk
291
+ this._requestStreams.push(m.streamId, m.chunk)
292
+ return true
293
+ }
294
+ case 'rpc-req-stream-end':
295
+ this._requestStreams.end((msg as RpcReqStreamEnd).streamId)
296
+ return true
297
+ case 'rpc-req-stream-error': {
298
+ const m = msg as RpcReqStreamError
299
+ this._requestStreams.error(m.streamId, deserializeError(m.error))
300
+ return true
301
+ }
302
+ default:
303
+ return false
304
+ }
305
+ }
306
+
307
+ disposeAll(err: Error): void {
308
+ this._streams.disposeAll()
309
+ this._requestStreams.disposeAll(err)
310
+ }
311
+ }
312
+
313
+ interface PendingCall {
314
+ resolve: (value: unknown) => void
315
+ reject: (error: Error) => void
316
+ }
317
+
318
+ type RpcClientPost = (
319
+ req:
320
+ | RpcCallRequest
321
+ | RpcGetRequest
322
+ | RpcFetchRequest
323
+ | RpcStreamCancel
324
+ | RpcStreamAck
325
+ | RpcReqStreamChunk
326
+ | RpcReqStreamEnd
327
+ | RpcReqStreamError,
328
+ ) => void
329
+
330
+ /**
331
+ * Pump a request body to the channel as `rpc-req-stream-*` messages. The
332
+ * source reader is registered with {@link requestStreams} so an inbound
333
+ * `rpc-req-stream-cancel` can stop the pump.
334
+ */
335
+ function pumpRpcRequestBody(
336
+ streamId: number,
337
+ body: ReadableStream<Uint8Array>,
338
+ post: RpcClientPost,
339
+ requestStreams: OutboundStreamRegistry,
340
+ ): void {
341
+ pumpStream<RpcReqStreamChunk, RpcReqStreamEnd, RpcReqStreamError>(
342
+ streamId,
343
+ body,
344
+ requestStreams,
345
+ post,
346
+ {
347
+ chunk: (id, chunk) => ({ type: 'rpc-req-stream-chunk', streamId: id, chunk }),
348
+ end: (id) => ({ type: 'rpc-req-stream-end', streamId: id }),
349
+ error: (id, error) => ({ type: 'rpc-req-stream-error', streamId: id, error }),
350
+ },
351
+ undefined,
352
+ STREAM_BACKPRESSURE_WINDOW,
353
+ )
354
+ }
355
+
356
+ /**
357
+ * Worker-side RPC caller: posts {@link RpcCallRequest}/{@link RpcFetchRequest},
358
+ * resolves the matching reply.
359
+ *
360
+ * Reads the active span context on every call so spans created on the
361
+ * receiving thread (including spans inside nested cross-thread hops) nest
362
+ * under the caller's current span.
363
+ *
364
+ * Also reconstructs streamed response bodies: when a `rpc-fetch-result`
365
+ * carries a `streamId`, the matching `Response` is built around a
366
+ * `ReadableStream` fed by `rpc-stream-chunk` messages. Consumer cancel posts
367
+ * `rpc-stream-cancel` so an unbounded source on main stops pumping.
368
+ */
369
+ export class RpcClient {
370
+ private _pending = new Map<number, PendingCall>()
371
+ private _nextId = 1
372
+ private _post: RpcClientPost
373
+ private _getParent: () => ParentSpanContext | undefined
374
+ /** Reconstructed inbound response-body streams (rpc-fetch-result.streamId). */
375
+ private _streams = new StreamReceiver(
376
+ (streamId) => {
377
+ this._post({ type: 'rpc-stream-cancel', streamId } satisfies RpcStreamCancel)
378
+ },
379
+ {
380
+ window: STREAM_BACKPRESSURE_WINDOW,
381
+ onCredit: (streamId) => {
382
+ this._post({ type: 'rpc-stream-ack', streamId } satisfies RpcStreamAck)
383
+ },
384
+ },
385
+ )
386
+ /** Outbound request-body pumps started by `callFetch`. A receiver-side
387
+ * `rpc-req-stream-cancel` arrives via `handle()` and stops the source
388
+ * reader so an unbounded upload doesn't pump forever. */
389
+ private _requestStreams = new OutboundStreamRegistry()
390
+
391
+ constructor(post: RpcClientPost, getParent: () => ParentSpanContext | undefined) {
392
+ this._post = post
393
+ this._getParent = getParent
394
+ }
395
+
396
+ call(target: BindingTarget, method: string, args: unknown[]): Promise<unknown> {
397
+ const id = this._nextId++
398
+ return new Promise((resolve, reject) => {
399
+ this._pending.set(id, { resolve, reject })
400
+ this._post({ type: 'rpc-call', id, target, method, args, parent: this._getParent() })
401
+ })
402
+ }
403
+
404
+ /** Property read on a binding (`await env.SVC.prop`). Reply reuses the
405
+ * rpc-call-result/error path via {@link handle}. */
406
+ callGet(target: BindingTarget, property: string): Promise<unknown> {
407
+ const id = this._nextId++
408
+ return new Promise((resolve, reject) => {
409
+ this._pending.set(id, { resolve, reject })
410
+ this._post({ type: 'rpc-call-get', id, target, property, parent: this._getParent() })
411
+ })
412
+ }
413
+
414
+ callFetch(target: BindingTarget, request: Request): Promise<SerializedResponse> {
415
+ const shell = serializeRequestShell(request)
416
+ const body = request.body
417
+ const id = this._nextId++
418
+ const serialized: SerializedRequest = body
419
+ ? { ...shell, body: null, streamId: this._requestStreams.allocateId() }
420
+ : { ...shell, body: null }
421
+ const promise = new Promise<SerializedResponse>((resolve, reject) => {
422
+ this._pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
423
+ this._post({ type: 'rpc-fetch', id, target, request: serialized, parent: this._getParent() })
424
+ })
425
+ if (body && serialized.streamId !== undefined) {
426
+ pumpRpcRequestBody(serialized.streamId, body, this._post, this._requestStreams)
427
+ }
428
+ return promise
429
+ }
430
+
431
+ /**
432
+ * Build a `Response` from a `SerializedResponse`. When `streamId` is set,
433
+ * the body becomes a `ReadableStream` fed by `rpc-stream-chunk` messages;
434
+ * otherwise a buffered body. WebSocket adoption is the caller's job (only
435
+ * `proxyFetch` / `makeEnvBindingProxy` know the channel's wsId convention).
436
+ */
437
+ makeResponse(serialized: SerializedResponse): Response {
438
+ if (serialized.streamId === undefined) {
439
+ return new Response(serialized.body, {
440
+ status: serialized.status,
441
+ statusText: serialized.statusText,
442
+ headers: serialized.headers,
443
+ })
444
+ }
445
+ const stream = this._streams.open(serialized.streamId)
446
+ return new Response(stream, {
447
+ status: serialized.status,
448
+ statusText: serialized.statusText,
449
+ headers: serialized.headers,
450
+ })
451
+ }
452
+
453
+ /** Returns true when `msg` was a unified RPC reply we consumed. */
454
+ handle(msg: { type: string }): boolean {
455
+ switch (msg.type) {
456
+ case 'rpc-call-result':
457
+ case 'rpc-fetch-result': {
458
+ const reply = msg as RpcCallReply | RpcFetchReply
459
+ const p = this._pending.get(reply.id)
460
+ if (!p) return true
461
+ this._pending.delete(reply.id)
462
+ p.resolve(reply.type === 'rpc-call-result' ? reply.value : reply.response)
463
+ return true
464
+ }
465
+ case 'rpc-call-error':
466
+ case 'rpc-fetch-error': {
467
+ const reply = msg as RpcCallErrorReply | RpcFetchErrorReply
468
+ const p = this._pending.get(reply.id)
469
+ if (!p) return true
470
+ this._pending.delete(reply.id)
471
+ p.reject(deserializeError(reply.error))
472
+ return true
473
+ }
474
+ case 'rpc-stream-chunk': {
475
+ const m = msg as RpcStreamChunk
476
+ this._streams.push(m.streamId, m.chunk)
477
+ return true
478
+ }
479
+ case 'rpc-stream-end': {
480
+ const m = msg as RpcStreamEnd
481
+ this._streams.end(m.streamId)
482
+ return true
483
+ }
484
+ case 'rpc-stream-error': {
485
+ const m = msg as RpcStreamError
486
+ this._streams.error(m.streamId, deserializeError(m.error))
487
+ return true
488
+ }
489
+ case 'rpc-req-stream-cancel': {
490
+ const m = msg as RpcReqStreamCancel
491
+ this._requestStreams.cancel(m.streamId)
492
+ return true
493
+ }
494
+ case 'rpc-req-stream-ack': {
495
+ const m = msg as RpcReqStreamAck
496
+ this._requestStreams.grantCredit(m.streamId)
497
+ return true
498
+ }
499
+ default:
500
+ return false
501
+ }
502
+ }
503
+ }
@@ -0,0 +1,37 @@
1
+ /** Request/Response serialization helpers shared between the worker bridges. */
2
+
3
+ import type { SerializedRequest, SerializedResponse } from './protocol'
4
+
5
+ /**
6
+ * Build the headers/method/url shell of a `SerializedRequest`. Body handling
7
+ * is the caller's responsibility — sender code allocates a `streamId` and
8
+ * pumps the body via channel-specific stream messages, or sets `body: null`
9
+ * for body-less requests. (`request.body` is *never* materialised here, which
10
+ * is what allows streaming uploads to cross the worker boundary incrementally.)
11
+ */
12
+ export function serializeRequestShell(request: Request): Omit<SerializedRequest, 'body' | 'streamId'> {
13
+ const headers: [string, string][] = []
14
+ request.headers.forEach((v, k) => headers.push([k, v]))
15
+ return { url: request.url, method: request.method, headers }
16
+ }
17
+
18
+ export function deserializeRequest(
19
+ req: SerializedRequest,
20
+ body?: ReadableStream<Uint8Array> | null,
21
+ signal?: AbortSignal,
22
+ ): Request {
23
+ return new Request(req.url, {
24
+ method: req.method,
25
+ headers: req.headers,
26
+ body: body !== undefined ? body : req.body,
27
+ signal,
28
+ })
29
+ }
30
+
31
+ export function deserializeResponse(serialized: SerializedResponse): Response {
32
+ return new Response(serialized.body, {
33
+ status: serialized.status,
34
+ statusText: serialized.statusText,
35
+ headers: serialized.headers,
36
+ })
37
+ }