@statewalker/webrun-streams-webrtc 0.3.0 → 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 +14 -5
- package/dist/connect-serve.d.ts.map +1 -1
- package/dist/duplex-over-data-channel.d.ts +14 -0
- package/dist/duplex-over-data-channel.d.ts.map +1 -1
- package/dist/index.js +61 -17
- package/package.json +7 -5
- package/src/connect-serve.ts +37 -21
- package/src/duplex-over-data-channel.ts +57 -2
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`
|
|
217
|
-
|
|
218
|
-
`close` event ends the inbound side with `TransportClosedError
|
|
219
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
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)
|
|
134
|
-
|
|
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 {
|
|
@@ -483,12 +519,10 @@ const connect = async ({ pc }) => {
|
|
|
483
519
|
let counter = 0;
|
|
484
520
|
let closed = false;
|
|
485
521
|
const open = /* @__PURE__ */ new Set();
|
|
486
|
-
const
|
|
487
|
-
closed = true;
|
|
522
|
+
const onStateChange = () => {
|
|
523
|
+
if (pc.connectionState === "closed" || pc.connectionState === "failed") closed = true;
|
|
488
524
|
};
|
|
489
|
-
pc.addEventListener("connectionstatechange",
|
|
490
|
-
if (pc.connectionState === "closed" || pc.connectionState === "failed") onConnectionClose();
|
|
491
|
-
});
|
|
525
|
+
pc.addEventListener("connectionstatechange", onStateChange);
|
|
492
526
|
const call = (input) => {
|
|
493
527
|
if (closed) return (async function* () {
|
|
494
528
|
throw new Error("webrun-streams-webrtc: connection closed");
|
|
@@ -507,6 +541,7 @@ const connect = async ({ pc }) => {
|
|
|
507
541
|
call,
|
|
508
542
|
async close() {
|
|
509
543
|
closed = true;
|
|
544
|
+
pc.removeEventListener("connectionstatechange", onStateChange);
|
|
510
545
|
for (const dc of open) try {
|
|
511
546
|
dc.close();
|
|
512
547
|
} catch {}
|
|
@@ -537,10 +572,11 @@ const serve = async ({ pc }, handler) => {
|
|
|
537
572
|
peek.release();
|
|
538
573
|
}
|
|
539
574
|
})();
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
575
|
+
const inbound = runStream(dc, out, { onInboundFailed: (err) => peek.done(err) });
|
|
576
|
+
for await (const chunk of inbound) await peek.deliver(chunk);
|
|
577
|
+
peek.done();
|
|
578
|
+
})().catch((err) => {
|
|
579
|
+
inputFromPeer?.done(err instanceof Error ? err : new Error(String(err)));
|
|
544
580
|
});
|
|
545
581
|
};
|
|
546
582
|
pc.addEventListener("datachannel", onChannel);
|
|
@@ -597,6 +633,7 @@ function peekInput(_dc) {
|
|
|
597
633
|
const slots = [];
|
|
598
634
|
let wake = null;
|
|
599
635
|
let closed = false;
|
|
636
|
+
let ended = false;
|
|
600
637
|
const input = (async function* () {
|
|
601
638
|
try {
|
|
602
639
|
while (true) {
|
|
@@ -609,7 +646,10 @@ function peekInput(_dc) {
|
|
|
609
646
|
}
|
|
610
647
|
const slot = slots.shift();
|
|
611
648
|
if (!slot) continue;
|
|
612
|
-
if (slot.type === "done")
|
|
649
|
+
if (slot.type === "done") {
|
|
650
|
+
if (slot.err) throw slot.err;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
613
653
|
slot.taken();
|
|
614
654
|
yield slot.value;
|
|
615
655
|
}
|
|
@@ -635,9 +675,13 @@ function peekInput(_dc) {
|
|
|
635
675
|
wake?.();
|
|
636
676
|
});
|
|
637
677
|
},
|
|
638
|
-
done() {
|
|
639
|
-
if (closed) return;
|
|
640
|
-
|
|
678
|
+
done(err) {
|
|
679
|
+
if (closed || ended) return;
|
|
680
|
+
ended = true;
|
|
681
|
+
slots.push({
|
|
682
|
+
type: "done",
|
|
683
|
+
err
|
|
684
|
+
});
|
|
641
685
|
wake?.();
|
|
642
686
|
},
|
|
643
687
|
release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-streams-webrtc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
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.
|
|
46
|
-
"@statewalker/webrun-
|
|
47
|
-
"@statewalker/webrun-
|
|
45
|
+
"@statewalker/webrun-msgpack": "^0.11.0",
|
|
46
|
+
"@statewalker/webrun-rpc": "^0.12.0",
|
|
47
|
+
"@statewalker/webrun-streams-conformance": "^0.5.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
|
}
|
package/src/connect-serve.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Connect, Duplex, Serve } from "@statewalker/webrun-streams";
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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.
|
|
95
|
-
// the handler's input
|
|
96
|
-
|
|
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
|
-
|
|
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<
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|