@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.
Files changed (66) hide show
  1. package/README.md +549 -0
  2. package/dist/byte-channel.d.ts +15 -0
  3. package/dist/byte-channel.d.ts.map +1 -0
  4. package/dist/call-bidi.d.ts +25 -0
  5. package/dist/call-bidi.d.ts.map +1 -0
  6. package/dist/call-port.d.ts +37 -0
  7. package/dist/call-port.d.ts.map +1 -0
  8. package/dist/cancel-channel.d.ts +15 -0
  9. package/dist/cancel-channel.d.ts.map +1 -0
  10. package/dist/close-signal.d.ts +36 -0
  11. package/dist/close-signal.d.ts.map +1 -0
  12. package/dist/connect-serve.d.ts +104 -0
  13. package/dist/connect-serve.d.ts.map +1 -0
  14. package/dist/duplex-over-port.d.ts +49 -0
  15. package/dist/duplex-over-port.d.ts.map +1 -0
  16. package/dist/index.d.ts +19 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +1180 -0
  19. package/dist/io-handle.d.ts +17 -0
  20. package/dist/io-handle.d.ts.map +1 -0
  21. package/dist/io-send.d.ts +26 -0
  22. package/dist/io-send.d.ts.map +1 -0
  23. package/dist/listen-bidi.d.ts +11 -0
  24. package/dist/listen-bidi.d.ts.map +1 -0
  25. package/dist/listen-port.d.ts +16 -0
  26. package/dist/listen-port.d.ts.map +1 -0
  27. package/dist/message-target.d.ts +16 -0
  28. package/dist/message-target.d.ts.map +1 -0
  29. package/dist/multiplex-port.d.ts +12 -0
  30. package/dist/multiplex-port.d.ts.map +1 -0
  31. package/dist/port-types.d.ts +86 -0
  32. package/dist/port-types.d.ts.map +1 -0
  33. package/dist/recieve.d.ts +35 -0
  34. package/dist/recieve.d.ts.map +1 -0
  35. package/dist/send.d.ts +23 -0
  36. package/dist/send.d.ts.map +1 -0
  37. package/dist/structured-codec.d.ts +12 -0
  38. package/dist/structured-codec.d.ts.map +1 -0
  39. package/dist/through-abort.d.ts +8 -0
  40. package/dist/through-abort.d.ts.map +1 -0
  41. package/dist/transfer-port-mux.d.ts +39 -0
  42. package/dist/transfer-port-mux.d.ts.map +1 -0
  43. package/dist/virtual-port.d.ts +19 -0
  44. package/dist/virtual-port.d.ts.map +1 -0
  45. package/package.json +51 -0
  46. package/src/byte-channel.ts +109 -0
  47. package/src/call-bidi.ts +60 -0
  48. package/src/call-port.ts +119 -0
  49. package/src/cancel-channel.ts +42 -0
  50. package/src/close-signal.ts +43 -0
  51. package/src/connect-serve.ts +208 -0
  52. package/src/duplex-over-port.ts +471 -0
  53. package/src/index.ts +29 -0
  54. package/src/io-handle.ts +40 -0
  55. package/src/io-send.ts +70 -0
  56. package/src/listen-bidi.ts +31 -0
  57. package/src/listen-port.ts +47 -0
  58. package/src/message-target.ts +18 -0
  59. package/src/multiplex-port.ts +134 -0
  60. package/src/port-types.ts +80 -0
  61. package/src/recieve.ts +89 -0
  62. package/src/send.ts +60 -0
  63. package/src/structured-codec.ts +30 -0
  64. package/src/through-abort.ts +32 -0
  65. package/src/transfer-port-mux.ts +106 -0
  66. 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 };