@statewalker/webrun-streams-webrtc 0.3.0 → 0.4.1

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 CHANGED
@@ -144,7 +144,10 @@ Two limits keep memory bounded whatever the speed of either side:
144
144
  the previous one, so a slow handler stalls the caller.
145
145
 
146
146
  Both ends must speak `CREDIT`: a sender facing a peer that never returns
147
- credit stops after the first window.
147
+ credit stops after the first window. The receiver enforces the window too: a
148
+ peer that sends more than the window plus the credit it was returned fails the
149
+ call. The receiver drops what it queued, closes the channel, and its side of
150
+ the call throws (on the responder, the handler's input throws).
148
151
 
149
152
  Each side keeps listening for `CREDIT` until both directions are done, so a
150
153
  reply larger than the window, sent after the input ended, still flows. When
@@ -213,10 +216,15 @@ a one-chunk bound.
213
216
 
214
217
  `END` means "I am done sending", and the peer answers it by carrying on, so it
215
218
  cannot express "stop". When the caller leaves early (`.return()` on the call's
216
- generator, or an error), `duplexOverDataChannel` waits for its own outbound
217
- pump to settle and then closes the channel. On the responder, the channel's
218
- `close` event ends the inbound side with `TransportClosedError`, which closes
219
- the handler's input and runs the handler's `finally`.
219
+ generator, or an error), `duplexOverDataChannel` closes the channel and then
220
+ waits for its own outbound pump to settle. On the responder, the channel's
221
+ `close` event ends the inbound side with `TransportClosedError`; a caller whose
222
+ input throws sends `ERROR` instead, which ends it with that error. Either way
223
+ the handler's input ends with the error (its next read throws it, so a handler
224
+ never takes a truncated upload for a complete one) before the responder waits
225
+ for its own outbound pump, which may be waiting for the handler's next output.
226
+ The handler's `finally` then runs, whether it was reading its input or
227
+ producing output.
220
228
 
221
229
  On a normal finish the outbound pump is not awaited. The responder feeds the
222
230
  handler's input from this generator and closes that input only when the
@@ -243,6 +251,7 @@ SDP, must be opened before `serve` is registered or on a different connection.
243
251
  | Channel closes before it opens | `DataChannel closed before open` |
244
252
  | Channel error before it opens | `DataChannel error: <message or "unknown">` |
245
253
  | Channel closes before the peer sent `END` | `TransportClosedError` (`transport closed`) |
254
+ | The peer sends past the receive window without credit | `webrun-streams-webrtc: the peer sent past the receive window (4194304 bytes) without credit` |
246
255
  | Handler throws | The caller's `for await` throws the deserialised error, with `message`, `stack` and custom fields |
247
256
 
248
257
  On the responder, a channel task that fails (usually a caller that cancelled)
@@ -1 +1 @@
1
- {"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAI1E,MAAM,WAAW,YAAY;IAC3B,wGAAwG;IACxG,EAAE,EAAE,iBAAiB,CAAC;CACvB;AAED;;;;GAIG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,YAAY,CA0CzC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,YAAY,CA0CrC,CAAC"}
1
+ {"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAS1E,MAAM,WAAW,YAAY;IAC3B,wGAAwG;IACxG,EAAE,EAAE,iBAAiB,CAAC;CACvB;AAED;;;;GAIG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,YAAY,CAyCzC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,YAAY,CA+CrC,CAAC"}
@@ -12,6 +12,9 @@ export declare const LOW_WATER_MARK: number;
12
12
  * whether or not anyone reads, so without credit a slow reader's queue grows without bound. The
13
13
  * window is larger than the send buffer's high-water mark so a long round trip does not throttle a
14
14
  * fast link: the buffer limit is what bounds the sender's memory.
15
+ *
16
+ * The receiver enforces it: a peer that sends more than the window plus the credit returned fails
17
+ * the call (the queued payload is dropped, the stream throws, the channel is closed).
15
18
  */
16
19
  export declare const RECEIVE_WINDOW: number;
17
20
  export declare function markAnswering(dc: RTCDataChannel): void;
@@ -34,4 +37,15 @@ export declare function holdEvents(dc: RTCDataChannel): void;
34
37
  * closed once both sides have ended.
35
38
  */
