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.
- 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 +12 -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 +87 -33
- 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 +18 -0
- package/src/tracing/span.ts +83 -57
- 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,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
|
+
}
|