@statewalker/webrun-streams-webrtc 0.1.6 → 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 +93 -15
- package/dist/connect-serve.d.ts.map +1 -1
- package/dist/duplex-over-data-channel.d.ts +36 -1
- package/dist/duplex-over-data-channel.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +428 -49
- package/dist/port-mux.d.ts +74 -0
- package/dist/port-mux.d.ts.map +1 -0
- package/package.json +9 -5
- package/src/connect-serve.ts +72 -27
- package/src/duplex-over-data-channel.ts +197 -10
- package/src/index.ts +11 -0
- package/src/port-mux.ts +288 -0
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,19 +30,21 @@
|
|
|
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",
|
|
37
37
|
"@roamhq/wrtc": "^0.10.0",
|
|
38
38
|
"@types/node": "^26.6.4",
|
|
39
|
-
"@vitest/browser-playwright": "^5.0.
|
|
39
|
+
"@vitest/browser-playwright": "^5.0.5",
|
|
40
40
|
"playwright": "^1.63.0",
|
|
41
41
|
"rimraf": "^6.1.3",
|
|
42
42
|
"rolldown": "^1.2.12",
|
|
43
43
|
"typescript": "^7.0.2",
|
|
44
|
-
"vitest": "^5.0.
|
|
45
|
-
"@statewalker/webrun-
|
|
44
|
+
"vitest": "^5.0.5",
|
|
45
|
+
"@statewalker/webrun-msgpack": "^0.11.0",
|
|
46
|
+
"@statewalker/webrun-rpc": "^0.12.0",
|
|
47
|
+
"@statewalker/webrun-streams-conformance": "^0.5.0"
|
|
46
48
|
},
|
|
47
49
|
"sideEffects": false,
|
|
48
50
|
"publishConfig": {
|
|
@@ -53,6 +55,8 @@
|
|
|
53
55
|
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
54
56
|
"test": "vitest run",
|
|
55
57
|
"test:browser": "vitest run --config vitest.browser.config.ts",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"typecheck:tests": "tsc -p tsconfig.tests.json",
|
|
56
60
|
"lint": "biome check src tests"
|
|
57
61
|
}
|
|
58
62
|
}
|
package/src/connect-serve.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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";
|
|
8
|
+
import { PORT_LABEL_PREFIX } from "./port-mux.js";
|
|
3
9
|
|
|
4
10
|
export interface WebRtcParams {
|
|
5
11
|
/** Open `RTCPeerConnection`. Signalling, auth, and connection setup are the caller's responsibility. */
|
|
@@ -15,12 +21,10 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
|
15
21
|
let counter = 0;
|
|
16
22
|
let closed = false;
|
|
17
23
|
const open = new Set<RTCDataChannel>();
|
|
18
|
-
const
|
|
19
|
-
closed = true;
|
|
24
|
+
const onStateChange = (): void => {
|
|
25
|
+
if (pc.connectionState === "closed" || pc.connectionState === "failed") closed = true;
|
|
20
26
|
};
|
|
21
|
-
pc.addEventListener("connectionstatechange",
|
|
22
|
-
if (pc.connectionState === "closed" || pc.connectionState === "failed") onConnectionClose();
|
|
23
|
-
});
|
|
27
|
+
pc.addEventListener("connectionstatechange", onStateChange);
|
|
24
28
|
|
|
25
29
|
const call: Duplex = (input) => {
|
|
26
30
|
if (closed) {
|
|
@@ -31,6 +35,7 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
|
31
35
|
}
|
|
32
36
|
const label = `webrun/${Date.now().toString(36)}/${(counter++).toString(36)}`;
|
|
33
37
|
const dc = pc.createDataChannel(label);
|
|
38
|
+
holdEvents(dc); // the responder may answer before our stream starts listening
|
|
34
39
|
open.add(dc);
|
|
35
40
|
dc.addEventListener("close", () => open.delete(dc));
|
|
36
41
|
return (async function* () {
|
|
@@ -43,6 +48,7 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
|
43
48
|
call,
|
|
44
49
|
async close() {
|
|
45
50
|
closed = true;
|
|
51
|
+
pc.removeEventListener("connectionstatechange", onStateChange);
|
|
46
52
|
for (const dc of open) {
|
|
47
53
|
try {
|
|
48
54
|
dc.close();
|
|
@@ -62,22 +68,41 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
|
62
68
|
export const serve: Serve<WebRtcParams> = async ({ pc }, handler: Duplex) => {
|
|
63
69
|
const onChannel = (ev: RTCDataChannelEvent): void => {
|
|
64
70
|
const dc = ev.channel;
|
|
71
|
+
if (dc.label.startsWith(PORT_LABEL_PREFIX)) return; // a port's channel: `webrtcPortMux`'s
|
|
72
|
+
holdEvents(dc);
|
|
73
|
+
markAnswering(dc); // the caller closes the channel once the call is over
|
|
65
74
|
let inputFromPeer: PeekInput | null = null;
|
|
66
75
|
void (async () => {
|
|
67
76
|
await waitForOpen(dc);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
const peek = peekInput(dc);
|
|
78
|
+
inputFromPeer = peek;
|
|
79
|
+
// Once the handler's output ends (or throws) the handler is done with its input: what the
|
|
80
|
+
// peer still sends is dropped, so its upload finishes instead of waiting for credit.
|
|
81
|
+
const handlerOutput = handler(peek.input);
|
|
82
|
+
const out = (async function* () {
|
|
83
|
+
try {
|
|
84
|
+
yield* handlerOutput;
|
|
85
|
+
} finally {
|
|
86
|
+
peek.release();
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
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);
|
|
72
96
|
}
|
|
73
|
-
|
|
74
|
-
})().catch(() => {
|
|
97
|
+
peek.done();
|
|
98
|
+
})().catch((err: unknown) => {
|
|
75
99
|
// A caller that cancels mid-response closes the DataChannel, which
|
|
76
100
|
// surfaces here as TransportClosedError. That is the cancellation
|
|
77
101
|
// working, not a fault: this task is fire-and-forget, so without a
|
|
78
|
-
// catch it becomes an unhandled rejection that fails the page.
|
|
79
|
-
// the handler's input
|
|
80
|
-
|
|
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)));
|
|
81
106
|
});
|
|
82
107
|
};
|
|
83
108
|
pc.addEventListener("datachannel", onChannel);
|
|
@@ -137,8 +162,12 @@ function toAsyncIterable(
|
|
|
137
162
|
|
|
138
163
|
interface PeekInput {
|
|
139
164
|
input: AsyncGenerator<Uint8Array>;
|
|
140
|
-
|
|
141
|
-
|
|
165
|
+
/** resolves once the handler has taken the chunk (or stopped reading) */
|
|
166
|
+
deliver(chunk: Uint8Array): Promise<void>;
|
|
167
|
+
/** ends the input after the chunks already delivered; with `err`, the handler's read throws it */
|
|
168
|
+
done(err?: Error): void;
|
|
169
|
+
/** the handler is finished: pending and later chunks are dropped */
|
|
170
|
+
release(): void;
|
|
142
171
|
}
|
|
143
172
|
|
|
144
173
|
/**
|
|
@@ -152,9 +181,12 @@ interface PeekInput {
|
|
|
152
181
|
* the handler's input queue.
|
|
153
182
|
*/
|
|
154
183
|
function peekInput(_dc: RTCDataChannel): PeekInput {
|
|
155
|
-
const slots: Array<
|
|
184
|
+
const slots: Array<
|
|
185
|
+
{ type: "value"; value: Uint8Array; taken: () => void } | { type: "done"; err?: Error }
|
|
186
|
+
> = [];
|
|
156
187
|
let wake: (() => void) | null = null;
|
|
157
188
|
let closed = false;
|
|
189
|
+
let ended = false;
|
|
158
190
|
const input = (async function* () {
|
|
159
191
|
try {
|
|
160
192
|
while (true) {
|
|
@@ -167,24 +199,37 @@ function peekInput(_dc: RTCDataChannel): PeekInput {
|
|
|
167
199
|
}
|
|
168
200
|
const slot = slots.shift();
|
|
169
201
|
if (!slot) continue;
|
|
170
|
-
if (slot.type === "done")
|
|
202
|
+
if (slot.type === "done") {
|
|
203
|
+
if (slot.err) throw slot.err;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
slot.taken();
|
|
171
207
|
yield slot.value;
|
|
172
208
|
}
|
|
173
209
|
} finally {
|
|
174
|
-
|
|
210
|
+
release();
|
|
175
211
|
}
|
|
176
212
|
})();
|
|
213
|
+
function release(): void {
|
|
214
|
+
closed = true;
|
|
215
|
+
for (const s of slots.splice(0)) if (s.type === "value") s.taken();
|
|
216
|
+
wake?.();
|
|
217
|
+
}
|
|
177
218
|
return {
|
|
178
219
|
input,
|
|
179
|
-
deliver(chunk: Uint8Array): void {
|
|
180
|
-
if (closed) return;
|
|
181
|
-
|
|
182
|
-
|
|
220
|
+
deliver(chunk: Uint8Array): Promise<void> {
|
|
221
|
+
if (closed) return Promise.resolve();
|
|
222
|
+
return new Promise<void>((taken) => {
|
|
223
|
+
slots.push({ type: "value", value: chunk, taken });
|
|
224
|
+
wake?.();
|
|
225
|
+
});
|
|
183
226
|
},
|
|
184
|
-
done(): void {
|
|
185
|
-
if (closed) return;
|
|
186
|
-
|
|
227
|
+
done(err?: Error): void {
|
|
228
|
+
if (closed || ended) return;
|
|
229
|
+
ended = true;
|
|
230
|
+
slots.push({ type: "done", err });
|
|
187
231
|
wake?.();
|
|
188
232
|
},
|
|
233
|
+
release,
|
|
189
234
|
};
|
|
190
235
|
}
|
|
@@ -8,13 +8,74 @@ import {
|
|
|
8
8
|
const TYPE_DATA = 0x00;
|
|
9
9
|
const TYPE_END = 0x01;
|
|
10
10
|
const TYPE_ERROR = 0x02;
|
|
11
|
+
const TYPE_CREDIT = 0x03;
|
|
11
12
|
|
|
12
13
|
const DC_MTU = 16 * 1024; // conservative across browsers
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Send-side backpressure: a sender waits while the DataChannel holds more than this many bytes
|
|
17
|
+
* unsent (`bufferedAmount`), until it drains to {@link LOW_WATER_MARK} (`bufferedamountlow`).
|
|
18
|
+
* Without it a fast producer fills the browser's send queue (Chrome fails `send` past 16 MiB).
|
|
19
|
+
*/
|
|
20
|
+
export const HIGH_WATER_MARK = 1024 * 1024;
|
|
21
|
+
export const LOW_WATER_MARK = 256 * 1024;
|
|
22
|
+
/**
|
|
23
|
+
* Receive-side backpressure: a sender may have at most this many payload bytes outstanding that
|
|
24
|
+
* the receiving consumer has not yet pulled. The receiver returns credit (`CREDIT` frames) as its
|
|
25
|
+
* consumer pulls, half a window at a time. A DataChannel delivers every message as it arrives,
|
|
26
|
+
* whether or not anyone reads, so without credit a slow reader's queue grows without bound. The
|
|
27
|
+
* window is larger than the send buffer's high-water mark so a long round trip does not throttle a
|
|
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).
|
|
32
|
+
*/
|
|
33
|
+
export const RECEIVE_WINDOW = 4 * 1024 * 1024;
|
|
13
34
|
const FRAME_OVERHEAD = 1; // 1-byte type tag
|
|
14
35
|
|
|
15
36
|
const textEncoder = new TextEncoder();
|
|
16
37
|
const textDecoder = new TextDecoder();
|
|
17
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Messages (and a close) that arrive on a channel before its stream starts listening. A generator
|
|
41
|
+
* runs only on its first `next()`, so without this a peer that answers at once (a handler that
|
|
42
|
+
* throws, a reply with no input to wait for) can be faster than our listener: its frames are
|
|
43
|
+
* dropped and the call hangs or reports a bare close.
|
|
44
|
+
*/
|
|
45
|
+
const held = new WeakMap<RTCDataChannel, { events: Event[]; listener: (ev: Event) => void }>();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Channels this side answers (from `datachannel`). A finished call is closed by its caller only,
|
|
49
|
+
* once it has the responder's `END` or `ERROR`: a responder that closed right after sending them
|
|
50
|
+
* could lose them in flight (Chrome delivers the close first), turning an error into a bare close.
|
|
51
|
+
*/
|
|
52
|
+
const answering = new WeakSet<RTCDataChannel>();
|
|
53
|
+
export function markAnswering(dc: RTCDataChannel): void {
|
|
54
|
+
answering.add(dc);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** starts holding a channel's events until {@link duplexOverDataChannel} takes them */
|
|
58
|
+
export function holdEvents(dc: RTCDataChannel): void {
|
|
59
|
+
if (held.has(dc)) return;
|
|
60
|
+
dc.binaryType = "arraybuffer";
|
|
61
|
+
const events: Event[] = [];
|
|
62
|
+
const listener = (ev: Event): void => {
|
|
63
|
+
events.push(ev);
|
|
64
|
+
};
|
|
65
|
+
dc.addEventListener("message", listener);
|
|
66
|
+
dc.addEventListener("close", listener);
|
|
67
|
+
held.set(dc, { events, listener });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function takeHeld(dc: RTCDataChannel): Event[] {
|
|
71
|
+
const h = held.get(dc);
|
|
72
|
+
if (!h) return [];
|
|
73
|
+
held.delete(dc);
|
|
74
|
+
dc.removeEventListener("message", h.listener);
|
|
75
|
+
dc.removeEventListener("close", h.listener);
|
|
76
|
+
return h.events;
|
|
77
|
+
}
|
|
78
|
+
|
|
18
79
|
/**
|
|
19
80
|
* Drive one `Duplex` over one `RTCDataChannel`. The DataChannel hosts a tiny
|
|
20
81
|
* in-band framing protocol:
|
|
@@ -23,7 +84,9 @@ const textDecoder = new TextDecoder();
|
|
|
23
84
|
*
|
|
24
85
|
* Types are `DATA` (0x00, body bytes), `END` (0x01, signals half-close — the
|
|
25
86
|
* sender is done emitting; the channel stays open for the other direction),
|
|
26
|
-
*
|
|
87
|
+
* `ERROR` (0x02, followed by a serialised `Error`) and `CREDIT` (0x03, a
|
|
88
|
+
* big-endian u32: payload bytes the receiver's consumer has pulled, which the
|
|
89
|
+
* sender may now send again; see {@link RECEIVE_WINDOW}).
|
|
27
90
|
*
|
|
28
91
|
* `RTCDataChannel` has no native half-close, so `END` is emitted by the
|
|
29
92
|
* sender once its input iterator exhausts; the channel is only physically
|
|
@@ -36,9 +99,21 @@ export function duplexOverDataChannel(
|
|
|
36
99
|
return runStream(dc, input);
|
|
37
100
|
}
|
|
38
101
|
|
|
39
|
-
|
|
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(
|
|
40
114
|
dc: RTCDataChannel,
|
|
41
115
|
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
116
|
+
hooks: RunStreamHooks = {},
|
|
42
117
|
): AsyncGenerator<Uint8Array> {
|
|
43
118
|
dc.binaryType = "arraybuffer";
|
|
44
119
|
|
|
@@ -46,8 +121,56 @@ async function* runStream(
|
|
|
46
121
|
let peerEnded = false;
|
|
47
122
|
let localEnded = false;
|
|
48
123
|
|
|
124
|
+
// flow control: what the peer still lets us send, and what our consumer has pulled
|
|
125
|
+
let credit = RECEIVE_WINDOW;
|
|
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;
|
|
130
|
+
let wakeSender: (() => void) | null = null;
|
|
131
|
+
const nudge = (): void => {
|
|
132
|
+
const w = wakeSender;
|
|
133
|
+
wakeSender = null;
|
|
134
|
+
w?.();
|
|
135
|
+
};
|
|
136
|
+
const stalled = (): Promise<void> =>
|
|
137
|
+
new Promise<void>((r) => {
|
|
138
|
+
wakeSender = r;
|
|
139
|
+
});
|
|
140
|
+
dc.bufferedAmountLowThreshold = LOW_WATER_MARK;
|
|
141
|
+
const sendRaw = (bytes: Uint8Array): void => {
|
|
142
|
+
(dc as unknown as { send: (data: Uint8Array) => void }).send(bytes);
|
|
143
|
+
};
|
|
144
|
+
/** set when this call is abandoned (cancelled, or the peer failed): the sender stops waiting */
|
|
145
|
+
let aborted = false;
|
|
146
|
+
/** waits for credit for `n` bytes and room in the send buffer; false once the call is over */
|
|
147
|
+
const room = async (n: number): Promise<boolean> => {
|
|
148
|
+
while (
|
|
149
|
+
!aborted &&
|
|
150
|
+
dc.readyState === "open" &&
|
|
151
|
+
(credit < n || dc.bufferedAmount > HIGH_WATER_MARK)
|
|
152
|
+
)
|
|
153
|
+
await stalled();
|
|
154
|
+
return !aborted && dc.readyState === "open";
|
|
155
|
+
};
|
|
156
|
+
/** returns credit for what our consumer pulled, half a window at a time */
|
|
157
|
+
const consumed = (n: number): void => {
|
|
158
|
+
pulled += n;
|
|
159
|
+
if (pulled < RECEIVE_WINDOW / 2 || dc.readyState !== "open") return;
|
|
160
|
+
const frame = new Uint8Array(5);
|
|
161
|
+
frame[0] = TYPE_CREDIT;
|
|
162
|
+
new DataView(frame.buffer).setUint32(1, pulled);
|
|
163
|
+
try {
|
|
164
|
+
sendRaw(frame);
|
|
165
|
+
allowance += pulled;
|
|
166
|
+
} catch {
|
|
167
|
+
/* closing */
|
|
168
|
+
}
|
|
169
|
+
pulled = 0;
|
|
170
|
+
};
|
|
171
|
+
|
|
49
172
|
const maybeClose = (): void => {
|
|
50
|
-
if (peerEnded && localEnded) {
|
|
173
|
+
if (peerEnded && localEnded && !answering.has(dc)) {
|
|
51
174
|
try {
|
|
52
175
|
dc.close();
|
|
53
176
|
} catch {
|
|
@@ -69,11 +192,35 @@ async function* runStream(
|
|
|
69
192
|
const type = bytes[0];
|
|
70
193
|
const payload = bytes.subarray(1);
|
|
71
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
|
+
}
|
|
72
216
|
incoming.push(new Uint8Array(payload));
|
|
73
217
|
} else if (type === TYPE_END) {
|
|
74
218
|
peerEnded = true;
|
|
75
219
|
incoming.done();
|
|
76
220
|
maybeClose();
|
|
221
|
+
} else if (type === TYPE_CREDIT && payload.byteLength >= 4) {
|
|
222
|
+
credit += new DataView(payload.buffer, payload.byteOffset, 4).getUint32(0);
|
|
223
|
+
nudge();
|
|
77
224
|
} else if (type === TYPE_ERROR) {
|
|
78
225
|
let err: Error;
|
|
79
226
|
try {
|
|
@@ -88,6 +235,7 @@ async function* runStream(
|
|
|
88
235
|
};
|
|
89
236
|
|
|
90
237
|
const onClose = (): void => {
|
|
238
|
+
nudge();
|
|
91
239
|
if (!peerEnded) {
|
|
92
240
|
incoming.done(new TransportClosedError());
|
|
93
241
|
peerEnded = true;
|
|
@@ -96,16 +244,33 @@ async function* runStream(
|
|
|
96
244
|
|
|
97
245
|
dc.addEventListener("message", onMessage);
|
|
98
246
|
dc.addEventListener("close", onClose);
|
|
247
|
+
dc.addEventListener("bufferedamountlow", nudge);
|
|
248
|
+
// what arrived before we listened, in order
|
|
249
|
+
for (const ev of takeHeld(dc)) {
|
|
250
|
+
if (ev.type === "message") onMessage(ev as MessageEvent);
|
|
251
|
+
else onClose();
|
|
252
|
+
}
|
|
253
|
+
if (dc.readyState === "closed") onClose();
|
|
254
|
+
// The listeners stay until both halves are done: after the peer's END our own sending may still
|
|
255
|
+
// wait for the peer's CREDIT frames (a reply larger than the window, sent after the input ended).
|
|
256
|
+
let inboundFinished = false;
|
|
257
|
+
const detach = (): void => {
|
|
258
|
+
if (!inboundFinished || !localEnded) return;
|
|
259
|
+
dc.removeEventListener("message", onMessage);
|
|
260
|
+
dc.removeEventListener("close", onClose);
|
|
261
|
+
dc.removeEventListener("bufferedamountlow", nudge);
|
|
262
|
+
};
|
|
99
263
|
|
|
100
264
|
// Outbound pump runs in parallel with inbound iteration.
|
|
101
265
|
const outbound = (async () => {
|
|
102
266
|
try {
|
|
103
267
|
for await (const chunk of toChunks(DC_MTU - FRAME_OVERHEAD)(input)) {
|
|
104
|
-
if (
|
|
268
|
+
if (!(await room(chunk.byteLength))) return;
|
|
269
|
+
credit -= chunk.byteLength;
|
|
105
270
|
const framed = new Uint8Array(chunk.byteLength + 1);
|
|
106
271
|
framed[0] = TYPE_DATA;
|
|
107
272
|
framed.set(chunk, 1);
|
|
108
|
-
(
|
|
273
|
+
sendRaw(framed);
|
|
109
274
|
}
|
|
110
275
|
if (dc.readyState === "open") {
|
|
111
276
|
const end = new Uint8Array([TYPE_END]);
|
|
@@ -127,20 +292,27 @@ async function* runStream(
|
|
|
127
292
|
} finally {
|
|
128
293
|
localEnded = true;
|
|
129
294
|
maybeClose();
|
|
295
|
+
detach();
|
|
130
296
|
}
|
|
131
297
|
})();
|
|
132
298
|
|
|
133
299
|
let inboundDrained = false;
|
|
300
|
+
let inboundError: Error | undefined;
|
|
134
301
|
try {
|
|
135
302
|
for await (const chunk of incoming.iterate()) {
|
|
136
303
|
yield chunk;
|
|
304
|
+
consumed(chunk.byteLength); // pulled: the peer may send that much again
|
|
137
305
|
}
|
|
138
306
|
inboundDrained = true;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
inboundError = err instanceof Error ? err : new Error(String(err));
|
|
309
|
+
throw err;
|
|
139
310
|
} finally {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
311
|
+
inboundFinished = true;
|
|
312
|
+
detach();
|
|
313
|
+
// On an early exit, stop the outbound pump (it may be waiting for credit
|
|
314
|
+
// the peer will never send now), close the channel, then let the pump
|
|
315
|
+
// settle — but ONLY on an early exit.
|
|
144
316
|
//
|
|
145
317
|
// Awaiting it unconditionally deadlocks a responder. `serve` feeds the
|
|
146
318
|
// handler from the values this generator yields and closes the handler's
|
|
@@ -154,7 +326,8 @@ async function* runStream(
|
|
|
154
326
|
// there is nothing of theirs left to truncate; outbound keeps running on
|
|
155
327
|
// its own and `maybeClose` still closes the channel once both halves end.
|
|
156
328
|
if (!inboundDrained) {
|
|
157
|
-
|
|
329
|
+
aborted = true;
|
|
330
|
+
nudge();
|
|
158
331
|
// The consumer walked away mid-response (`.return()` on the generator),
|
|
159
332
|
// or the peer errored. Either way this call is over while the peer may
|
|
160
333
|
// still be producing, and a DataChannel has no half-close to say so —
|
|
@@ -164,11 +337,17 @@ async function* runStream(
|
|
|
164
337
|
// `onClose` then ends its inbound queue, which closes the handler's
|
|
165
338
|
// input and runs the handler's `finally`. Without this, cancellation is
|
|
166
339
|
// silently local and the handler leaks until the connection drops.
|
|
340
|
+
// The close comes before waiting on the pump: a pump stalled on credit
|
|
341
|
+
// would otherwise hold the cancellation back.
|
|
167
342
|
try {
|
|
168
343
|
dc.close();
|
|
169
344
|
} catch {
|
|
170
345
|
/* already closing */
|
|
171
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);
|
|
350
|
+
await outbound.catch(() => {});
|
|
172
351
|
}
|
|
173
352
|
}
|
|
174
353
|
}
|
|
@@ -176,6 +355,8 @@ async function* runStream(
|
|
|
176
355
|
interface InboundQueue {
|
|
177
356
|
push(chunk: Uint8Array): void;
|
|
178
357
|
done(err?: Error): void;
|
|
358
|
+
/** drops what is queued and ends with `err` */
|
|
359
|
+
fail(err: Error): void;
|
|
179
360
|
iterate(): AsyncGenerator<Uint8Array>;
|
|
180
361
|
}
|
|
181
362
|
|
|
@@ -196,6 +377,12 @@ function makeInboundQueue(): InboundQueue {
|
|
|
196
377
|
slots.push({ type: "done", err });
|
|
197
378
|
wake?.();
|
|
198
379
|
},
|
|
380
|
+
fail(err: Error): void {
|
|
381
|
+
if (closed) return;
|
|
382
|
+
slots.length = 0;
|
|
383
|
+
slots.push({ type: "done", err });
|
|
384
|
+
wake?.();
|
|
385
|
+
},
|
|
199
386
|
iterate(): AsyncGenerator<Uint8Array> {
|
|
200
387
|
return (async function* () {
|
|
201
388
|
try {
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
export { connect, serve, type WebRtcParams } from "./connect-serve.js";
|
|
2
2
|
export { duplexOverDataChannel } from "./duplex-over-data-channel.js";
|
|
3
|
+
export {
|
|
4
|
+
type DataChannelPort,
|
|
5
|
+
DEFAULT_PORT_MAX_FRAME_BYTES,
|
|
6
|
+
DEFAULT_PORT_MAX_MESSAGE_SIZE,
|
|
7
|
+
PORT_DRAIN_LOW_WATER,
|
|
8
|
+
PORT_LABEL_PREFIX,
|
|
9
|
+
type ValueCodec,
|
|
10
|
+
type WebRtcPortMux,
|
|
11
|
+
type WebRtcPortMuxOptions,
|
|
12
|
+
webrtcPortMux,
|
|
13
|
+
} from "./port-mux.js";
|