36
39
  export declare function duplexOverDataChannel(dc: RTCDataChannel, input: AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array>;
40
+ export interface RunStreamHooks {
41
+ /**
42
+ * Called once when the inbound side fails (the peer's `ERROR`, the channel closing, a peer
43
+ * past the receive window), before the stream waits for its outbound pump to settle. A
44
+ * responder ends its handler's input here: the pump may be waiting for the handler's next
45
+ * output while the handler waits for its next input.
46
+ */
47
+ onInboundFailed?(err: Error): void;
48
+ }
49
+ /** {@link duplexOverDataChannel}, with hooks for `serve` */
50
+ export declare function runStream(dc: RTCDataChannel, input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>, hooks?: RunStreamHooks): AsyncGenerator<Uint8Array>;
37
51
  //# sourceMappingURL=duplex-over-data-channel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"duplex-over-data-channel.d.ts","sourceRoot":"","sources":["../src/duplex-over-data-channel.ts"],"names":[],"mappings":"AAcA;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAc,CAAC;AAC3C,eAAO,MAAM,cAAc,QAAa,CAAC;AACzC;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,QAAkB,CAAC;AAoB9C,wBAAgB,aAAa,CAAC,EAAE,EAAE,cAAc,GAAG,IAAI,CAEtD;AAED,uFAAuF;AACvF,wBAAgB,UAAU,CAAC,EAAE,EAAE,cAAc,GAAG,IAAI,CAUnD;AAWD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAC/B,cAAc,CAAC,UAAU,CAAC,CAE5B"}
1
+ {"version":3,"file":"duplex-over-data-channel.d.ts","sourceRoot":"","sources":["../src/duplex-over-data-channel.ts"],"names":[],"mappings":"AAcA;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAc,CAAC;AAC3C,eAAO,MAAM,cAAc,QAAa,CAAC;AACzC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc,QAAkB,CAAC;AAoB9C,wBAAgB,aAAa,CAAC,EAAE,EAAE,cAAc,GAAG,IAAI,CAEtD;AAED,uFAAuF;AACvF,wBAAgB,UAAU,CAAC,EAAE,EAAE,cAAc,GAAG,IAAI,CAUnD;AAWD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAC/B,cAAc,CAAC,UAAU,CAAC,CAE5B;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,eAAe,CAAC,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,CAAC;CACpC;AAED,4DAA4D;AAC5D,wBAAuB,SAAS,CAC9B,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,EACvD,KAAK,GAAE,cAAmB,GACzB,cAAc,CAAC,UAAU,CAAC,CA4O5B"}
package/dist/index.js CHANGED
@@ -12,6 +12,9 @@ const LOW_WATER_MARK = 262144;
12
12
  * whether or not anyone reads, so without credit a slow reader's queue grows without bound. The
13
13
  * window is larger than the send buffer's high-water mark so a long round trip does not throttle a
14
14
  * fast link: the buffer limit is what bounds the sender's memory.
15
+ *
16
+ * The receiver enforces it: a peer that sends more than the window plus the credit returned fails
17
+ * the call (the queued payload is dropped, the stream throws, the channel is closed).
15
18
  */
16
19
  const RECEIVE_WINDOW = 4194304;
17
20
  const textEncoder = new TextEncoder();
@@ -74,13 +77,17 @@ function takeHeld(dc) {
74
77
  function duplexOverDataChannel(dc, input) {
75
78
  return runStream(dc, input);
76
79
  }
77
- async function* runStream(dc, input) {
80
+ /** {@link duplexOverDataChannel}, with hooks for `serve` */
81
+ async function* runStream(dc, input, hooks = {}) {
78
82
  dc.binaryType = "arraybuffer";
79
83
  const incoming = makeInboundQueue();
80
84
  let peerEnded = false;
81
85
  let localEnded = false;
82
86
  let credit = RECEIVE_WINDOW;
83
87
  let pulled = 0;
88
+ /** what the peer may still send us: the window plus the credit we returned, less what came */
89
+ let allowance = RECEIVE_WINDOW;
90
+ let overrun = false;
84
91
  let wakeSender = null;
85
92
  const nudge = () => {
86
93
  const w = wakeSender;
@@ -108,10 +115,11 @@ async function* runStream(dc, input) {
108
115
  const frame = /* @__PURE__ */ new Uint8Array(5);
109
116
  frame[0] = TYPE_CREDIT;
110
117
  new DataView(frame.buffer).setUint32(1, pulled);
111
- pulled = 0;
112
118
  try {
113
119
  sendRaw(frame);
120
+ allowance += pulled;
114
121
  } catch {}
122
+ pulled = 0;
115
123
  };
116
124
  const maybeClose = () => {
117
125
  if (peerEnded && localEnded && !answering.has(dc)) try {
@@ -130,8 +138,22 @@ async function* runStream(dc, input) {
130
138
  if (bytes.byteLength === 0) return;
131
139
  const type = bytes[0];
132
140
  const payload = bytes.subarray(1);
133
- if (type === TYPE_DATA) incoming.push(new Uint8Array(payload));
134
- else if (type === TYPE_END) {
141
+ if (type === TYPE_DATA) {
142
+ if (overrun) return;
143
+ allowance -= payload.byteLength;
144
+ if (allowance < 0) {
145
+ overrun = true;
146
+ peerEnded = true;
147
+ incoming.fail(/* @__PURE__ */ new Error(`webrun-streams-webrtc: the peer sent past the receive window (${RECEIVE_WINDOW} bytes) without credit`));
148
+ aborted = true;
149
+ nudge();
150
+ try {
151
+ dc.close();
152
+ } catch {}
153
+ return;
154
+ }
155
+ incoming.push(new Uint8Array(payload));
156
+ } else if (type === TYPE_END) {
135
157
  peerEnded = true;
136
158
  incoming.done();
137
159
  maybeClose();
@@ -202,12 +224,16 @@ async function* runStream(dc, input) {
202
224
  }
203
225
  })();
204
226
  let inboundDrained = false;
227
+ let inboundError;
205
228
  try {
206
229
  for await (const chunk of incoming.iterate()) {
207
230
  yield chunk;
208
231
  consumed(chunk.byteLength);
209
232
  }
210
233
  inboundDrained = true;
234
+ } catch (err) {
235
+ inboundError = err instanceof Error ? err : new Error(String(err));
236
+ throw err;
211
237
  } finally {
212
238
  inboundFinished = true;
213
239
  detach();
@@ -217,6 +243,7 @@ async function* runStream(dc, input) {
217
243
  try {
218
244
  dc.close();
219
245
  } catch {}
246
+ if (inboundError) hooks.onInboundFailed?.(inboundError);
220
247
  await outbound.catch(() => {});
221
248
  }
222
249
  }
@@ -242,6 +269,15 @@ function makeInboundQueue() {
242
269
  });
243
270
  wake?.();
244
271
  },
272
+ fail(err) {
273
+ if (closed) return;
274
+ slots.length = 0;
275
+ slots.push({
276
+ type: "done",
277
+ err
278
+ });
279
+ wake?.();
280
+ },
245
281
  iterate() {
246
282
  return (async function* () {
247
283
  try {
@@ -297,7 +333,8 @@ const claimed = /* @__PURE__ */ new WeakSet();
297
333
  * ```
298
334
  *
299
335
  * Messages that arrive before a port's first listener are held for it and delivered after that
300
- * tick, to every listener added by then.
336
+ * tick, to every listener added by then. Messages posted before a port's channel opens are queued
337
+ * and sent, in order, when it opens.
301
338
  */
302
339
  async function webrtcPortMux(options) {
303
340
  const { pc, codec, onPort } = options;
@@ -340,9 +377,26 @@ async function webrtcPortMux(options) {
340
377
  if (held) held.push(data);
341
378
  else deliver(data);
342
379
  };
380
+ let queued = dc.readyState === "connecting" ? [] : void 0;
381
+ const send = (bytes) => {
382
+ try {
383
+ dc.send(bytes);
384
+ } catch (err) {
385
+ end(err instanceof Error ? err : new Error(String(err)));
386
+ }
387
+ };
388
+ const onOpen = () => {
389
+ const pending = queued;
390
+ queued = void 0;
391
+ for (const bytes of pending ?? []) {
392
+ if (closeSignal.aborted) return;
393
+ send(bytes);
394
+ }
395
+ };
343
396
  const onClose = () => end(/* @__PURE__ */ new Error("webrun-streams-webrtc: the data channel closed"));
344
397
  const onError = (ev) => end(/* @__PURE__ */ new Error(`webrun-streams-webrtc: the data channel failed: ${ev.error?.message ?? "unknown"}`));
345
398
  dc.addEventListener("message", onMessage);
399
+ dc.addEventListener("open", onOpen);
346
400
  dc.addEventListener("close", onClose);
347
401
  dc.addEventListener("error", onError);
348
402
  if (dc.readyState === "closing" || dc.readyState === "closed") onClose();
@@ -350,6 +404,8 @@ async function webrtcPortMux(options) {
350
404
  const cleanUp = () => {
351
405
  live.delete(port);
352
406
  dc.removeEventListener("message", onMessage);
407
+ dc.removeEventListener("open", onOpen);
408
+ queued = void 0;
353
409
  listeners.clear();
354
410
  held = void 0;
355
411
  if (dc.readyState !== "closed") dc.close();
@@ -372,18 +428,16 @@ async function webrtcPortMux(options) {
372
428
  });
373
429
  },
374
430
  postMessage(message) {
375
- if (closeSignal.aborted || dc.readyState !== "open") return;
431
+ if (closeSignal.aborted) return;
432
+ if (!queued && dc.readyState !== "open") return;
376
433
  const bytes = codec.encode(message);
377
434
  if (bytes.byteLength > maxFrameBytes) {
378
435
  const err = /* @__PURE__ */ new RangeError(`webrun-streams-webrtc: a ${bytes.byteLength}-byte message exceeds maxFrameBytes (${maxFrameBytes})`);
379
436
  end(err);
380
437
  throw err;
381
438
  }
382
- try {
383
- dc.send(bytes);
384
- } catch (err) {
385
- end(err instanceof Error ? err : new Error(String(err)));
386
- }
439
+ if (queued) queued.push(bytes);
440
+ else send(bytes);
387
441
  },
388
442
  addEventListener(_type, listener) {
389
443
  listeners.add(listener);
@@ -483,12 +537,10 @@ const connect = async ({ pc }) => {
483
537
  let counter = 0;
484
538
  let closed = false;
485
539
  const open = /* @__PURE__ */ new Set();
486
- const onConnectionClose = () => {
487
- closed = true;
540
+ const onStateChange = () => {
541
+ if (pc.connectionState === "closed" || pc.connectionState === "failed") closed = true;
488
542
  };
489
- pc.addEventListener("connectionstatechange", () => {
490
- if (pc.connectionState === "closed" || pc.connectionState === "failed") onConnectionClose();
491
- });
543
+ pc.addEventListener("connectionstatechange", onStateChange);
492
544
  const call = (input) => {
493
545
  if (closed) return (async function* () {
494
546
  throw new Error("webrun-streams-webrtc: connection closed");
@@ -507,6 +559,7 @@ const connect = async ({ pc }) => {
507
559
  call,
508
560
  async close() {
509
561
  closed = true;
562
+ pc.removeEventListener("connectionstatechange", onStateChange);
510
563
  for (const dc of open) try {
511
564
  dc.close();
512
565
  } catch {}
@@ -537,10 +590,11 @@ const serve = async ({ pc }, handler) => {
537
590
  peek.release();
538
591
  }
539
592
  })();
540
- for await (const chunk of duplexOverDataChannel(dc, out)) await inputFromPeer.deliver(chunk);
541
- inputFromPeer.done();
542
- })().catch(() => {
543
- inputFromPeer?.done();
593
+ const inbound = runStream(dc, out, { onInboundFailed: (err) => peek.done(err) });
594
+ for await (const chunk of inbound) await peek.deliver(chunk);
595
+ peek.done();
596
+ })().catch((err) => {
597
+ inputFromPeer?.done(err instanceof Error ? err : new Error(String(err)));
544
598
  });
545
599
  };
546
600
  pc.addEventListener("datachannel", onChannel);
@@ -597,6 +651,7 @@ function peekInput(_dc) {
597
651
  const slots = [];
598
652
  let wake = null;
599
653
  let closed = false;
654
+ let ended = false;
600
655
  const input = (async function* () {
601
656
  try {
602
657
  while (true) {
@@ -609,7 +664,10 @@ function peekInput(_dc) {
609
664
  }
610
665
  const slot = slots.shift();
611
666
  if (!slot) continue;
612
- if (slot.type === "done") return;
667
+ if (slot.type === "done") {
668
+ if (slot.err) throw slot.err;
669
+ return;
670
+ }
613
671
  slot.taken();
614
672
  yield slot.value;
615
673
  }
@@ -635,9 +693,13 @@ function peekInput(_dc) {
635
693
  wake?.();
636
694
  });
637
695
  },
638
- done() {
639
- if (closed) return;
640
- slots.push({ type: "done" });
696
+ done(err) {
697
+ if (closed || ended) return;
698
+ ended = true;
699
+ slots.push({
700
+ type: "done",
701
+ err
702
+ });
641
703
  wake?.();
642
704
  },
643
705
  release
@@ -67,7 +67,8 @@ export interface WebRtcPortMux {
67
67
  * ```
68
68
  *
69
69
  * Messages that arrive before a port's first listener are held for it and delivered after that
70
- * tick, to every listener added by then.
70
+ * tick, to every listener added by then. Messages posted before a port's channel opens are queued
71
+ * and sent, in order, when it opens.
71
72
  */
72
73
  export declare function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebRtcPortMux>;
73
74
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"port-mux.d.ts","sourceRoot":"","sources":["../src/port-mux.ts"],"names":[],"mappings":"AAAA,yFAAyF;AACzF,eAAO,MAAM,iBAAiB,iBAAiB,CAAC;AAEhD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,QAAY,CAAC;AAEtD,sHAAsH;AACtH,eAAO,MAAM,6BAA6B,QAAqC,CAAC;AAEhF,kFAAkF;AAClF,eAAO,MAAM,oBAAoB,QAAa,CAAC;AAE/C;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAAC;IACnC,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC;CACpC;AAED,KAAK,QAAQ,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE9D,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC5D,mBAAmB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC/D,+EAA+E;IAC/E,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,gGAAgG;IAChG,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;CACnC;AAED,MAAM,WAAW,oBAAoB;IACnC,wFAAwF;IACxF,EAAE,EAAE,iBAAiB,CAAC;IACtB,kDAAkD;IAClD,KAAK,EAAE,UAAU,CAAC;IAClB;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,OAAO,GAAG,SAAS,CAAC;IACxD,mFAAmF;IACnF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oDAAoD;IACpD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IACvF,KAAK,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,aAAa,CAAC,CA6MzF"}
1
+ {"version":3,"file":"port-mux.d.ts","sourceRoot":"","sources":["../src/port-mux.ts"],"names":[],"mappings":"AAAA,yFAAyF;AACzF,eAAO,MAAM,iBAAiB,iBAAiB,CAAC;AAEhD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,QAAY,CAAC;AAEtD,sHAAsH;AACtH,eAAO,MAAM,6BAA6B,QAAqC,CAAC;AAEhF,kFAAkF;AAClF,eAAO,MAAM,oBAAoB,QAAa,CAAC;AAE/C;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAAC;IACnC,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC;CACpC;AAED,KAAK,QAAQ,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE9D,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC5D,mBAAmB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC/D,+EAA+E;IAC/E,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,gGAAgG;IAChG,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;CACnC;AAED,MAAM,WAAW,oBAAoB;IACnC,wFAAwF;IACxF,EAAE,EAAE,iBAAiB,CAAC;IACtB,kDAAkD;IAClD,KAAK,EAAE,UAAU,CAAC;IAClB;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,OAAO,GAAG,SAAS,CAAC;IACxD,mFAAmF;IACnF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oDAAoD;IACpD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IACvF,KAAK,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAMD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,aAAa,CAAC,CAkOzF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-streams-webrtc",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "WebRTC DataChannel-backed Connect/Serve adapter (native multi-stream) in the webrun-streams-* family",
@@ -30,7 +30,7 @@
30
30
  "src"
31
31
  ],
32
32
  "dependencies": {
33
- "@statewalker/webrun-streams": "^0.3.0"
33
+ "@statewalker/webrun-streams": "^0.4.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@biomejs/biome": "^2.5.15",
@@ -42,9 +42,9 @@
42
42
  "rolldown": "^1.2.12",
43
43
  "typescript": "^7.0.2",
44
44
  "vitest": "^5.0.5",
45
- "@statewalker/webrun-msgpack": "^0.10.0",
46
- "@statewalker/webrun-streams-conformance": "^0.4.0",
47
- "@statewalker/webrun-rpc": "^0.11.0"
45
+ "@statewalker/webrun-msgpack": "^0.11.0",
46
+ "@statewalker/webrun-streams-conformance": "^0.5.0",
47
+ "@statewalker/webrun-rpc": "^0.12.0"
48
48
  },
49
49
  "sideEffects": false,
50
50
  "publishConfig": {
@@ -55,6 +55,8 @@
55
55
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
56
56
  "test": "vitest run",
57
57
  "test:browser": "vitest run --config vitest.browser.config.ts",
58
+ "typecheck": "tsc --noEmit",
59
+ "typecheck:tests": "tsc -p tsconfig.tests.json",
58
60
  "lint": "biome check src tests"
59
61
  }
60
62
  }
@@ -1,5 +1,10 @@
1
1
  import type { Connect, Duplex, Serve } from "@statewalker/webrun-streams";
2
- import { duplexOverDataChannel, holdEvents, markAnswering } from "./duplex-over-data-channel.js";
2
+ import {
3
+ duplexOverDataChannel,
4
+ holdEvents,
5
+ markAnswering,
6
+ runStream,
7
+ } from "./duplex-over-data-channel.js";
3
8
  import { PORT_LABEL_PREFIX } from "./port-mux.js";
4
9
 
5
10
  export interface WebRtcParams {
@@ -16,12 +21,10 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
16
21
  let counter = 0;
17
22
  let closed = false;
18
23
  const open = new Set<RTCDataChannel>();
19
- const onConnectionClose = (): void => {
20
- closed = true;
24
+ const onStateChange = (): void => {
25
+ if (pc.connectionState === "closed" || pc.connectionState === "failed") closed = true;
21
26
  };
22
- pc.addEventListener("connectionstatechange", () => {
23
- if (pc.connectionState === "closed" || pc.connectionState === "failed") onConnectionClose();
24
- });
27
+ pc.addEventListener("connectionstatechange", onStateChange);
25
28
 
26
29
  const call: Duplex = (input) => {
27
30
  if (closed) {
@@ -45,6 +48,7 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
45
48
  call,
46
49
  async close() {
47
50
  closed = true;
51
+ pc.removeEventListener("connectionstatechange", onStateChange);
48
52
  for (const dc of open) {
49
53
  try {
50
54
  dc.close();
@@ -82,18 +86,23 @@ export const serve: Serve<WebRtcParams> = async ({ pc }, handler: Duplex) => {
82
86
  peek.release();
83
87
  }
84
88
  })();
85
- // the next chunk is pulled (and its credit returned) only once the handler took this one
86
- for await (const chunk of duplexOverDataChannel(dc, out)) {
87
- await inputFromPeer.deliver(chunk);
89
+ // The next chunk is pulled (and its credit returned) only once the handler took this one.
90
+ // When the inbound side fails (the caller cancelled and closed the channel, or its input
91
+ // threw), the handler's input ends with that error before the stream waits for its outbound
92
+ // pump: the pump may be waiting for the handler's output, the handler for its input.
93
+ const inbound = runStream(dc, out, { onInboundFailed: (err) => peek.done(err) });
94
+ for await (const chunk of inbound) {
95
+ await peek.deliver(chunk);
88
96
  }
89
- inputFromPeer.done();
90
- })().catch(() => {
97
+ peek.done();
98
+ })().catch((err: unknown) => {
91
99
  // A caller that cancels mid-response closes the DataChannel, which
92
100
  // surfaces here as TransportClosedError. That is the cancellation
93
101
  // working, not a fault: this task is fire-and-forget, so without a
94
- // catch it becomes an unhandled rejection that fails the page. Closing
95
- // the handler's input is what runs the handler's own `finally`.
96
- inputFromPeer?.done();
102
+ // catch it becomes an unhandled rejection that fails the page. Ending
103
+ // the handler's input (with the error, so a handler does not take a
104
+ // truncated upload for a complete one) is what runs its own `finally`.
105
+ inputFromPeer?.done(err instanceof Error ? err : new Error(String(err)));
97
106
  });
98
107
  };
99
108
  pc.addEventListener("datachannel", onChannel);
@@ -155,7 +164,8 @@ interface PeekInput {
155
164
  input: AsyncGenerator<Uint8Array>;
156
165
  /** resolves once the handler has taken the chunk (or stopped reading) */
157
166
  deliver(chunk: Uint8Array): Promise<void>;
158
- done(): void;
167
+ /** ends the input after the chunks already delivered; with `err`, the handler's read throws it */
168
+ done(err?: Error): void;
159
169
  /** the handler is finished: pending and later chunks are dropped */
160
170
  release(): void;
161
171
  }
@@ -171,10 +181,12 @@ interface PeekInput {
171
181
  * the handler's input queue.
172
182
  */
173
183
  function peekInput(_dc: RTCDataChannel): PeekInput {
174
- const slots: Array<{ type: "value"; value: Uint8Array; taken: () => void } | { type: "done" }> =
175
- [];
184
+ const slots: Array<
185
+ { type: "value"; value: Uint8Array; taken: () => void } | { type: "done"; err?: Error }
186
+ > = [];
176
187
  let wake: (() => void) | null = null;
177
188
  let closed = false;
189
+ let ended = false;
178
190
  const input = (async function* () {
179
191
  try {
180
192
  while (true) {
@@ -187,7 +199,10 @@ function peekInput(_dc: RTCDataChannel): PeekInput {
187
199
  }
188
200
  const slot = slots.shift();
189
201
  if (!slot) continue;
190
- if (slot.type === "done") return;
202
+ if (slot.type === "done") {
203
+ if (slot.err) throw slot.err;
204
+ return;
205
+ }
191
206
  slot.taken();
192
207
  yield slot.value;
193
208
  }
@@ -209,9 +224,10 @@ function peekInput(_dc: RTCDataChannel): PeekInput {
209
224
  wake?.();
210
225
  });
211
226
  },
212
- done(): void {
213
- if (closed) return;
214
- slots.push({ type: "done" });
227
+ done(err?: Error): void {
228
+ if (closed || ended) return;
229
+ ended = true;
230
+ slots.push({ type: "done", err });
215
231
  wake?.();
216
232
  },
217
233
  release,
@@ -26,6 +26,9 @@ export const LOW_WATER_MARK = 256 * 1024;
26
26
  * whether or not anyone reads, so without credit a slow reader's queue grows without bound. The
27
27
  * window is larger than the send buffer's high-water mark so a long round trip does not throttle a
28
28
  * fast link: the buffer limit is what bounds the sender's memory.
29
+ *
30
+ * The receiver enforces it: a peer that sends more than the window plus the credit returned fails
31
+ * the call (the queued payload is dropped, the stream throws, the channel is closed).
29
32
  */
30
33
  export const RECEIVE_WINDOW = 4 * 1024 * 1024;
31
34
  const FRAME_OVERHEAD = 1; // 1-byte type tag
@@ -96,9 +99,21 @@ export function duplexOverDataChannel(
96
99
  return runStream(dc, input);
97
100
  }
98
101
 
99
- async function* runStream(
102
+ export interface RunStreamHooks {
103
+ /**
104
+ * Called once when the inbound side fails (the peer's `ERROR`, the channel closing, a peer
105
+ * past the receive window), before the stream waits for its outbound pump to settle. A
106
+ * responder ends its handler's input here: the pump may be waiting for the handler's next
107
+ * output while the handler waits for its next input.
108
+ */
109
+ onInboundFailed?(err: Error): void;
110
+ }
111
+
112
+ /** {@link duplexOverDataChannel}, with hooks for `serve` */
113
+ export async function* runStream(
100
114
  dc: RTCDataChannel,
101
115
  input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
116
+ hooks: RunStreamHooks = {},
102
117
  ): AsyncGenerator<Uint8Array> {
103
118
  dc.binaryType = "arraybuffer";
104
119
 
@@ -109,6 +124,9 @@ async function* runStream(
109
124
  // flow control: what the peer still lets us send, and what our consumer has pulled
110
125
  let credit = RECEIVE_WINDOW;
111
126
  let pulled = 0;
127
+ /** what the peer may still send us: the window plus the credit we returned, less what came */
128
+ let allowance = RECEIVE_WINDOW;
129
+ let overrun = false;
112
130
  let wakeSender: (() => void) | null = null;
113
131
  const nudge = (): void => {
114
132
  const w = wakeSender;
@@ -142,12 +160,13 @@ async function* runStream(
142
160
  const frame = new Uint8Array(5);
143
161
  frame[0] = TYPE_CREDIT;
144
162
  new DataView(frame.buffer).setUint32(1, pulled);
145
- pulled = 0;
146
163
  try {
147
164
  sendRaw(frame);
165
+ allowance += pulled;
148
166
  } catch {
149
167
  /* closing */
150
168
  }
169
+ pulled = 0;
151
170
  };
152
171
 
153
172
  const maybeClose = (): void => {
@@ -173,6 +192,27 @@ async function* runStream(
173
192
  const type = bytes[0];
174
193
  const payload = bytes.subarray(1);
175
194
  if (type === TYPE_DATA) {
195
+ if (overrun) return;
196
+ allowance -= payload.byteLength;
197
+ if (allowance < 0) {
198
+ // the peer ignores credit: fail the call rather than queue without bound
199
+ overrun = true;
200
+ peerEnded = true;
201
+ incoming.fail(
202
+ new Error(
203
+ `webrun-streams-webrtc: the peer sent past the receive window (${RECEIVE_WINDOW} bytes) without credit`,
204
+ ),
205
+ );
206
+ // and tell the peer now, not once our consumer reaches the error
207
+ aborted = true;
208
+ nudge();
209
+ try {
210
+ dc.close();
211
+ } catch {
212
+ /* already closing */
213
+ }
214
+ return;
215
+ }
176
216
  incoming.push(new Uint8Array(payload));
177
217
  } else if (type === TYPE_END) {
178
218
  peerEnded = true;
@@ -257,12 +297,16 @@ async function* runStream(
257
297
  })();
258
298
 
259
299
  let inboundDrained = false;
300
+ let inboundError: Error | undefined;
260
301
  try {
261
302
  for await (const chunk of incoming.iterate()) {
262
303
  yield chunk;
263
304
  consumed(chunk.byteLength); // pulled: the peer may send that much again
264
305
  }
265
306
  inboundDrained = true;
307
+ } catch (err) {
308
+ inboundError = err instanceof Error ? err : new Error(String(err));
309
+ throw err;
266
310
  } finally {
267
311
  inboundFinished = true;
268
312
  detach();
@@ -300,6 +344,9 @@ async function* runStream(
300
344
  } catch {
301
345
  /* already closing */
302
346
  }
347
+ // A responder's pump may be waiting for the handler's next output while the handler waits
348
+ // for its next input: end that input (with the error) before waiting on the pump.
349
+ if (inboundError) hooks.onInboundFailed?.(inboundError);
303
350
  await outbound.catch(() => {});
304
351
  }
305
352
  }
@@ -308,6 +355,8 @@ async function* runStream(
308
355
  interface InboundQueue {
309
356
  push(chunk: Uint8Array): void;
310
357
  done(err?: Error): void;
358
+ /** drops what is queued and ends with `err` */
359
+ fail(err: Error): void;
311
360
  iterate(): AsyncGenerator<Uint8Array>;
312
361
  }
313
362
 
@@ -328,6 +377,12 @@ function makeInboundQueue(): InboundQueue {
328
377
  slots.push({ type: "done", err });
329
378
  wake?.();
330
379
  },
380
+ fail(err: Error): void {
381
+ if (closed) return;
382
+ slots.length = 0;
383
+ slots.push({ type: "done", err });
384
+ wake?.();
385
+ },
331
386
  iterate(): AsyncGenerator<Uint8Array> {
332
387
  return (async function* () {
333
388
  try {
package/src/port-mux.ts CHANGED
@@ -78,7 +78,8 @@ const claimed = new WeakSet<RTCDataChannel>();
78
78
  * ```
79
79
  *
80
80
  * Messages that arrive before a port's first listener are held for it and delivered after that
81
- * tick, to every listener added by then.
81
+ * tick, to every listener added by then. Messages posted before a port's channel opens are queued
82
+ * and sent, in order, when it opens.
82
83
  */
83
84
  export async function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebRtcPortMux> {
84
85
  const { pc, codec, onPort } = options;
@@ -132,6 +133,26 @@ export async function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebR
132
133
  if (held) held.push(data);
133
134
  else deliver(data);
134
135
  };
136
+ // Posts made while the channel is still connecting, sent in order when it opens, as a
137
+ // MessagePort queues them (statewalker/statewalker-root#57). `undefined` once the channel has
138
+ // opened, or when it was open already.
139
+ let queued: Uint8Array[] | undefined = dc.readyState === "connecting" ? [] : undefined;
140
+ const send = (bytes: Uint8Array) => {
141
+ try {
142
+ dc.send(bytes as Uint8Array<ArrayBuffer>);
143
+ } catch (err) {
144
+ // a send queue full, or a channel closing meanwhile: the port is broken
145
+ end(err instanceof Error ? err : new Error(String(err)));
146
+ }
147
+ };
148
+ const onOpen = () => {
149
+ const pending = queued;
150
+ queued = undefined;
151
+ for (const bytes of pending ?? []) {
152
+ if (closeSignal.aborted) return;
153
+ send(bytes);
154
+ }
155
+ };
135
156
  const onClose = () => end(new Error("webrun-streams-webrtc: the data channel closed"));
136
157
  const onError = (ev: Event) =>
137
158
  end(
@@ -140,6 +161,7 @@ export async function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebR
140
161
  ),
141
162
  );
142
163
  dc.addEventListener("message", onMessage);
164
+ dc.addEventListener("open", onOpen);
143
165
  dc.addEventListener("close", onClose);
144
166
  dc.addEventListener("error", onError);
145
167
  if (dc.readyState === "closing" || dc.readyState === "closed") onClose();
@@ -147,6 +169,8 @@ export async function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebR
147
169
  const cleanUp = () => {
148
170
  live.delete(port);
149
171
  dc.removeEventListener("message", onMessage);
172
+ dc.removeEventListener("open", onOpen);
173
+ queued = undefined;
150
174
  listeners.clear();
151
175
  held = undefined;
152
176
  if (dc.readyState !== "closed") dc.close();
@@ -170,7 +194,9 @@ export async function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebR
170
194
  });
171
195
  },
172
196
  postMessage(message) {
173
- if (closeSignal.aborted || dc.readyState !== "open") return; // `closeSignal` says so
197
+ if (closeSignal.aborted) return; // `closeSignal` says so
198
+ // a channel closing or closed: its close event aborts `closeSignal`
199
+ if (!queued && dc.readyState !== "open") return;
174
200
  const bytes = codec.encode(message);
175
201
  if (bytes.byteLength > maxFrameBytes) {
176
202
  const err = new RangeError(
@@ -179,12 +205,8 @@ export async function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebR
179
205
  end(err);
180
206
  throw err;
181
207
  }
182
- try {
183
- dc.send(bytes as Uint8Array<ArrayBuffer>);
184
- } catch (err) {
185
- // a send queue full, or a channel closing meanwhile: the port is broken
186
- end(err instanceof Error ? err : new Error(String(err)));
187
- }
208
+ if (queued) queued.push(bytes);
209
+ else send(bytes);
188
210
  },
189
211
  addEventListener(_type, listener) {
190
212
  listeners.add(listener);