@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/dist/index.js
CHANGED
|
@@ -3,9 +3,62 @@ import { TransportClosedError, deserializeError, serializeError, toChunks } from
|
|
|
3
3
|
const TYPE_DATA = 0;
|
|
4
4
|
const TYPE_END = 1;
|
|
5
5
|
const TYPE_ERROR = 2;
|
|
6
|
+
const TYPE_CREDIT = 3;
|
|
7
|
+
const LOW_WATER_MARK = 262144;
|
|
8
|
+
/**
|
|
9
|
+
* Receive-side backpressure: a sender may have at most this many payload bytes outstanding that
|
|
10
|
+
* the receiving consumer has not yet pulled. The receiver returns credit (`CREDIT` frames) as its
|
|
11
|
+
* consumer pulls, half a window at a time. A DataChannel delivers every message as it arrives,
|
|
12
|
+
* whether or not anyone reads, so without credit a slow reader's queue grows without bound. The
|
|
13
|
+
* window is larger than the send buffer's high-water mark so a long round trip does not throttle a
|
|
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).
|
|
18
|
+
*/
|
|
19
|
+
const RECEIVE_WINDOW = 4194304;
|
|
6
20
|
const textEncoder = new TextEncoder();
|
|
7
21
|
const textDecoder = new TextDecoder();
|
|
8
22
|
/**
|
|
23
|
+
* Messages (and a close) that arrive on a channel before its stream starts listening. A generator
|
|
24
|
+
* runs only on its first `next()`, so without this a peer that answers at once (a handler that
|
|
25
|
+
* throws, a reply with no input to wait for) can be faster than our listener: its frames are
|
|
26
|
+
* dropped and the call hangs or reports a bare close.
|
|
27
|
+
*/
|
|
28
|
+
const held = /* @__PURE__ */ new WeakMap();
|
|
29
|
+
/**
|
|
30
|
+
* Channels this side answers (from `datachannel`). A finished call is closed by its caller only,
|
|
31
|
+
* once it has the responder's `END` or `ERROR`: a responder that closed right after sending them
|
|
32
|
+
* could lose them in flight (Chrome delivers the close first), turning an error into a bare close.
|
|
33
|
+
*/
|
|
34
|
+
const answering = /* @__PURE__ */ new WeakSet();
|
|
35
|
+
function markAnswering(dc) {
|
|
36
|
+
answering.add(dc);
|
|
37
|
+
}
|
|
38
|
+
/** starts holding a channel's events until {@link duplexOverDataChannel} takes them */
|
|
39
|
+
function holdEvents(dc) {
|
|
40
|
+
if (held.has(dc)) return;
|
|
41
|
+
dc.binaryType = "arraybuffer";
|
|
42
|
+
const events = [];
|
|
43
|
+
const listener = (ev) => {
|
|
44
|
+
events.push(ev);
|
|
45
|
+
};
|
|
46
|
+
dc.addEventListener("message", listener);
|
|
47
|
+
dc.addEventListener("close", listener);
|
|
48
|
+
held.set(dc, {
|
|
49
|
+
events,
|
|
50
|
+
listener
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function takeHeld(dc) {
|
|
54
|
+
const h = held.get(dc);
|
|
55
|
+
if (!h) return [];
|
|
56
|
+
held.delete(dc);
|
|
57
|
+
dc.removeEventListener("message", h.listener);
|
|
58
|
+
dc.removeEventListener("close", h.listener);
|
|
59
|
+
return h.events;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
9
62
|
* Drive one `Duplex` over one `RTCDataChannel`. The DataChannel hosts a tiny
|
|
10
63
|
* in-band framing protocol:
|
|
11
64
|
*
|
|
@@ -13,7 +66,9 @@ const textDecoder = new TextDecoder();
|
|
|
13
66
|
*
|
|
14
67
|
* Types are `DATA` (0x00, body bytes), `END` (0x01, signals half-close — the
|
|
15
68
|
* sender is done emitting; the channel stays open for the other direction),
|
|
16
|
-
*
|
|
69
|
+
* `ERROR` (0x02, followed by a serialised `Error`) and `CREDIT` (0x03, a
|
|
70
|
+
* big-endian u32: payload bytes the receiver's consumer has pulled, which the
|
|
71
|
+
* sender may now send again; see {@link RECEIVE_WINDOW}).
|
|
17
72
|
*
|
|
18
73
|
* `RTCDataChannel` has no native half-close, so `END` is emitted by the
|
|
19
74
|
* sender once its input iterator exhausts; the channel is only physically
|
|
@@ -22,13 +77,52 @@ const textDecoder = new TextDecoder();
|
|
|
22
77
|
function duplexOverDataChannel(dc, input) {
|
|
23
78
|
return runStream(dc, input);
|
|
24
79
|
}
|
|
25
|
-
|
|
80
|
+
/** {@link duplexOverDataChannel}, with hooks for `serve` */
|
|
81
|
+
async function* runStream(dc, input, hooks = {}) {
|
|
26
82
|
dc.binaryType = "arraybuffer";
|
|
27
83
|
const incoming = makeInboundQueue();
|
|
28
84
|
let peerEnded = false;
|
|
29
85
|
let localEnded = false;
|
|
86
|
+
let credit = RECEIVE_WINDOW;
|
|
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;
|
|
91
|
+
let wakeSender = null;
|
|
92
|
+
const nudge = () => {
|
|
93
|
+
const w = wakeSender;
|
|
94
|
+
wakeSender = null;
|
|
95
|
+
w?.();
|
|
96
|
+
};
|
|
97
|
+
const stalled = () => new Promise((r) => {
|
|
98
|
+
wakeSender = r;
|
|
99
|
+
});
|
|
100
|
+
dc.bufferedAmountLowThreshold = LOW_WATER_MARK;
|
|
101
|
+
const sendRaw = (bytes) => {
|
|
102
|
+
dc.send(bytes);
|
|
103
|
+
};
|
|
104
|
+
/** set when this call is abandoned (cancelled, or the peer failed): the sender stops waiting */
|
|
105
|
+
let aborted = false;
|
|
106
|
+
/** waits for credit for `n` bytes and room in the send buffer; false once the call is over */
|
|
107
|
+
const room = async (n) => {
|
|
108
|
+
while (!aborted && dc.readyState === "open" && (credit < n || dc.bufferedAmount > 1048576)) await stalled();
|
|
109
|
+
return !aborted && dc.readyState === "open";
|
|
110
|
+
};
|
|
111
|
+
/** returns credit for what our consumer pulled, half a window at a time */
|
|
112
|
+
const consumed = (n) => {
|
|
113
|
+
pulled += n;
|
|
114
|
+
if (pulled < 2097152 || dc.readyState !== "open") return;
|
|
115
|
+
const frame = /* @__PURE__ */ new Uint8Array(5);
|
|
116
|
+
frame[0] = TYPE_CREDIT;
|
|
117
|
+
new DataView(frame.buffer).setUint32(1, pulled);
|
|
118
|
+
try {
|
|
119
|
+
sendRaw(frame);
|
|
120
|
+
allowance += pulled;
|
|
121
|
+
} catch {}
|
|
122
|
+
pulled = 0;
|
|
123
|
+
};
|
|
30
124
|
const maybeClose = () => {
|
|
31
|
-
if (peerEnded && localEnded) try {
|
|
125
|
+
if (peerEnded && localEnded && !answering.has(dc)) try {
|
|
32
126
|
dc.close();
|
|
33
127
|
} catch {}
|
|
34
128
|
};
|
|
@@ -44,11 +138,28 @@ async function* runStream(dc, input) {
|
|
|
44
138
|
if (bytes.byteLength === 0) return;
|
|
45
139
|
const type = bytes[0];
|
|
46
140
|
const payload = bytes.subarray(1);
|
|
47
|
-
if (type === TYPE_DATA)
|
|
48
|
-
|
|
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) {
|
|
49
157
|
peerEnded = true;
|
|
50
158
|
incoming.done();
|
|
51
159
|
maybeClose();
|
|
160
|
+
} else if (type === TYPE_CREDIT && payload.byteLength >= 4) {
|
|
161
|
+
credit += new DataView(payload.buffer, payload.byteOffset, 4).getUint32(0);
|
|
162
|
+
nudge();
|
|
52
163
|
} else if (type === TYPE_ERROR) {
|
|
53
164
|
let err;
|
|
54
165
|
try {
|
|
@@ -62,6 +173,7 @@ async function* runStream(dc, input) {
|
|
|
62
173
|
}
|
|
63
174
|
};
|
|
64
175
|
const onClose = () => {
|
|
176
|
+
nudge();
|
|
65
177
|
if (!peerEnded) {
|
|
66
178
|
incoming.done(new TransportClosedError());
|
|
67
179
|
peerEnded = true;
|
|
@@ -69,14 +181,26 @@ async function* runStream(dc, input) {
|
|
|
69
181
|
};
|
|
70
182
|
dc.addEventListener("message", onMessage);
|
|
71
183
|
dc.addEventListener("close", onClose);
|
|
184
|
+
dc.addEventListener("bufferedamountlow", nudge);
|
|
185
|
+
for (const ev of takeHeld(dc)) if (ev.type === "message") onMessage(ev);
|
|
186
|
+
else onClose();
|
|
187
|
+
if (dc.readyState === "closed") onClose();
|
|
188
|
+
let inboundFinished = false;
|
|
189
|
+
const detach = () => {
|
|
190
|
+
if (!inboundFinished || !localEnded) return;
|
|
191
|
+
dc.removeEventListener("message", onMessage);
|
|
192
|
+
dc.removeEventListener("close", onClose);
|
|
193
|
+
dc.removeEventListener("bufferedamountlow", nudge);
|
|
194
|
+
};
|
|
72
195
|
const outbound = (async () => {
|
|
73
196
|
try {
|
|
74
197
|
for await (const chunk of toChunks(16383)(input)) {
|
|
75
|
-
if (
|
|
198
|
+
if (!await room(chunk.byteLength)) return;
|
|
199
|
+
credit -= chunk.byteLength;
|
|
76
200
|
const framed = new Uint8Array(chunk.byteLength + 1);
|
|
77
201
|
framed[0] = TYPE_DATA;
|
|
78
202
|
framed.set(chunk, 1);
|
|
79
|
-
|
|
203
|
+
sendRaw(framed);
|
|
80
204
|
}
|
|
81
205
|
if (dc.readyState === "open") {
|
|
82
206
|
const end = new Uint8Array([TYPE_END]);
|
|
@@ -96,20 +220,31 @@ async function* runStream(dc, input) {
|
|
|
96
220
|
} finally {
|
|
97
221
|
localEnded = true;
|
|
98
222
|
maybeClose();
|
|
223
|
+
detach();
|
|
99
224
|
}
|
|
100
225
|
})();
|
|
101
226
|
let inboundDrained = false;
|
|
227
|
+
let inboundError;
|
|
102
228
|
try {
|
|
103
|
-
for await (const chunk of incoming.iterate())
|
|
229
|
+
for await (const chunk of incoming.iterate()) {
|
|
230
|
+
yield chunk;
|
|
231
|
+
consumed(chunk.byteLength);
|
|
232
|
+
}
|
|
104
233
|
inboundDrained = true;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
inboundError = err instanceof Error ? err : new Error(String(err));
|
|
236
|
+
throw err;
|
|
105
237
|
} finally {
|
|
106
|
-
|
|
107
|
-
|
|
238
|
+
inboundFinished = true;
|
|
239
|
+
detach();
|
|
108
240
|
if (!inboundDrained) {
|
|
109
|
-
|
|
241
|
+
aborted = true;
|
|
242
|
+
nudge();
|
|
110
243
|
try {
|
|
111
244
|
dc.close();
|
|
112
245
|
} catch {}
|
|
246
|
+
if (inboundError) hooks.onInboundFailed?.(inboundError);
|
|
247
|
+
await outbound.catch(() => {});
|
|
113
248
|
}
|
|
114
249
|
}
|
|
115
250
|
}
|
|
@@ -134,6 +269,15 @@ function makeInboundQueue() {
|
|
|
134
269
|
});
|
|
135
270
|
wake?.();
|
|
136
271
|
},
|
|
272
|
+
fail(err) {
|
|
273
|
+
if (closed) return;
|
|
274
|
+
slots.length = 0;
|
|
275
|
+
slots.push({
|
|
276
|
+
type: "done",
|
|
277
|
+
err
|
|
278
|
+
});
|
|
279
|
+
wake?.();
|
|
280
|
+
},
|
|
137
281
|
iterate() {
|
|
138
282
|
return (async function* () {
|
|
139
283
|
try {
|
|
@@ -161,6 +305,210 @@ function makeInboundQueue() {
|
|
|
161
305
|
};
|
|
162
306
|
}
|
|
163
307
|
//#endregion
|
|
308
|
+
//#region src/port-mux.ts
|
|
309
|
+
/** The label prefix of a port's data channel: channels without it are someone else's. */
|
|
310
|
+
const PORT_LABEL_PREFIX = "webrun-port/";
|
|
311
|
+
/**
|
|
312
|
+
* The largest message a data channel delivers between any two browsers (Firefox to Chrome cuts
|
|
313
|
+
* off at 16 KiB). A post past it fails the port with a `RangeError` rather than vanishing.
|
|
314
|
+
*/
|
|
315
|
+
const DEFAULT_PORT_MAX_FRAME_BYTES = 16384;
|
|
316
|
+
/** What one message on a port may carry before the stream tier chunks: 256 bytes under the frame limit (spec D10). */
|
|
317
|
+
const DEFAULT_PORT_MAX_MESSAGE_SIZE = 16128;
|
|
318
|
+
/** `whenDrained` resolves once the channel's `bufferedAmount` is at most this. */
|
|
319
|
+
const PORT_DRAIN_LOW_WATER = 262144;
|
|
320
|
+
let counter = 0;
|
|
321
|
+
/** Inbound port channels a serving mux has taken: on one connection, each port is answered once. */
|
|
322
|
+
const claimed = /* @__PURE__ */ new WeakSet();
|
|
323
|
+
/**
|
|
324
|
+
* Native MessagePorts over WebRTC: every port is its own `RTCDataChannel` (ordered, reliable),
|
|
325
|
+
* labelled `webrun-port/…`. Hand it to `webrun-rpc` as a port source and its stream tier runs one
|
|
326
|
+
* call per port, with its backpressure: a producer stays one confirmed chunk ahead of its
|
|
327
|
+
* consumer, where this package's `connect` / `serve` allow a 4 MiB window.
|
|
328
|
+
*
|
|
329
|
+
* ```ts
|
|
330
|
+
* const codec = { encode: serialize, decode: deserialize }; // webrun-msgpack
|
|
331
|
+
* await serve({ mux: overPorts((onPort) => webrtcPortMux({ pc, codec, onPort })) }, handler);
|
|
332
|
+
* const { call } = await connect({ mux: overPorts(() => webrtcPortMux({ pc, codec })) });
|
|
333
|
+
* ```
|
|
334
|
+
*
|
|
335
|
+
* Messages that arrive before a port's first listener are held for it and delivered after that
|
|
336
|
+
* tick, to every listener added by then.
|
|
337
|
+
*/
|
|
338
|
+
async function webrtcPortMux(options) {
|
|
339
|
+
const { pc, codec, onPort } = options;
|
|
340
|
+
const maxFrameBytes = options.maxFrameBytes ?? 16384;
|
|
341
|
+
const maxMessageSize = options.maxMessageSize ?? 16128;
|
|
342
|
+
const live = /* @__PURE__ */ new Set();
|
|
343
|
+
let closed = false;
|
|
344
|
+
const connectionGone = new AbortController();
|
|
345
|
+
const onConnectionState = () => {
|
|
346
|
+
if (pc.connectionState === "failed" || pc.connectionState === "closed") connectionGone.abort(/* @__PURE__ */ new Error(`webrun-streams-webrtc: the connection is ${pc.connectionState}`));
|
|
347
|
+
};
|
|
348
|
+
pc.addEventListener("connectionstatechange", onConnectionState);
|
|
349
|
+
onConnectionState();
|
|
350
|
+
const wrap = (dc) => {
|
|
351
|
+
dc.binaryType = "arraybuffer";
|
|
352
|
+
dc.bufferedAmountLowThreshold = PORT_DRAIN_LOW_WATER;
|
|
353
|
+
const life = new AbortController();
|
|
354
|
+
const end = (reason) => {
|
|
355
|
+
if (!life.signal.aborted) life.abort(reason);
|
|
356
|
+
};
|
|
357
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
358
|
+
let held = [];
|
|
359
|
+
const deliver = (data) => {
|
|
360
|
+
if (closeSignal.aborted) return;
|
|
361
|
+
const event = new MessageEvent("message", { data });
|
|
362
|
+
for (const l of [...listeners]) try {
|
|
363
|
+
Promise.resolve(l(event)).catch(() => void 0);
|
|
364
|
+
} catch {}
|
|
365
|
+
};
|
|
366
|
+
const onMessage = (ev) => {
|
|
367
|
+
if (closeSignal.aborted) return;
|
|
368
|
+
let data;
|
|
369
|
+
try {
|
|
370
|
+
const raw = ev.data;
|
|
371
|
+
data = codec.decode(raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
end(new Error("webrun-streams-webrtc: a frame the port's codec cannot decode", { cause: err }));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (held) held.push(data);
|
|
377
|
+
else deliver(data);
|
|
378
|
+
};
|
|
379
|
+
const onClose = () => end(/* @__PURE__ */ new Error("webrun-streams-webrtc: the data channel closed"));
|
|
380
|
+
const onError = (ev) => end(/* @__PURE__ */ new Error(`webrun-streams-webrtc: the data channel failed: ${ev.error?.message ?? "unknown"}`));
|
|
381
|
+
dc.addEventListener("message", onMessage);
|
|
382
|
+
dc.addEventListener("close", onClose);
|
|
383
|
+
dc.addEventListener("error", onError);
|
|
384
|
+
if (dc.readyState === "closing" || dc.readyState === "closed") onClose();
|
|
385
|
+
const closeSignal = AbortSignal.any([life.signal, connectionGone.signal]);
|
|
386
|
+
const cleanUp = () => {
|
|
387
|
+
live.delete(port);
|
|
388
|
+
dc.removeEventListener("message", onMessage);
|
|
389
|
+
listeners.clear();
|
|
390
|
+
held = void 0;
|
|
391
|
+
if (dc.readyState !== "closed") dc.close();
|
|
392
|
+
};
|
|
393
|
+
const port = {
|
|
394
|
+
closeSignal,
|
|
395
|
+
get bufferedAmount() {
|
|
396
|
+
return dc.bufferedAmount;
|
|
397
|
+
},
|
|
398
|
+
whenDrained() {
|
|
399
|
+
if (closeSignal.aborted || dc.bufferedAmount <= 262144) return Promise.resolve();
|
|
400
|
+
return new Promise((resolve) => {
|
|
401
|
+
const done = () => {
|
|
402
|
+
dc.removeEventListener("bufferedamountlow", done);
|
|
403
|
+
closeSignal.removeEventListener("abort", done);
|
|
404
|
+
resolve();
|
|
405
|
+
};
|
|
406
|
+
dc.addEventListener("bufferedamountlow", done);
|
|
407
|
+
closeSignal.addEventListener("abort", done);
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
postMessage(message) {
|
|
411
|
+
if (closeSignal.aborted || dc.readyState !== "open") return;
|
|
412
|
+
const bytes = codec.encode(message);
|
|
413
|
+
if (bytes.byteLength > maxFrameBytes) {
|
|
414
|
+
const err = /* @__PURE__ */ new RangeError(`webrun-streams-webrtc: a ${bytes.byteLength}-byte message exceeds maxFrameBytes (${maxFrameBytes})`);
|
|
415
|
+
end(err);
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
dc.send(bytes);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
end(err instanceof Error ? err : new Error(String(err)));
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
addEventListener(_type, listener) {
|
|
425
|
+
listeners.add(listener);
|
|
426
|
+
if (held) {
|
|
427
|
+
const pending = held;
|
|
428
|
+
held = void 0;
|
|
429
|
+
queueMicrotask(() => {
|
|
430
|
+
for (const data of pending) deliver(data);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
removeEventListener(_type, listener) {
|
|
435
|
+
listeners.delete(listener);
|
|
436
|
+
},
|
|
437
|
+
async close() {
|
|
438
|
+
end(/* @__PURE__ */ new Error("webrun-streams-webrtc: port closed"));
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
live.add(port);
|
|
442
|
+
if (closeSignal.aborted) cleanUp();
|
|
443
|
+
else closeSignal.addEventListener("abort", cleanUp, { once: true });
|
|
444
|
+
return port;
|
|
445
|
+
};
|
|
446
|
+
const onChannel = (ev) => {
|
|
447
|
+
const dc = ev.channel;
|
|
448
|
+
if (!dc.label.startsWith("webrun-port/")) return;
|
|
449
|
+
if (!onPort || closed || claimed.has(dc)) return;
|
|
450
|
+
claimed.add(dc);
|
|
451
|
+
const port = wrap(dc);
|
|
452
|
+
let accepted = false;
|
|
453
|
+
try {
|
|
454
|
+
accepted = onPort(port) !== false;
|
|
455
|
+
} catch {
|
|
456
|
+
accepted = false;
|
|
457
|
+
}
|
|
458
|
+
if (!accepted) port.close();
|
|
459
|
+
};
|
|
460
|
+
pc.addEventListener("datachannel", onChannel);
|
|
461
|
+
return {
|
|
462
|
+
maxMessageSize,
|
|
463
|
+
async openPort(_meta, opts) {
|
|
464
|
+
if (closed) throw new Error("webrun-streams-webrtc: port mux closed");
|
|
465
|
+
const signal = opts?.signal;
|
|
466
|
+
signal?.throwIfAborted();
|
|
467
|
+
if (connectionGone.signal.aborted) throw connectionGone.signal.reason;
|
|
468
|
+
const dc = pc.createDataChannel(`${PORT_LABEL_PREFIX}${(counter++).toString(36)}`, { ordered: true });
|
|
469
|
+
const port = wrap(dc);
|
|
470
|
+
await new Promise((resolve, reject) => {
|
|
471
|
+
if (port.closeSignal.aborted) return reject(port.closeSignal.reason);
|
|
472
|
+
if (dc.readyState === "open") return resolve();
|
|
473
|
+
const settle = () => {
|
|
474
|
+
dc.removeEventListener("open", done);
|
|
475
|
+
port.closeSignal.removeEventListener("abort", fail);
|
|
476
|
+
signal?.removeEventListener("abort", cancel);
|
|
477
|
+
};
|
|
478
|
+
const done = () => {
|
|
479
|
+
settle();
|
|
480
|
+
resolve();
|
|
481
|
+
};
|
|
482
|
+
const fail = () => {
|
|
483
|
+
settle();
|
|
484
|
+
reject(port.closeSignal.reason);
|
|
485
|
+
};
|
|
486
|
+
const cancel = () => {
|
|
487
|
+
settle();
|
|
488
|
+
port.close();
|
|
489
|
+
reject(signal?.reason);
|
|
490
|
+
};
|
|
491
|
+
dc.addEventListener("open", done);
|
|
492
|
+
port.closeSignal.addEventListener("abort", fail, { once: true });
|
|
493
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
494
|
+
});
|
|
495
|
+
if (signal) {
|
|
496
|
+
const onAbort = () => void port.close();
|
|
497
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
498
|
+
port.closeSignal.addEventListener("abort", () => signal.removeEventListener("abort", onAbort), { once: true });
|
|
499
|
+
}
|
|
500
|
+
return port;
|
|
501
|
+
},
|
|
502
|
+
async close() {
|
|
503
|
+
if (closed) return;
|
|
504
|
+
closed = true;
|
|
505
|
+
pc.removeEventListener("datachannel", onChannel);
|
|
506
|
+
pc.removeEventListener("connectionstatechange", onConnectionState);
|
|
507
|
+
await Promise.allSettled([...live].map((p) => p.close()));
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
//#endregion
|
|
164
512
|
//#region src/connect-serve.ts
|
|
165
513
|
/**
|
|
166
514
|
* Caller-side: each `call(input)` invocation opens a new `RTCDataChannel` on
|
|
@@ -171,18 +519,17 @@ const connect = async ({ pc }) => {
|
|
|
171
519
|
let counter = 0;
|
|
172
520
|
let closed = false;
|
|
173
521
|
const open = /* @__PURE__ */ new Set();
|
|
174
|
-
const
|
|
175
|
-
closed = true;
|
|
522
|
+
const onStateChange = () => {
|
|
523
|
+
if (pc.connectionState === "closed" || pc.connectionState === "failed") closed = true;
|
|
176
524
|
};
|
|
177
|
-
pc.addEventListener("connectionstatechange",
|
|
178
|
-
if (pc.connectionState === "closed" || pc.connectionState === "failed") onConnectionClose();
|
|
179
|
-
});
|
|
525
|
+
pc.addEventListener("connectionstatechange", onStateChange);
|
|
180
526
|
const call = (input) => {
|
|
181
527
|
if (closed) return (async function* () {
|
|
182
528
|
throw new Error("webrun-streams-webrtc: connection closed");
|
|
183
529
|
})();
|
|
184
530
|
const label = `webrun/${Date.now().toString(36)}/${(counter++).toString(36)}`;
|
|
185
531
|
const dc = pc.createDataChannel(label);
|
|
532
|
+
holdEvents(dc);
|
|
186
533
|
open.add(dc);
|
|
187
534
|
dc.addEventListener("close", () => open.delete(dc));
|
|
188
535
|
return (async function* () {
|
|
@@ -194,6 +541,7 @@ const connect = async ({ pc }) => {
|
|
|
194
541
|
call,
|
|
195
542
|
async close() {
|
|
196
543
|
closed = true;
|
|
544
|
+
pc.removeEventListener("connectionstatechange", onStateChange);
|
|
197
545
|
for (const dc of open) try {
|
|
198
546
|
dc.close();
|
|
199
547
|
} catch {}
|
|
@@ -208,15 +556,27 @@ const connect = async ({ pc }) => {
|
|
|
208
556
|
const serve = async ({ pc }, handler) => {
|
|
209
557
|
const onChannel = (ev) => {
|
|
210
558
|
const dc = ev.channel;
|
|
559
|
+
if (dc.label.startsWith("webrun-port/")) return;
|
|
560
|
+
holdEvents(dc);
|
|
561
|
+
markAnswering(dc);
|
|
211
562
|
let inputFromPeer = null;
|
|
212
563
|
(async () => {
|
|
213
564
|
await waitForOpen(dc);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
565
|
+
const peek = peekInput(dc);
|
|
566
|
+
inputFromPeer = peek;
|
|
567
|
+
const handlerOutput = handler(peek.input);
|
|
568
|
+
const out = (async function* () {
|
|
569
|
+
try {
|
|
570
|
+
yield* handlerOutput;
|
|
571
|
+
} finally {
|
|
572
|
+
peek.release();
|
|
573
|
+
}
|
|
574
|
+
})();
|
|
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)));
|
|
220
580
|
});
|
|
221
581
|
};
|
|
222
582
|
pc.addEventListener("datachannel", onChannel);
|
|
@@ -273,40 +633,59 @@ function peekInput(_dc) {
|
|
|
273
633
|
const slots = [];
|
|
274
634
|
let wake = null;
|
|
275
635
|
let closed = false;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
636
|
+
let ended = false;
|
|
637
|
+
const input = (async function* () {
|
|
638
|
+
try {
|
|
639
|
+
while (true) {
|
|
640
|
+
if (slots.length === 0) {
|
|
641
|
+
await new Promise((r) => {
|
|
642
|
+
wake = r;
|
|
643
|
+
});
|
|
644
|
+
wake = null;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const slot = slots.shift();
|
|
648
|
+
if (!slot) continue;
|
|
649
|
+
if (slot.type === "done") {
|
|
650
|
+
if (slot.err) throw slot.err;
|
|
651
|
+
return;
|
|
291
652
|
}
|
|
292
|
-
|
|
293
|
-
|
|
653
|
+
slot.taken();
|
|
654
|
+
yield slot.value;
|
|
294
655
|
}
|
|
295
|
-
}
|
|
656
|
+
} finally {
|
|
657
|
+
release();
|
|
658
|
+
}
|
|
659
|
+
})();
|
|
660
|
+
function release() {
|
|
661
|
+
closed = true;
|
|
662
|
+
for (const s of slots.splice(0)) if (s.type === "value") s.taken();
|
|
663
|
+
wake?.();
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
input,
|
|
296
667
|
deliver(chunk) {
|
|
297
|
-
if (closed) return;
|
|
668
|
+
if (closed) return Promise.resolve();
|
|
669
|
+
return new Promise((taken) => {
|
|
670
|
+
slots.push({
|
|
671
|
+
type: "value",
|
|
672
|
+
value: chunk,
|
|
673
|
+
taken
|
|
674
|
+
});
|
|
675
|
+
wake?.();
|
|
676
|
+
});
|
|
677
|
+
},
|
|
678
|
+
done(err) {
|
|
679
|
+
if (closed || ended) return;
|
|
680
|
+
ended = true;
|
|
298
681
|
slots.push({
|
|
299
|
-
type: "
|
|
300
|
-
|
|
682
|
+
type: "done",
|
|
683
|
+
err
|
|
301
684
|
});
|
|
302
685
|
wake?.();
|
|
303
686
|
},
|
|
304
|
-
|
|
305
|
-
if (closed) return;
|
|
306
|
-
slots.push({ type: "done" });
|
|
307
|
-
wake?.();
|
|
308
|
-
}
|
|
687
|
+
release
|
|
309
688
|
};
|
|
310
689
|
}
|
|
311
690
|
//#endregion
|
|
312
|
-
export { connect, duplexOverDataChannel, serve };
|
|
691
|
+
export { DEFAULT_PORT_MAX_FRAME_BYTES, DEFAULT_PORT_MAX_MESSAGE_SIZE, PORT_DRAIN_LOW_WATER, PORT_LABEL_PREFIX, connect, duplexOverDataChannel, serve, webrtcPortMux };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** The label prefix of a port's data channel: channels without it are someone else's. */
|
|
2
|
+
export declare const PORT_LABEL_PREFIX = "webrun-port/";
|
|
3
|
+
/**
|
|
4
|
+
* The largest message a data channel delivers between any two browsers (Firefox to Chrome cuts
|
|
5
|
+
* off at 16 KiB). A post past it fails the port with a `RangeError` rather than vanishing.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_PORT_MAX_FRAME_BYTES: number;
|
|
8
|
+
/** What one message on a port may carry before the stream tier chunks: 256 bytes under the frame limit (spec D10). */
|
|
9
|
+
export declare const DEFAULT_PORT_MAX_MESSAGE_SIZE: number;
|
|
10
|
+
/** `whenDrained` resolves once the channel's `bufferedAmount` is at most this. */
|
|
11
|
+
export declare const PORT_DRAIN_LOW_WATER: number;
|
|
12
|
+
/**
|
|
13
|
+
* How a port's messages become bytes and back: any value the stream tier posts. `webrun-msgpack`'s
|
|
14
|
+
* `{ encode: serialize, decode: deserialize }` fits.
|
|
15
|
+
*/
|
|
16
|
+
export interface ValueCodec {
|
|
17
|
+
encode(value: unknown): Uint8Array;
|
|
18
|
+
decode(bytes: Uint8Array): unknown;
|
|
19
|
+
}
|
|
20
|
+
type Listener = (event: MessageEvent) => void | Promise<void>;
|
|
21
|
+
/** One port: a data channel as a `MessageTarget` of values, with its close signal on it. */
|
|
22
|
+
export interface DataChannelPort {
|
|
23
|
+
addEventListener(type: "message", listener: Listener): void;
|
|
24
|
+
removeEventListener(type: "message", listener: Listener): void;
|
|
25
|
+
/** Sends one message. `RangeError`, failing the port, past `maxFrameBytes`. */
|
|
26
|
+
postMessage(message: unknown): void;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
readonly bufferedAmount: number;
|
|
29
|
+
whenDrained(): Promise<void>;
|
|
30
|
+
/** Aborts when the channel closes or errs, the connection fails, or the port is closed here. */
|
|
31
|
+
readonly closeSignal: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
export interface WebRtcPortMuxOptions {
|
|
34
|
+
/** A connected `RTCPeerConnection` with SCTP negotiated. Signalling is the caller's. */
|
|
35
|
+
pc: RTCPeerConnection;
|
|
36
|
+
/** Encodes each message; both ends must agree. */
|
|
37
|
+
codec: ValueCodec;
|
|
38
|
+
/**
|
|
39
|
+
* Called for each port the peer opens (an inbound channel with the port label). `false` refuses
|
|
40
|
+
* it. A mux without `onPort` (a caller's) ignores inbound ports, leaving them to the serving
|
|
41
|
+
* mux on the same connection; of several serving muxes on one connection, the first takes each.
|
|
42
|
+
*/
|
|
43
|
+
onPort?: (port: DataChannelPort) => boolean | undefined;
|
|
44
|
+
/** Reported as `maxMessageSize`. Default {@link DEFAULT_PORT_MAX_MESSAGE_SIZE}. */
|
|
45
|
+
maxMessageSize?: number;
|
|
46
|
+
/** Default {@link DEFAULT_PORT_MAX_FRAME_BYTES}. */
|
|
47
|
+
maxFrameBytes?: number;
|
|
48
|
+
}
|
|
49
|
+
/** A `webrun-rpc` `PortMux` by shape; this package imports nothing from it. */
|
|
50
|
+
export interface WebRtcPortMux {
|
|
51
|
+
openPort(meta?: unknown, options?: {
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}): Promise<DataChannelPort>;
|
|
54
|
+
close(reason?: unknown): Promise<void>;
|
|
55
|
+
readonly maxMessageSize: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Native MessagePorts over WebRTC: every port is its own `RTCDataChannel` (ordered, reliable),
|
|
59
|
+
* labelled `webrun-port/…`. Hand it to `webrun-rpc` as a port source and its stream tier runs one
|
|
60
|
+
* call per port, with its backpressure: a producer stays one confirmed chunk ahead of its
|
|
61
|
+
* consumer, where this package's `connect` / `serve` allow a 4 MiB window.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* const codec = { encode: serialize, decode: deserialize }; // webrun-msgpack
|
|
65
|
+
* await serve({ mux: overPorts((onPort) => webrtcPortMux({ pc, codec, onPort })) }, handler);
|
|
66
|
+
* const { call } = await connect({ mux: overPorts(() => webrtcPortMux({ pc, codec })) });
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
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.
|
|
71
|
+
*/
|
|
72
|
+
export declare function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebRtcPortMux>;
|
|
73
|
+
export {};
|
|
74
|
+
//# sourceMappingURL=port-mux.d.ts.map
|
|
@@ -0,0 +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"}
|