@statewalker/webrun-rpc 0.4.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/README.md +549 -0
- package/dist/byte-channel.d.ts +15 -0
- package/dist/byte-channel.d.ts.map +1 -0
- package/dist/call-bidi.d.ts +25 -0
- package/dist/call-bidi.d.ts.map +1 -0
- package/dist/call-port.d.ts +37 -0
- package/dist/call-port.d.ts.map +1 -0
- package/dist/cancel-channel.d.ts +15 -0
- package/dist/cancel-channel.d.ts.map +1 -0
- package/dist/close-signal.d.ts +36 -0
- package/dist/close-signal.d.ts.map +1 -0
- package/dist/connect-serve.d.ts +104 -0
- package/dist/connect-serve.d.ts.map +1 -0
- package/dist/duplex-over-port.d.ts +49 -0
- package/dist/duplex-over-port.d.ts.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1180 -0
- package/dist/io-handle.d.ts +17 -0
- package/dist/io-handle.d.ts.map +1 -0
- package/dist/io-send.d.ts +26 -0
- package/dist/io-send.d.ts.map +1 -0
- package/dist/listen-bidi.d.ts +11 -0
- package/dist/listen-bidi.d.ts.map +1 -0
- package/dist/listen-port.d.ts +16 -0
- package/dist/listen-port.d.ts.map +1 -0
- package/dist/message-target.d.ts +16 -0
- package/dist/message-target.d.ts.map +1 -0
- package/dist/multiplex-port.d.ts +12 -0
- package/dist/multiplex-port.d.ts.map +1 -0
- package/dist/port-types.d.ts +86 -0
- package/dist/port-types.d.ts.map +1 -0
- package/dist/recieve.d.ts +35 -0
- package/dist/recieve.d.ts.map +1 -0
- package/dist/send.d.ts +23 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/structured-codec.d.ts +12 -0
- package/dist/structured-codec.d.ts.map +1 -0
- package/dist/through-abort.d.ts +8 -0
- package/dist/through-abort.d.ts.map +1 -0
- package/dist/transfer-port-mux.d.ts +39 -0
- package/dist/transfer-port-mux.d.ts.map +1 -0
- package/dist/virtual-port.d.ts +19 -0
- package/dist/virtual-port.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/byte-channel.ts +109 -0
- package/src/call-bidi.ts +60 -0
- package/src/call-port.ts +119 -0
- package/src/cancel-channel.ts +42 -0
- package/src/close-signal.ts +43 -0
- package/src/connect-serve.ts +208 -0
- package/src/duplex-over-port.ts +471 -0
- package/src/index.ts +29 -0
- package/src/io-handle.ts +40 -0
- package/src/io-send.ts +70 -0
- package/src/listen-bidi.ts +31 -0
- package/src/listen-port.ts +47 -0
- package/src/message-target.ts +18 -0
- package/src/multiplex-port.ts +134 -0
- package/src/port-types.ts +80 -0
- package/src/recieve.ts +89 -0
- package/src/send.ts +60 -0
- package/src/structured-codec.ts +30 -0
- package/src/through-abort.ts +32 -0
- package/src/transfer-port-mux.ts +106 -0
- package/src/virtual-port.ts +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import { deserializeError, recieveIterator, sendIterator, serializeError, toChunks } from "@statewalker/webrun-streams";
|
|
2
|
+
//#region src/byte-channel.ts
|
|
3
|
+
/**
|
|
4
|
+
* Wrap any `MessageTarget` — a real `MessagePort`, a worker, or a virtual
|
|
5
|
+
* port over some other transport — as a `ByteChannel`. Outbound bytes are emitted via
|
|
6
|
+
* `port.postMessage(uint8Array)` (the structured-clone path); inbound bytes
|
|
7
|
+
* are taken from `message` events whose `data` is a `Uint8Array` (or
|
|
8
|
+
* coerceable byte-like value).
|
|
9
|
+
*
|
|
10
|
+
* The port must already be started (`port.start()` if manually constructed).
|
|
11
|
+
* This adapter assumes the port carries only byte payloads — non-byte messages
|
|
12
|
+
* are ignored.
|
|
13
|
+
*/
|
|
14
|
+
function byteChannelFromMessagePort(port) {
|
|
15
|
+
let closedResolve;
|
|
16
|
+
const closed = new Promise((r) => {
|
|
17
|
+
closedResolve = r;
|
|
18
|
+
});
|
|
19
|
+
let isClosed = false;
|
|
20
|
+
const queue = [];
|
|
21
|
+
let pending = null;
|
|
22
|
+
const deliver = (bytes) => {
|
|
23
|
+
if (isClosed) return;
|
|
24
|
+
if (pending) {
|
|
25
|
+
const r = pending;
|
|
26
|
+
pending = null;
|
|
27
|
+
r({
|
|
28
|
+
value: bytes,
|
|
29
|
+
done: false
|
|
30
|
+
});
|
|
31
|
+
} else queue.push(bytes);
|
|
32
|
+
};
|
|
33
|
+
const onMessage = (ev) => {
|
|
34
|
+
const data = ev.data;
|
|
35
|
+
if (data instanceof Uint8Array) {
|
|
36
|
+
deliver(new Uint8Array(data));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (data instanceof ArrayBuffer) {
|
|
40
|
+
deliver(new Uint8Array(data));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (ArrayBuffer.isView(data)) {
|
|
44
|
+
const view = data;
|
|
45
|
+
deliver(new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
port.addEventListener("message", onMessage);
|
|
49
|
+
port.start?.();
|
|
50
|
+
return {
|
|
51
|
+
send(bytes) {
|
|
52
|
+
if (isClosed) return;
|
|
53
|
+
try {
|
|
54
|
+
port.postMessage(bytes);
|
|
55
|
+
} catch {}
|
|
56
|
+
},
|
|
57
|
+
recv: { [Symbol.asyncIterator]() {
|
|
58
|
+
return { next() {
|
|
59
|
+
if (queue.length > 0) return Promise.resolve({
|
|
60
|
+
value: queue.shift(),
|
|
61
|
+
done: false
|
|
62
|
+
});
|
|
63
|
+
if (isClosed) return Promise.resolve({
|
|
64
|
+
value: void 0,
|
|
65
|
+
done: true
|
|
66
|
+
});
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
pending = resolve;
|
|
69
|
+
});
|
|
70
|
+
} };
|
|
71
|
+
} },
|
|
72
|
+
closed,
|
|
73
|
+
close() {
|
|
74
|
+
if (isClosed) return;
|
|
75
|
+
isClosed = true;
|
|
76
|
+
port.removeEventListener("message", onMessage);
|
|
77
|
+
try {
|
|
78
|
+
port.close?.();
|
|
79
|
+
} catch {}
|
|
80
|
+
if (pending) {
|
|
81
|
+
const r = pending;
|
|
82
|
+
pending = null;
|
|
83
|
+
r({
|
|
84
|
+
value: void 0,
|
|
85
|
+
done: true
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
closedResolve();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/close-signal.ts
|
|
94
|
+
const closeSignals = /* @__PURE__ */ new WeakMap();
|
|
95
|
+
/**
|
|
96
|
+
* Internal: register a port's close signal. Called once by `bindBytesToPort`.
|
|
97
|
+
*
|
|
98
|
+
* @internal
|
|
99
|
+
*/
|
|
100
|
+
function setPortCloseSignal(port, signal) {
|
|
101
|
+
closeSignals.set(port, signal);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Return the AbortSignal that fires when `port`'s transport closes, or
|
|
105
|
+
* `undefined` if `port` is not transport-backed (e.g., a raw
|
|
106
|
+
* `MessageChannel().port1`).
|
|
107
|
+
*/
|
|
108
|
+
function getPortCloseSignal(port) {
|
|
109
|
+
return closeSignals.get(port);
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/call-port.ts
|
|
113
|
+
/**
|
|
114
|
+
* Pass as `timeout` to install no deadline at all. Used by the stream tier,
|
|
115
|
+
* where the deadline belongs to the stream rather than to one chunk (spec D8):
|
|
116
|
+
* a slow consumer is throttled, never failed.
|
|
117
|
+
*/
|
|
118
|
+
const NO_TIMEOUT = Number.POSITIVE_INFINITY;
|
|
119
|
+
/**
|
|
120
|
+
* Asynchronous request/response over any `MessageTarget`.
|
|
121
|
+
*
|
|
122
|
+
* Sends `params` to the peer listening with `listenPort`, waits up to
|
|
123
|
+
* `timeout` ms for a matching reply, and either resolves with the result or
|
|
124
|
+
* rejects with the deserialised error.
|
|
125
|
+
*/
|
|
126
|
+
function callPort(port, params, { timeout = 1e3, channelName = "", log = () => {}, newCallId = () => `call-${Date.now()}-${String(Math.random()).substring(2)}`, signal } = {}) {
|
|
127
|
+
const callId = newCallId();
|
|
128
|
+
log("[callPort]", {
|
|
129
|
+
channelName,
|
|
130
|
+
callId,
|
|
131
|
+
params
|
|
132
|
+
});
|
|
133
|
+
const combinedSignal = combineSignals(signal, getPortCloseSignal(port));
|
|
134
|
+
let timerId;
|
|
135
|
+
let onMessage;
|
|
136
|
+
let onAbort;
|
|
137
|
+
const promise = new Promise((resolve, reject) => {
|
|
138
|
+
if (combinedSignal?.aborted) {
|
|
139
|
+
reject(abortReason(combinedSignal));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (Number.isFinite(timeout) && timeout > 0) timerId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Call timeout. CallId: "${callId}".`)), timeout);
|
|
143
|
+
onMessage = (event) => {
|
|
144
|
+
const data = event.data;
|
|
145
|
+
if (!data) return;
|
|
146
|
+
if (data.channelName !== channelName) return;
|
|
147
|
+
if (data.callId !== callId) return;
|
|
148
|
+
if (data.type === "response:error") reject(deserializeError(data.error));
|
|
149
|
+
else if (data.type === "response:result") resolve(data.result);
|
|
150
|
+
};
|
|
151
|
+
port.addEventListener("message", onMessage);
|
|
152
|
+
if (combinedSignal) {
|
|
153
|
+
onAbort = () => reject(abortReason(combinedSignal));
|
|
154
|
+
combinedSignal.addEventListener("abort", onAbort, { once: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
promise.catch(() => {}).finally(() => {
|
|
158
|
+
if (timerId !== void 0) clearTimeout(timerId);
|
|
159
|
+
if (onMessage) port.removeEventListener("message", onMessage);
|
|
160
|
+
if (onAbort && combinedSignal) combinedSignal.removeEventListener("abort", onAbort);
|
|
161
|
+
});
|
|
162
|
+
port.postMessage({
|
|
163
|
+
type: "request",
|
|
164
|
+
channelName,
|
|
165
|
+
callId,
|
|
166
|
+
params
|
|
167
|
+
});
|
|
168
|
+
return promise;
|
|
169
|
+
}
|
|
170
|
+
function abortReason(signal) {
|
|
171
|
+
const reason = signal.reason;
|
|
172
|
+
if (reason instanceof Error) return reason;
|
|
173
|
+
const err = new Error(reason === void 0 ? "Aborted" : String(reason));
|
|
174
|
+
err.name = "AbortError";
|
|
175
|
+
return err;
|
|
176
|
+
}
|
|
177
|
+
function combineSignals(...signals) {
|
|
178
|
+
const live = signals.filter((s) => s !== void 0);
|
|
179
|
+
if (live.length === 0) return void 0;
|
|
180
|
+
if (live.length === 1) return live[0];
|
|
181
|
+
return AbortSignal.any(live);
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/cancel-channel.ts
|
|
185
|
+
const CANCEL_CHANNEL_TYPE = "cancel-channel";
|
|
186
|
+
function postCancelChannel(port, channelName) {
|
|
187
|
+
if (!channelName) return;
|
|
188
|
+
try {
|
|
189
|
+
port.postMessage({
|
|
190
|
+
type: CANCEL_CHANNEL_TYPE,
|
|
191
|
+
channelName
|
|
192
|
+
});
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
function listenCancelChannel(port, channelName, onCancel) {
|
|
196
|
+
const handler = (event) => {
|
|
197
|
+
const data = event.data;
|
|
198
|
+
if (!data || data.type !== "cancel-channel") return;
|
|
199
|
+
if (data.channelName !== channelName) return;
|
|
200
|
+
onCancel();
|
|
201
|
+
};
|
|
202
|
+
port.addEventListener("message", handler);
|
|
203
|
+
return () => port.removeEventListener("message", handler);
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/listen-port.ts
|
|
207
|
+
/**
|
|
208
|
+
* Installs `handler` as the server side of a `callPort` / `listenPort`
|
|
209
|
+
* request/response pair on `port`.
|
|
210
|
+
*
|
|
211
|
+
* Returns a cleanup function that removes the listener.
|
|
212
|
+
*/
|
|
213
|
+
function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
|
|
214
|
+
const onMessage = async (event) => {
|
|
215
|
+
const data = event.data;
|
|
216
|
+
if (!data || data.channelName !== channelName || data.type !== "request") return;
|
|
217
|
+
const { callId, params } = data;
|
|
218
|
+
log("[listenPort]", {
|
|
219
|
+
channelName,
|
|
220
|
+
callId,
|
|
221
|
+
params
|
|
222
|
+
});
|
|
223
|
+
let result;
|
|
224
|
+
let error;
|
|
225
|
+
let type;
|
|
226
|
+
try {
|
|
227
|
+
result = await handler(params);
|
|
228
|
+
type = "response:result";
|
|
229
|
+
} catch (e) {
|
|
230
|
+
error = serializeError(e);
|
|
231
|
+
type = "response:error";
|
|
232
|
+
}
|
|
233
|
+
port.postMessage({
|
|
234
|
+
callId,
|
|
235
|
+
channelName,
|
|
236
|
+
type,
|
|
237
|
+
result,
|
|
238
|
+
error
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
port.addEventListener("message", onMessage);
|
|
242
|
+
return () => port.removeEventListener("message", onMessage);
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/recieve.ts
|
|
246
|
+
/**
|
|
247
|
+
* Async generator over async generators. Each outer yield is one inbound
|
|
248
|
+
* stream reconstructed from chunk-envelopes delivered by the peer's
|
|
249
|
+
* {@link send}.
|
|
250
|
+
*
|
|
251
|
+
* The outer generator itself never ends: break out of the outer loop when
|
|
252
|
+
* you've handled the streams you care about.
|
|
253
|
+
*
|
|
254
|
+
* When the outer for-await is interrupted (`break`, `return`, throw), the
|
|
255
|
+
* `finally` here both removes the underlying `listenPort` and closes the
|
|
256
|
+
* most recently yielded `recieveIterator`. The latter is required because
|
|
257
|
+
* a `listenPort` handler invocation that's already in flight (awaiting
|
|
258
|
+
* `deliver(chunk)`) would otherwise hang forever — `iterator.return()`
|
|
259
|
+
* triggers `drainQueue` which resolves all pending producer Promises.
|
|
260
|
+
*
|
|
261
|
+
* If `options.signal` is provided and fires while the consumer is awaiting
|
|
262
|
+
* a chunk, the active `recieveIterator` is force-delivered an end-of-stream
|
|
263
|
+
* marker so the consumer wakes up immediately rather than waiting for the
|
|
264
|
+
* next inbound chunk (or `callPort` timeout).
|
|
265
|
+
*/
|
|
266
|
+
async function* recieve(port, options = {}) {
|
|
267
|
+
const { signal, ...listenOptions } = options;
|
|
268
|
+
let onMessage;
|
|
269
|
+
let currentIter;
|
|
270
|
+
let forceClose;
|
|
271
|
+
const close = listenPort(port, async ({ done, value, error }) => {
|
|
272
|
+
await onMessage?.({
|
|
273
|
+
done,
|
|
274
|
+
value,
|
|
275
|
+
error
|
|
276
|
+
});
|
|
277
|
+
}, listenOptions);
|
|
278
|
+
const onAbort = () => {
|
|
279
|
+
forceClose?.();
|
|
280
|
+
};
|
|
281
|
+
if (signal) {
|
|
282
|
+
if (signal.aborted) {
|
|
283
|
+
close();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
signal.addEventListener("abort", onAbort);
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
while (true) {
|
|
290
|
+
currentIter = recieveIterator((deliver) => {
|
|
291
|
+
onMessage = deliver;
|
|
292
|
+
forceClose = () => {
|
|
293
|
+
deliver({ done: true });
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
yield currentIter;
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
close();
|
|
300
|
+
signal?.removeEventListener("abort", onAbort);
|
|
301
|
+
onMessage = void 0;
|
|
302
|
+
forceClose = void 0;
|
|
303
|
+
if (currentIter) try {
|
|
304
|
+
await currentIter.return?.(void 0);
|
|
305
|
+
} catch {}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/through-abort.ts
|
|
310
|
+
/**
|
|
311
|
+
* Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
|
|
312
|
+
* to return cleanly, forwarding `return()` to the underlying iterator so the
|
|
313
|
+
* producer (e.g., a user-supplied generator) sees its own `finally` blocks
|
|
314
|
+
* run immediately rather than waiting for the next yield.
|
|
315
|
+
*/
|
|
316
|
+
async function* throughAbort(input, signal) {
|
|
317
|
+
const iter = input[Symbol.asyncIterator] ? input[Symbol.asyncIterator]() : input[Symbol.iterator]();
|
|
318
|
+
const onAbort = () => {
|
|
319
|
+
iter.return?.(void 0);
|
|
320
|
+
};
|
|
321
|
+
if (signal.aborted) {
|
|
322
|
+
iter.return?.(void 0);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
326
|
+
try {
|
|
327
|
+
while (true) {
|
|
328
|
+
const r = await iter.next();
|
|
329
|
+
if (r.done) return;
|
|
330
|
+
if (signal.aborted) return;
|
|
331
|
+
yield r.value;
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
signal.removeEventListener("abort", onAbort);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/send.ts
|
|
339
|
+
/**
|
|
340
|
+
* Send every value produced by `output` to `port`, one `callPort` round-trip
|
|
341
|
+
* per chunk. Resolves once the peer has acknowledged the final `{ done: true }`
|
|
342
|
+
* envelope, or once `options.signal` aborts (whichever comes first).
|
|
343
|
+
*/
|
|
344
|
+
async function send(port, output, options = {}) {
|
|
345
|
+
const { signal, ...callOptions } = options;
|
|
346
|
+
if (!signal) {
|
|
347
|
+
await sendIterator(async ({ done, value, error }) => {
|
|
348
|
+
await callPort(port, {
|
|
349
|
+
done,
|
|
350
|
+
value,
|
|
351
|
+
error
|
|
352
|
+
}, callOptions);
|
|
353
|
+
}, output);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const stream = throughAbort(output, signal);
|
|
357
|
+
let abortedBeforeDone = false;
|
|
358
|
+
try {
|
|
359
|
+
await sendIterator(async ({ done, value, error }) => {
|
|
360
|
+
if (signal.aborted) {
|
|
361
|
+
abortedBeforeDone = true;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
await callPort(port, {
|
|
365
|
+
done,
|
|
366
|
+
value,
|
|
367
|
+
error
|
|
368
|
+
}, {
|
|
369
|
+
...callOptions,
|
|
370
|
+
signal
|
|
371
|
+
});
|
|
372
|
+
}, stream);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (signal.aborted || abortedBeforeDone) return;
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/io-send.ts
|
|
380
|
+
/**
|
|
381
|
+
* Client half of a full-duplex exchange over any `MessageTarget`.
|
|
382
|
+
*
|
|
383
|
+
* Concurrently reads one inbound stream from the peer and writes `output`
|
|
384
|
+
* to it. Yields each value received from the peer. Completes once both
|
|
385
|
+
* directions finish. Pairs with {@link ioHandle}.
|
|
386
|
+
*
|
|
387
|
+
* If the consumer breaks out of the `for await` (via `iter.return()` or
|
|
388
|
+
* loop `break`), `ioSend` posts a `cancel-channel` message on the same
|
|
389
|
+
* sub-channel so the peer can abort its `send` immediately rather than
|
|
390
|
+
* waiting for `callPort` timeouts to fire.
|
|
391
|
+
*/
|
|
392
|
+
async function* ioSend(port, output, options = {}) {
|
|
393
|
+
const { cancelSignal, ...recieveOptions } = options;
|
|
394
|
+
const channelName = recieveOptions.channelName ?? "";
|
|
395
|
+
let inputEndedNormally = false;
|
|
396
|
+
const sendAbort = new AbortController();
|
|
397
|
+
if (cancelSignal) {
|
|
398
|
+
if (cancelSignal.aborted) sendAbort.abort();
|
|
399
|
+
else cancelSignal.addEventListener("abort", () => sendAbort.abort(), { once: true });
|
|
400
|
+
}
|
|
401
|
+
const recieveOpts = {
|
|
402
|
+
...recieveOptions,
|
|
403
|
+
signal: cancelSignal
|
|
404
|
+
};
|
|
405
|
+
for await (const input of recieve(port, recieveOpts)) {
|
|
406
|
+
const sendPromise = send(port, output, {
|
|
407
|
+
...recieveOptions,
|
|
408
|
+
signal: sendAbort.signal
|
|
409
|
+
});
|
|
410
|
+
try {
|
|
411
|
+
yield* input;
|
|
412
|
+
inputEndedNormally = true;
|
|
413
|
+
} finally {
|
|
414
|
+
if (!inputEndedNormally) {
|
|
415
|
+
postCancelChannel(port, channelName);
|
|
416
|
+
sendAbort.abort();
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
await sendPromise;
|
|
420
|
+
} catch {}
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/call-bidi.ts
|
|
427
|
+
/**
|
|
428
|
+
* Initiates a full-duplex stream call: ships `input` values to the peer and
|
|
429
|
+
* yields the values returned by `listenBidi`'s handler.
|
|
430
|
+
*
|
|
431
|
+
* Internally allocates a fresh sub-channel name, announces it to the peer
|
|
432
|
+
* via `callPort`, and then runs {@link ioSend} on that sub-channel.
|
|
433
|
+
*
|
|
434
|
+
* If the outer `callPort` rejects (e.g., the peer's handler threw and
|
|
435
|
+
* `listenPort` surfaced the error as `response:error`), the inner `ioSend`'s
|
|
436
|
+
* recieveIterator is force-closed via an internal cancel signal so the
|
|
437
|
+
* consumer doesn't hang waiting for chunks that will never come. The outer
|
|
438
|
+
* error is then re-thrown to the caller.
|
|
439
|
+
*/
|
|
440
|
+
async function* callBidi(port, input, { options = {}, ...params } = {}) {
|
|
441
|
+
const channelName = `${+String(Math.random()).substring(2)}`;
|
|
442
|
+
const { bidiTimeout = 2147483647 } = options;
|
|
443
|
+
const promise = callPort(port, {
|
|
444
|
+
...params,
|
|
445
|
+
channelName
|
|
446
|
+
}, {
|
|
447
|
+
...options,
|
|
448
|
+
timeout: bidiTimeout
|
|
449
|
+
});
|
|
450
|
+
const cancelInner = new AbortController();
|
|
451
|
+
const sendIter = ioSend(port, input, {
|
|
452
|
+
...options,
|
|
453
|
+
channelName,
|
|
454
|
+
cancelSignal: cancelInner.signal
|
|
455
|
+
});
|
|
456
|
+
let outerError;
|
|
457
|
+
promise.catch((err) => {
|
|
458
|
+
outerError = err;
|
|
459
|
+
cancelInner.abort();
|
|
460
|
+
});
|
|
461
|
+
try {
|
|
462
|
+
yield* sendIter;
|
|
463
|
+
} finally {
|
|
464
|
+
try {
|
|
465
|
+
await promise;
|
|
466
|
+
} catch {}
|
|
467
|
+
}
|
|
468
|
+
if (outerError !== void 0) throw outerError;
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/duplex-over-port.ts
|
|
472
|
+
/**
|
|
473
|
+
* The `type` of the out-of-band notice a side posts when it abandons a stream.
|
|
474
|
+
*
|
|
475
|
+
* Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
|
|
476
|
+
* its listeners silently and is indistinguishable from a working port nobody
|
|
477
|
+
* is answering — so the peer would otherwise wait forever. Exported because
|
|
478
|
+
* tests and adapters assert on it.
|
|
479
|
+
*/
|
|
480
|
+
const STREAM_ABORT = "webrun-rpc:stream-abort";
|
|
481
|
+
/** Caller's input travels on this channel; the handler listens on it. */
|
|
482
|
+
const CHANNEL_IN = "in";
|
|
483
|
+
/** The handler's output travels on this channel; the caller listens on it. */
|
|
484
|
+
const CHANNEL_OUT = "out";
|
|
485
|
+
/**
|
|
486
|
+
* One port in, one `Duplex` out (spec D9).
|
|
487
|
+
*
|
|
488
|
+
* The returned `Duplex` runs a single stream on `port`: the caller's `input`
|
|
489
|
+
* is sent chunk by chunk with `callPort`, and the handler's output arrives the
|
|
490
|
+
* same way on the other channel. Within each direction the next chunk is never
|
|
491
|
+
* sent until the previous one has been delivered *and* pulled past by the
|
|
492
|
+
* consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
|
|
493
|
+
* `listenPort` withholds it until then.
|
|
494
|
+
*
|
|
495
|
+
* A stream port carries exactly one invocation. To make several calls, open
|
|
496
|
+
* several ports: `mux.openPort({ kind: "stream" })` per call.
|
|
497
|
+
*/
|
|
498
|
+
function duplexOverPort(port, options = {}) {
|
|
499
|
+
return (input) => runCallerSide(port, input, options);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Installs `handler` as the serving side of one stream on `port`. Returns an
|
|
503
|
+
* idempotent teardown that abandons the stream and notifies the peer.
|
|
504
|
+
*/
|
|
505
|
+
function serveDuplexOverPort(port, handler, options = {}) {
|
|
506
|
+
const controller = new AbortController();
|
|
507
|
+
const notice = installAbortNotice(port, controller);
|
|
508
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
509
|
+
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
|
|
510
|
+
let output;
|
|
511
|
+
try {
|
|
512
|
+
output = handler(inbound.stream);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
|
|
515
|
+
pump.catch(() => {});
|
|
516
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
517
|
+
return teardownOnce(controller, notice, clock, inbound, void 0);
|
|
518
|
+
}
|
|
519
|
+
const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
|
|
520
|
+
pump.catch(() => {});
|
|
521
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
522
|
+
return teardownOnce(controller, notice, clock, inbound, output);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* A stream that completes normally has nobody left to call the returned
|
|
526
|
+
* teardown — the caller only knows the stream ended, not that it must also
|
|
527
|
+
* dispose the serve-side handle. Without this, the last `touch()`'s timer
|
|
528
|
+
* stays armed for up to `timeout` ms after real completion, doing nothing
|
|
529
|
+
* but holding the event loop open.
|
|
530
|
+
*
|
|
531
|
+
* Only disarms once BOTH `pump` (our output) and `inboundEnded` (the peer's
|
|
532
|
+
* declared end of input, or an abort) have settled. Disarming on `pump`
|
|
533
|
+
* alone is not safe: under half-close the handler can finish producing
|
|
534
|
+
* output while the caller is still sending input, and a clock stopped then
|
|
535
|
+
* would leave that still-open half with no deadline at all — worse than the
|
|
536
|
+
* leak this fixes.
|
|
537
|
+
*/
|
|
538
|
+
function disarmClockWhenBothSidesSettle(clock, pump, inboundEnded) {
|
|
539
|
+
Promise.allSettled([pump, inboundEnded]).then(() => {
|
|
540
|
+
clock.stop();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
async function* failing(err) {
|
|
544
|
+
throw err;
|
|
545
|
+
}
|
|
546
|
+
function teardownOnce(controller, notice, clock, inbound, output) {
|
|
547
|
+
let torn = false;
|
|
548
|
+
return () => {
|
|
549
|
+
if (torn) return;
|
|
550
|
+
torn = true;
|
|
551
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: stream torn down"));
|
|
552
|
+
notice.post();
|
|
553
|
+
notice.stop();
|
|
554
|
+
clock.stop();
|
|
555
|
+
inbound.stop();
|
|
556
|
+
output?.return(void 0).catch(() => {});
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function runCallerSide(port, input, options) {
|
|
560
|
+
const controller = new AbortController();
|
|
561
|
+
const notice = installAbortNotice(port, controller);
|
|
562
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
563
|
+
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
|
|
564
|
+
const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
|
|
565
|
+
pump.catch(() => {});
|
|
566
|
+
return (async function* () {
|
|
567
|
+
try {
|
|
568
|
+
yield* inbound.stream;
|
|
569
|
+
} finally {
|
|
570
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
|
|
571
|
+
notice.post();
|
|
572
|
+
notice.stop();
|
|
573
|
+
clock.stop();
|
|
574
|
+
inbound.stop();
|
|
575
|
+
await pump.catch(() => {});
|
|
576
|
+
try {
|
|
577
|
+
await port.close?.();
|
|
578
|
+
} catch {}
|
|
579
|
+
}
|
|
580
|
+
})();
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Listens for the peer's abort notice, and can post our own. The notice is a
|
|
584
|
+
* plain message with no `channelName` and a `type` that is not `"request"`,
|
|
585
|
+
* so neither `callPort` nor `listenPort` reacts to it, and it carries no
|
|
586
|
+
* numeric `id`, so `structuredCodec` never mistakes it for a layer 1 envelope.
|
|
587
|
+
*/
|
|
588
|
+
function installAbortNotice(port, controller) {
|
|
589
|
+
const onMessage = (event) => {
|
|
590
|
+
const data = event.data;
|
|
591
|
+
if (!data || data.type !== "webrun-rpc:stream-abort") return;
|
|
592
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the peer abandoned the stream"));
|
|
593
|
+
};
|
|
594
|
+
port.addEventListener("message", onMessage);
|
|
595
|
+
let posted = false;
|
|
596
|
+
return {
|
|
597
|
+
post() {
|
|
598
|
+
if (posted) return;
|
|
599
|
+
posted = true;
|
|
600
|
+
try {
|
|
601
|
+
port.postMessage({ type: STREAM_ABORT });
|
|
602
|
+
} catch {}
|
|
603
|
+
},
|
|
604
|
+
stop() {
|
|
605
|
+
port.removeEventListener("message", onMessage);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The per-stream inactivity timeout (spec D8). Reset by any chunk in either
|
|
611
|
+
* direction; elapsing aborts the stream. Unset, zero or non-finite installs no
|
|
612
|
+
* timer at all, which is the default: a slow consumer is throttled, not failed.
|
|
613
|
+
*/
|
|
614
|
+
function installStreamTimeout(controller, timeout) {
|
|
615
|
+
if (timeout === void 0 || !Number.isFinite(timeout) || timeout <= 0) return {
|
|
616
|
+
touch() {},
|
|
617
|
+
stop() {}
|
|
618
|
+
};
|
|
619
|
+
let timer;
|
|
620
|
+
let stopped = false;
|
|
621
|
+
const arm = () => {
|
|
622
|
+
timer = setTimeout(() => {
|
|
623
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error(`webrun-rpc: stream idle for ${timeout} ms`));
|
|
624
|
+
}, timeout);
|
|
625
|
+
};
|
|
626
|
+
arm();
|
|
627
|
+
return {
|
|
628
|
+
touch() {
|
|
629
|
+
if (stopped) return;
|
|
630
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
631
|
+
arm();
|
|
632
|
+
},
|
|
633
|
+
stop() {
|
|
634
|
+
stopped = true;
|
|
635
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
636
|
+
timer = void 0;
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* The receiving half of one direction.
|
|
642
|
+
*
|
|
643
|
+
* The `listenPort` listener is installed **eagerly**, not lazily inside
|
|
644
|
+
* `recieveIterator`'s installer, because a handler that never drains its input
|
|
645
|
+
* would otherwise leave the peer's chunk calls with nobody to answer them. If
|
|
646
|
+
* the local consumer has not started iterating, an inbound chunk waits for it —
|
|
647
|
+
* which is the correct backpressure, and different from having no listener.
|
|
648
|
+
*/
|
|
649
|
+
function receiveChunks(port, channelName, controller, touch) {
|
|
650
|
+
let deliver;
|
|
651
|
+
const waiting = [];
|
|
652
|
+
let finished = false;
|
|
653
|
+
let outstanding = false;
|
|
654
|
+
let poison;
|
|
655
|
+
let resolveEnded = () => {};
|
|
656
|
+
const ended = new Promise((resolve) => {
|
|
657
|
+
resolveEnded = resolve;
|
|
658
|
+
});
|
|
659
|
+
let aborted = false;
|
|
660
|
+
let abortReason;
|
|
661
|
+
const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
|
|
662
|
+
const wake = () => {
|
|
663
|
+
for (const r of waiting.splice(0)) r();
|
|
664
|
+
};
|
|
665
|
+
const off = listenPort(port, async ({ done, value, error }) => {
|
|
666
|
+
touch();
|
|
667
|
+
if (poison) throw poison;
|
|
668
|
+
if (outstanding) {
|
|
669
|
+
poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
|
|
670
|
+
controller.abort(poison);
|
|
671
|
+
setTimeout(() => {
|
|
672
|
+
try {
|
|
673
|
+
port.close?.();
|
|
674
|
+
} catch {}
|
|
675
|
+
}, 0);
|
|
676
|
+
throw poison;
|
|
677
|
+
}
|
|
678
|
+
outstanding = true;
|
|
679
|
+
try {
|
|
680
|
+
await ready();
|
|
681
|
+
if (finished) throw poison ?? /* @__PURE__ */ new Error("webrun-rpc: the stream is closed");
|
|
682
|
+
await deliver?.({
|
|
683
|
+
done,
|
|
684
|
+
value,
|
|
685
|
+
error: error ? deserializeError(error) : void 0
|
|
686
|
+
});
|
|
687
|
+
} finally {
|
|
688
|
+
outstanding = false;
|
|
689
|
+
}
|
|
690
|
+
if (done) resolveEnded();
|
|
691
|
+
}, { channelName });
|
|
692
|
+
const detach = () => {
|
|
693
|
+
finished = true;
|
|
694
|
+
wake();
|
|
695
|
+
off();
|
|
696
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
697
|
+
resolveEnded();
|
|
698
|
+
};
|
|
699
|
+
const onAbort = () => {
|
|
700
|
+
aborted = true;
|
|
701
|
+
abortReason = controller.signal.reason;
|
|
702
|
+
finished = true;
|
|
703
|
+
wake();
|
|
704
|
+
deliver?.({
|
|
705
|
+
done: true,
|
|
706
|
+
error: abortReason
|
|
707
|
+
});
|
|
708
|
+
resolveEnded();
|
|
709
|
+
};
|
|
710
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
711
|
+
return {
|
|
712
|
+
stream: recieveIterator((d) => {
|
|
713
|
+
if (aborted) {
|
|
714
|
+
d({
|
|
715
|
+
done: true,
|
|
716
|
+
error: abortReason
|
|
717
|
+
});
|
|
718
|
+
return detach;
|
|
719
|
+
}
|
|
720
|
+
deliver = d;
|
|
721
|
+
wake();
|
|
722
|
+
return detach;
|
|
723
|
+
}),
|
|
724
|
+
stop: detach,
|
|
725
|
+
ended
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The sending half of one direction: one `callPort` per chunk, one call
|
|
730
|
+
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
|
|
731
|
+
*/
|
|
732
|
+
async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
|
|
733
|
+
const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
|
|
734
|
+
try {
|
|
735
|
+
await sendIterator(async ({ done, value, error }) => {
|
|
736
|
+
if (signal.aborted) return;
|
|
737
|
+
const chunk = {
|
|
738
|
+
done,
|
|
739
|
+
value,
|
|
740
|
+
error: error === void 0 ? void 0 : serializeError(error)
|
|
741
|
+
};
|
|
742
|
+
log?.("[duplexOverPort] send", {
|
|
743
|
+
channelName,
|
|
744
|
+
done,
|
|
745
|
+
size: value?.byteLength
|
|
746
|
+
});
|
|
747
|
+
await callPort(port, chunk, {
|
|
748
|
+
channelName,
|
|
749
|
+
timeout: NO_TIMEOUT,
|
|
750
|
+
signal
|
|
751
|
+
});
|
|
752
|
+
touch();
|
|
753
|
+
}, stream);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
if (signal.aborted) return;
|
|
756
|
+
throw err;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/virtual-port.ts
|
|
761
|
+
/**
|
|
762
|
+
* One virtual port.
|
|
763
|
+
*
|
|
764
|
+
* `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
|
|
765
|
+
* only a `MessageTarget`, so it cannot forge inbound traffic or close the port
|
|
766
|
+
* out from under the multiplexer's bookkeeping.
|
|
767
|
+
*/
|
|
768
|
+
function newVirtualPort(send, requestClose) {
|
|
769
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
770
|
+
let closed = false;
|
|
771
|
+
return {
|
|
772
|
+
port: {
|
|
773
|
+
addEventListener(_type, listener) {
|
|
774
|
+
listeners.add(listener);
|
|
775
|
+
},
|
|
776
|
+
removeEventListener(_type, listener) {
|
|
777
|
+
listeners.delete(listener);
|
|
778
|
+
},
|
|
779
|
+
postMessage(message, transfer) {
|
|
780
|
+
if (closed) return;
|
|
781
|
+
send(message, transfer);
|
|
782
|
+
},
|
|
783
|
+
close() {
|
|
784
|
+
if (closed) return;
|
|
785
|
+
closed = true;
|
|
786
|
+
const notify = requestClose;
|
|
787
|
+
listeners.clear();
|
|
788
|
+
notify();
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
deliver(payload) {
|
|
792
|
+
if (closed) return;
|
|
793
|
+
const event = new MessageEvent("message", { data: payload });
|
|
794
|
+
for (const listener of [...listeners]) try {
|
|
795
|
+
listener(event);
|
|
796
|
+
} catch {}
|
|
797
|
+
},
|
|
798
|
+
markClosed() {
|
|
799
|
+
closed = true;
|
|
800
|
+
listeners.clear();
|
|
801
|
+
},
|
|
802
|
+
isClosed() {
|
|
803
|
+
return closed;
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/multiplex-port.ts
|
|
809
|
+
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
810
|
+
const DEFAULT_MAX_PORTS = 1024;
|
|
811
|
+
/**
|
|
812
|
+
* The default `PortMux`: emulates multiplexing over a single port.
|
|
813
|
+
*
|
|
814
|
+
* A transport that already multiplexes natively supplies its own `PortMux`
|
|
815
|
+
* instead — this implementation is for transports that offer one pipe.
|
|
816
|
+
*/
|
|
817
|
+
function multiplexPort(port, options) {
|
|
818
|
+
const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
|
|
819
|
+
const open = /* @__PURE__ */ new Map();
|
|
820
|
+
let nextId = side === "initiator" ? 0 : 1;
|
|
821
|
+
let muxClosed = false;
|
|
822
|
+
const post = (envelope, transfer) => {
|
|
823
|
+
if (muxClosed) return;
|
|
824
|
+
try {
|
|
825
|
+
codec.post(port, envelope, transfer);
|
|
826
|
+
} catch {}
|
|
827
|
+
};
|
|
828
|
+
const attach = (id) => {
|
|
829
|
+
let self;
|
|
830
|
+
self = newVirtualPort((payload, transfer) => post({
|
|
831
|
+
type: "message",
|
|
832
|
+
id,
|
|
833
|
+
payload
|
|
834
|
+
}, transfer), (reason) => {
|
|
835
|
+
post({
|
|
836
|
+
type: "close",
|
|
837
|
+
id,
|
|
838
|
+
reason
|
|
839
|
+
});
|
|
840
|
+
open.delete(id);
|
|
841
|
+
self.markClosed();
|
|
842
|
+
});
|
|
843
|
+
open.set(id, self);
|
|
844
|
+
return self;
|
|
845
|
+
};
|
|
846
|
+
const handleEnvelope = (envelope) => {
|
|
847
|
+
if (muxClosed) return;
|
|
848
|
+
if (envelope.type === "open") {
|
|
849
|
+
if (open.has(envelope.id)) return;
|
|
850
|
+
if (open.size >= maxPorts) {
|
|
851
|
+
post({
|
|
852
|
+
type: "close",
|
|
853
|
+
id: envelope.id,
|
|
854
|
+
reason: "max-ports"
|
|
855
|
+
});
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const handle = attach(envelope.id);
|
|
859
|
+
let accepted = false;
|
|
860
|
+
if (onPort) try {
|
|
861
|
+
accepted = onPort(handle.port, envelope.meta) !== false;
|
|
862
|
+
} catch {
|
|
863
|
+
accepted = false;
|
|
864
|
+
}
|
|
865
|
+
if (!accepted) {
|
|
866
|
+
open.delete(envelope.id);
|
|
867
|
+
handle.markClosed();
|
|
868
|
+
post({
|
|
869
|
+
type: "close",
|
|
870
|
+
id: envelope.id,
|
|
871
|
+
reason: "rejected"
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const handle = open.get(envelope.id);
|
|
877
|
+
if (envelope.type === "message") {
|
|
878
|
+
if (!handle) return;
|
|
879
|
+
handle.deliver(envelope.payload);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (!handle) return;
|
|
883
|
+
open.delete(envelope.id);
|
|
884
|
+
handle.markClosed();
|
|
885
|
+
};
|
|
886
|
+
const listener = (event) => {
|
|
887
|
+
const envelope = codec.read(event);
|
|
888
|
+
if (envelope) handleEnvelope(envelope);
|
|
889
|
+
};
|
|
890
|
+
port.addEventListener("message", listener);
|
|
891
|
+
port.start?.();
|
|
892
|
+
return {
|
|
893
|
+
maxMessageSize,
|
|
894
|
+
async openPort(meta) {
|
|
895
|
+
if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
896
|
+
if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
|
|
897
|
+
while (open.has(nextId)) nextId += 2;
|
|
898
|
+
const id = nextId;
|
|
899
|
+
nextId += 2;
|
|
900
|
+
const handle = attach(id);
|
|
901
|
+
post({
|
|
902
|
+
type: "open",
|
|
903
|
+
id,
|
|
904
|
+
meta
|
|
905
|
+
});
|
|
906
|
+
return handle.port;
|
|
907
|
+
},
|
|
908
|
+
async close() {
|
|
909
|
+
if (muxClosed) return;
|
|
910
|
+
for (const [id, handle] of [...open]) {
|
|
911
|
+
post({
|
|
912
|
+
type: "close",
|
|
913
|
+
id
|
|
914
|
+
});
|
|
915
|
+
open.delete(id);
|
|
916
|
+
handle.markClosed();
|
|
917
|
+
}
|
|
918
|
+
muxClosed = true;
|
|
919
|
+
port.removeEventListener("message", listener);
|
|
920
|
+
await port.close?.();
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
//#endregion
|
|
925
|
+
//#region src/connect-serve.ts
|
|
926
|
+
/**
|
|
927
|
+
* Announced with every port this pair opens. Layer 1 never reads `meta` — it is
|
|
928
|
+
* here so a peer running something else over the same multiplexer can tell a
|
|
929
|
+
* stream port from whatever else it hands out, and so a packet capture says
|
|
930
|
+
* what the port is for.
|
|
931
|
+
*/
|
|
932
|
+
const STREAM_META = { kind: "stream" };
|
|
933
|
+
/**
|
|
934
|
+
* One port source in, one caller `Duplex` out.
|
|
935
|
+
*
|
|
936
|
+
* A call is a port: the mux allocates one, `duplexOverPort` runs the single
|
|
937
|
+
* invocation on it, and closing the stream closes the port. No stream ids, no
|
|
938
|
+
* framing and no credit accounting live here — the mux owns the first, and
|
|
939
|
+
* `duplexOverPort` owns the one-chunk window that makes memory bounded.
|
|
940
|
+
*/
|
|
941
|
+
const connect = async ({ mux: factory, timeout }) => {
|
|
942
|
+
const mux = await factory();
|
|
943
|
+
const streamOptions = {
|
|
944
|
+
maxMessageSize: mux.maxMessageSize,
|
|
945
|
+
timeout
|
|
946
|
+
};
|
|
947
|
+
const call = (input) => (async function* () {
|
|
948
|
+
yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
|
|
949
|
+
})();
|
|
950
|
+
return {
|
|
951
|
+
call,
|
|
952
|
+
async close() {
|
|
953
|
+
await mux.close();
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
};
|
|
957
|
+
/**
|
|
958
|
+
* One port source in, `handler` serving every call that arrives on it.
|
|
959
|
+
*
|
|
960
|
+
* Each inbound port is one invocation, so the factory's `onPort` is the accept
|
|
961
|
+
* loop: it installs `handler` on the new port and nothing else. The returned
|
|
962
|
+
* teardown abandons the streams still running *before* dropping the mux, so
|
|
963
|
+
* the peer's callers reject with "the peer abandoned the stream" instead of
|
|
964
|
+
* parking forever on a port that has silently gone inert — layer 1's close is
|
|
965
|
+
* not observable to layer 2, which is why the notice has to be posted
|
|
966
|
+
* deliberately.
|
|
967
|
+
*/
|
|
968
|
+
const serve = async ({ mux: factory, timeout }, handler) => {
|
|
969
|
+
const live = /* @__PURE__ */ new Set();
|
|
970
|
+
let streamOptions = { timeout };
|
|
971
|
+
const mux = await factory((port) => {
|
|
972
|
+
let off;
|
|
973
|
+
let finished = false;
|
|
974
|
+
const tracked = (input) => (async function* () {
|
|
975
|
+
try {
|
|
976
|
+
yield* handler(input);
|
|
977
|
+
} finally {
|
|
978
|
+
finished = true;
|
|
979
|
+
if (off) live.delete(off);
|
|
980
|
+
}
|
|
981
|
+
})();
|
|
982
|
+
off = serveDuplexOverPort(port, tracked, streamOptions);
|
|
983
|
+
if (!finished) live.add(off);
|
|
984
|
+
});
|
|
985
|
+
streamOptions = {
|
|
986
|
+
maxMessageSize: mux.maxMessageSize,
|
|
987
|
+
timeout
|
|
988
|
+
};
|
|
989
|
+
let torn = false;
|
|
990
|
+
return async () => {
|
|
991
|
+
if (torn) return;
|
|
992
|
+
torn = true;
|
|
993
|
+
for (const off of [...live]) {
|
|
994
|
+
live.delete(off);
|
|
995
|
+
try {
|
|
996
|
+
off();
|
|
997
|
+
} catch {}
|
|
998
|
+
}
|
|
999
|
+
await mux.close();
|
|
1000
|
+
};
|
|
1001
|
+
};
|
|
1002
|
+
/**
|
|
1003
|
+
* Ports over ONE PIPE OF BYTES — a `MessagePort`, a worker, a WebSocket.
|
|
1004
|
+
*
|
|
1005
|
+
* There is no second port to be had, so `multiplexPort`'s id table invents
|
|
1006
|
+
* them. Use this when the transport gives you exactly one channel.
|
|
1007
|
+
*/
|
|
1008
|
+
function overPipe(pipe, options) {
|
|
1009
|
+
return (onPort) => multiplexPort(pipe, {
|
|
1010
|
+
...options,
|
|
1011
|
+
onPort
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Ports from a source that already has them — a transferable boundary, or a
|
|
1016
|
+
* transport that multiplexes on its own (libp2p's yamux, say).
|
|
1017
|
+
*
|
|
1018
|
+
* A pass-through, so an adapter that builds its own `PortMux` plugs in without
|
|
1019
|
+
* this package learning what that transport is. It exists to make the
|
|
1020
|
+
* three-way choice legible at the call site rather than to do work:
|
|
1021
|
+
*
|
|
1022
|
+
* ```ts
|
|
1023
|
+
* await serve({ mux: overPorts((onPort) => libp2pPortMux({ node, onPort })) }, handler);
|
|
1024
|
+
* ```
|
|
1025
|
+
*/
|
|
1026
|
+
function overPorts(factory) {
|
|
1027
|
+
return factory;
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
1030
|
+
//#region src/io-handle.ts
|
|
1031
|
+
/**
|
|
1032
|
+
* Server half of a full-duplex exchange over any `MessageTarget`.
|
|
1033
|
+
*
|
|
1034
|
+
* For each inbound stream, invokes `handler` with the stream, sends the
|
|
1035
|
+
* handler's output back, and yields a counter. The generator never ends
|
|
1036
|
+
* on its own — consumers break when they want to stop. Pairs with
|
|
1037
|
+
* {@link ioSend}.
|
|
1038
|
+
*
|
|
1039
|
+
* If the peer (the consumer of our outbound send) posts a `cancel-channel`
|
|
1040
|
+
* message on the same sub-channel, we abort `send` immediately. This makes
|
|
1041
|
+
* `ioSend`'s `iter.return()` propagate cleanly without waiting for
|
|
1042
|
+
* `callPort` timeouts.
|
|
1043
|
+
*/
|
|
1044
|
+
async function* ioHandle(port, handler, options = {}) {
|
|
1045
|
+
let counter = 0;
|
|
1046
|
+
const channelName = options.channelName ?? "";
|
|
1047
|
+
for await (const input of recieve(port, options)) {
|
|
1048
|
+
const sendAbort = new AbortController();
|
|
1049
|
+
const unsubscribeCancel = channelName ? listenCancelChannel(port, channelName, () => sendAbort.abort()) : () => {};
|
|
1050
|
+
try {
|
|
1051
|
+
await send(port, await handler(input), {
|
|
1052
|
+
...options,
|
|
1053
|
+
signal: sendAbort.signal
|
|
1054
|
+
});
|
|
1055
|
+
} finally {
|
|
1056
|
+
unsubscribeCancel();
|
|
1057
|
+
}
|
|
1058
|
+
yield counter++;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
//#endregion
|
|
1062
|
+
//#region src/listen-bidi.ts
|
|
1063
|
+
/**
|
|
1064
|
+
* Server half of {@link callBidi}: listens for stream-call requests on `port`
|
|
1065
|
+
* and dispatches each accepted one to `action`.
|
|
1066
|
+
*
|
|
1067
|
+
* The optional `accept` predicate can inspect the incoming params and reject
|
|
1068
|
+
* unwanted calls. Returns a cleanup function that removes the listener.
|
|
1069
|
+
*/
|
|
1070
|
+
function listenBidi(port, action, accept = () => true) {
|
|
1071
|
+
return listenPort(port, async (params) => {
|
|
1072
|
+
if (!params || typeof params.channelName !== "string") return;
|
|
1073
|
+
if (!accept(params)) return;
|
|
1074
|
+
const handler = async (input) => action(input, params);
|
|
1075
|
+
for await (const _idx of ioHandle(port, handler, params)) break;
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region src/structured-codec.ts
|
|
1080
|
+
function isEnvelope(value) {
|
|
1081
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1082
|
+
const candidate = value;
|
|
1083
|
+
if (typeof candidate.id !== "number") return false;
|
|
1084
|
+
if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
|
|
1085
|
+
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* For ports whose messages are structured values — a real `MessagePort`, a
|
|
1089
|
+
* worker, an iframe.
|
|
1090
|
+
*
|
|
1091
|
+
* Envelopes are posted as-is, so nothing is encoded, `ArrayBuffer`s move
|
|
1092
|
+
* zero-copy through the transfer list, and structured clone does the work the
|
|
1093
|
+
* platform already does well. This is a performance choice only: layer 2 may
|
|
1094
|
+
* not send anything a byte codec could not also carry.
|
|
1095
|
+
*/
|
|
1096
|
+
const structuredCodec = {
|
|
1097
|
+
post(port, envelope, transfer) {
|
|
1098
|
+
if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
|
|
1099
|
+
else port.postMessage(envelope);
|
|
1100
|
+
},
|
|
1101
|
+
read(event) {
|
|
1102
|
+
return isEnvelope(event.data) ? event.data : void 0;
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
//#endregion
|
|
1106
|
+
//#region src/transfer-port-mux.ts
|
|
1107
|
+
/** The `type` of the envelope that carries a transferred port to the peer. */
|
|
1108
|
+
const PORT_TRANSFER = "webrun-rpc:port-transfer";
|
|
1109
|
+
/**
|
|
1110
|
+
* A `PortMux` whose ports are real, transferred `MessagePort`s (spec D23).
|
|
1111
|
+
*
|
|
1112
|
+
* `openPort` creates a `MessageChannel`, transfers one end to the peer over
|
|
1113
|
+
* `target`, and returns the other. There is no id table, no `maxPorts` and no
|
|
1114
|
+
* envelope overhead per message, because the platform does the multiplexing.
|
|
1115
|
+
*
|
|
1116
|
+
* **It needs structured clone with transferables**, so it exists in browsers,
|
|
1117
|
+
* workers and iframes and nowhere else that lacks them. A caller selects it
|
|
1118
|
+
* explicitly rather than by capability sniffing (spec D21): use
|
|
1119
|
+
* `multiplexPort` where the transport is one pipe of bytes.
|
|
1120
|
+
*
|
|
1121
|
+
* What it buys over emulation: a transferred port can cross an origin or a
|
|
1122
|
+
* worker boundary and be handed to code that never saw `target`, where an
|
|
1123
|
+
* emulated port id is meaningless outside its own mux.
|
|
1124
|
+
*
|
|
1125
|
+
* `target` must be a full `MessageTarget`. Reaching a send-only `MessageSink`
|
|
1126
|
+
* — a `ServiceWorkerClient`, say — is a real use of port transfer but needs a
|
|
1127
|
+
* different entry point, and is not part of this interface.
|
|
1128
|
+
*/
|
|
1129
|
+
function transferPortMux(target, options = {}) {
|
|
1130
|
+
const { onPort, maxMessageSize } = options;
|
|
1131
|
+
const issued = /* @__PURE__ */ new Set();
|
|
1132
|
+
let closed = false;
|
|
1133
|
+
const listener = (event) => {
|
|
1134
|
+
if (closed) return;
|
|
1135
|
+
const data = event.data;
|
|
1136
|
+
if (!data || typeof data !== "object" || data.type !== "webrun-rpc:port-transfer") return;
|
|
1137
|
+
const port = event.ports?.[0];
|
|
1138
|
+
if (!port) return;
|
|
1139
|
+
port.start();
|
|
1140
|
+
let accepted = false;
|
|
1141
|
+
if (onPort) try {
|
|
1142
|
+
accepted = onPort(port, data.meta) !== false;
|
|
1143
|
+
} catch {
|
|
1144
|
+
accepted = false;
|
|
1145
|
+
}
|
|
1146
|
+
if (!accepted) {
|
|
1147
|
+
port.close();
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
issued.add(port);
|
|
1151
|
+
};
|
|
1152
|
+
target.addEventListener("message", listener);
|
|
1153
|
+
target.start?.();
|
|
1154
|
+
return {
|
|
1155
|
+
maxMessageSize,
|
|
1156
|
+
async openPort(meta) {
|
|
1157
|
+
if (closed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
1158
|
+
const channel = new MessageChannel();
|
|
1159
|
+
channel.port1.start();
|
|
1160
|
+
target.postMessage({
|
|
1161
|
+
type: PORT_TRANSFER,
|
|
1162
|
+
meta
|
|
1163
|
+
}, [channel.port2]);
|
|
1164
|
+
issued.add(channel.port1);
|
|
1165
|
+
return channel.port1;
|
|
1166
|
+
},
|
|
1167
|
+
async close() {
|
|
1168
|
+
if (closed) return;
|
|
1169
|
+
closed = true;
|
|
1170
|
+
target.removeEventListener("message", listener);
|
|
1171
|
+
for (const port of issued) try {
|
|
1172
|
+
port.close();
|
|
1173
|
+
} catch {}
|
|
1174
|
+
issued.clear();
|
|
1175
|
+
await target.close?.();
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
//#endregion
|
|
1180
|
+
export { CANCEL_CHANNEL_TYPE, DEFAULT_MAX_PORTS, NO_TIMEOUT, PORT_TRANSFER, STREAM_ABORT, byteChannelFromMessagePort, callBidi, callPort, connect, duplexOverPort, getPortCloseSignal, ioHandle, ioSend, listenBidi, listenCancelChannel, listenPort, multiplexPort, overPipe, overPorts, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux };
|