@statewalker/webrun-streams-webrtc 0.1.6 → 0.3.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 +80 -11
- package/dist/connect-serve.d.ts.map +1 -1
- package/dist/duplex-over-data-channel.d.ts +22 -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 +371 -36
- package/dist/port-mux.d.ts +74 -0
- package/dist/port-mux.d.ts.map +1 -0
- package/package.json +7 -5
- package/src/connect-serve.ts +40 -11
- package/src/duplex-over-data-channel.ts +141 -9
- package/src/index.ts +11 -0
- package/src/port-mux.ts +288 -0
package/src/connect-serve.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Connect, Duplex, Serve } from "@statewalker/webrun-streams";
|
|
2
|
-
import { duplexOverDataChannel } from "./duplex-over-data-channel.js";
|
|
2
|
+
import { duplexOverDataChannel, holdEvents, markAnswering } from "./duplex-over-data-channel.js";
|
|
3
|
+
import { PORT_LABEL_PREFIX } from "./port-mux.js";
|
|
3
4
|
|
|
4
5
|
export interface WebRtcParams {
|
|
5
6
|
/** Open `RTCPeerConnection`. Signalling, auth, and connection setup are the caller's responsibility. */
|
|
@@ -31,6 +32,7 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
|
31
32
|
}
|
|
32
33
|
const label = `webrun/${Date.now().toString(36)}/${(counter++).toString(36)}`;
|
|
33
34
|
const dc = pc.createDataChannel(label);
|
|
35
|
+
holdEvents(dc); // the responder may answer before our stream starts listening
|
|
34
36
|
open.add(dc);
|
|
35
37
|
dc.addEventListener("close", () => open.delete(dc));
|
|
36
38
|
return (async function* () {
|
|
@@ -62,13 +64,27 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
|
62
64
|
export const serve: Serve<WebRtcParams> = async ({ pc }, handler: Duplex) => {
|
|
63
65
|
const onChannel = (ev: RTCDataChannelEvent): void => {
|
|
64
66
|
const dc = ev.channel;
|
|
67
|
+
if (dc.label.startsWith(PORT_LABEL_PREFIX)) return; // a port's channel: `webrtcPortMux`'s
|
|
68
|
+
holdEvents(dc);
|
|
69
|
+
markAnswering(dc); // the caller closes the channel once the call is over
|
|
65
70
|
let inputFromPeer: PeekInput | null = null;
|
|
66
71
|
void (async () => {
|
|
67
72
|
await waitForOpen(dc);
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
const peek = peekInput(dc);
|
|
74
|
+
inputFromPeer = peek;
|
|
75
|
+
// Once the handler's output ends (or throws) the handler is done with its input: what the
|
|
76
|
+
// peer still sends is dropped, so its upload finishes instead of waiting for credit.
|
|
77
|
+
const handlerOutput = handler(peek.input);
|
|
78
|
+
const out = (async function* () {
|
|
79
|
+
try {
|
|
80
|
+
yield* handlerOutput;
|
|
81
|
+
} finally {
|
|
82
|
+
peek.release();
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
// the next chunk is pulled (and its credit returned) only once the handler took this one
|
|
70
86
|
for await (const chunk of duplexOverDataChannel(dc, out)) {
|
|
71
|
-
inputFromPeer.deliver(chunk);
|
|
87
|
+
await inputFromPeer.deliver(chunk);
|
|
72
88
|
}
|
|
73
89
|
inputFromPeer.done();
|
|
74
90
|
})().catch(() => {
|
|
@@ -137,8 +153,11 @@ function toAsyncIterable(
|
|
|
137
153
|
|
|
138
154
|
interface PeekInput {
|
|
139
155
|
input: AsyncGenerator<Uint8Array>;
|
|
140
|
-
|
|
156
|
+
/** resolves once the handler has taken the chunk (or stopped reading) */
|
|
157
|
+
deliver(chunk: Uint8Array): Promise<void>;
|
|
141
158
|
done(): void;
|
|
159
|
+
/** the handler is finished: pending and later chunks are dropped */
|
|
160
|
+
release(): void;
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
/**
|
|
@@ -152,7 +171,8 @@ interface PeekInput {
|
|
|
152
171
|
* the handler's input queue.
|
|
153
172
|
*/
|
|
154
173
|
function peekInput(_dc: RTCDataChannel): PeekInput {
|
|
155
|
-
const slots: Array<{ type: "value"; value: Uint8Array } | { type: "done" }> =
|
|
174
|
+
const slots: Array<{ type: "value"; value: Uint8Array; taken: () => void } | { type: "done" }> =
|
|
175
|
+
[];
|
|
156
176
|
let wake: (() => void) | null = null;
|
|
157
177
|
let closed = false;
|
|
158
178
|
const input = (async function* () {
|
|
@@ -168,23 +188,32 @@ function peekInput(_dc: RTCDataChannel): PeekInput {
|
|
|
168
188
|
const slot = slots.shift();
|
|
169
189
|
if (!slot) continue;
|
|
170
190
|
if (slot.type === "done") return;
|
|
191
|
+
slot.taken();
|
|
171
192
|
yield slot.value;
|
|
172
193
|
}
|
|
173
194
|
} finally {
|
|
174
|
-
|
|
195
|
+
release();
|
|
175
196
|
}
|
|
176
197
|
})();
|
|
198
|
+
function release(): void {
|
|
199
|
+
closed = true;
|
|
200
|
+
for (const s of slots.splice(0)) if (s.type === "value") s.taken();
|
|
201
|
+
wake?.();
|
|
202
|
+
}
|
|
177
203
|
return {
|
|
178
204
|
input,
|
|
179
|
-
deliver(chunk: Uint8Array): void {
|
|
180
|
-
if (closed) return;
|
|
181
|
-
|
|
182
|
-
|
|
205
|
+
deliver(chunk: Uint8Array): Promise<void> {
|
|
206
|
+
if (closed) return Promise.resolve();
|
|
207
|
+
return new Promise<void>((taken) => {
|
|
208
|
+
slots.push({ type: "value", value: chunk, taken });
|
|
209
|
+
wake?.();
|
|
210
|
+
});
|
|
183
211
|
},
|
|
184
212
|
done(): void {
|
|
185
213
|
if (closed) return;
|
|
186
214
|
slots.push({ type: "done" });
|
|
187
215
|
wake?.();
|
|
188
216
|
},
|
|
217
|
+
release,
|
|
189
218
|
};
|
|
190
219
|
}
|
|
@@ -8,13 +8,71 @@ 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
|
+
export const RECEIVE_WINDOW = 4 * 1024 * 1024;
|
|
13
31
|
const FRAME_OVERHEAD = 1; // 1-byte type tag
|
|
14
32
|
|
|
15
33
|
const textEncoder = new TextEncoder();
|
|
16
34
|
const textDecoder = new TextDecoder();
|
|
17
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Messages (and a close) that arrive on a channel before its stream starts listening. A generator
|
|
38
|
+
* runs only on its first `next()`, so without this a peer that answers at once (a handler that
|
|
39
|
+
* throws, a reply with no input to wait for) can be faster than our listener: its frames are
|
|
40
|
+
* dropped and the call hangs or reports a bare close.
|
|
41
|
+
*/
|
|
42
|
+
const held = new WeakMap<RTCDataChannel, { events: Event[]; listener: (ev: Event) => void }>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Channels this side answers (from `datachannel`). A finished call is closed by its caller only,
|
|
46
|
+
* once it has the responder's `END` or `ERROR`: a responder that closed right after sending them
|
|
47
|
+
* could lose them in flight (Chrome delivers the close first), turning an error into a bare close.
|
|
48
|
+
*/
|
|
49
|
+
const answering = new WeakSet<RTCDataChannel>();
|
|
50
|
+
export function markAnswering(dc: RTCDataChannel): void {
|
|
51
|
+
answering.add(dc);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** starts holding a channel's events until {@link duplexOverDataChannel} takes them */
|
|
55
|
+
export function holdEvents(dc: RTCDataChannel): void {
|
|
56
|
+
if (held.has(dc)) return;
|
|
57
|
+
dc.binaryType = "arraybuffer";
|
|
58
|
+
const events: Event[] = [];
|
|
59
|
+
const listener = (ev: Event): void => {
|
|
60
|
+
events.push(ev);
|
|
61
|
+
};
|
|
62
|
+
dc.addEventListener("message", listener);
|
|
63
|
+
dc.addEventListener("close", listener);
|
|
64
|
+
held.set(dc, { events, listener });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function takeHeld(dc: RTCDataChannel): Event[] {
|
|
68
|
+
const h = held.get(dc);
|
|
69
|
+
if (!h) return [];
|
|
70
|
+
held.delete(dc);
|
|
71
|
+
dc.removeEventListener("message", h.listener);
|
|
72
|
+
dc.removeEventListener("close", h.listener);
|
|
73
|
+
return h.events;
|
|
74
|
+
}
|
|
75
|
+
|
|
18
76
|
/**
|
|
19
77
|
* Drive one `Duplex` over one `RTCDataChannel`. The DataChannel hosts a tiny
|
|
20
78
|
* in-band framing protocol:
|
|
@@ -23,7 +81,9 @@ const textDecoder = new TextDecoder();
|
|
|
23
81
|
*
|
|
24
82
|
* Types are `DATA` (0x00, body bytes), `END` (0x01, signals half-close — the
|
|
25
83
|
* sender is done emitting; the channel stays open for the other direction),
|
|
26
|
-
*
|
|
84
|
+
* `ERROR` (0x02, followed by a serialised `Error`) and `CREDIT` (0x03, a
|
|
85
|
+
* big-endian u32: payload bytes the receiver's consumer has pulled, which the
|
|
86
|
+
* sender may now send again; see {@link RECEIVE_WINDOW}).
|
|
27
87
|
*
|
|
28
88
|
* `RTCDataChannel` has no native half-close, so `END` is emitted by the
|
|
29
89
|
* sender once its input iterator exhausts; the channel is only physically
|
|
@@ -46,8 +106,52 @@ async function* runStream(
|
|
|
46
106
|
let peerEnded = false;
|
|
47
107
|
let localEnded = false;
|
|
48
108
|
|
|
109
|
+
// flow control: what the peer still lets us send, and what our consumer has pulled
|
|
110
|
+
let credit = RECEIVE_WINDOW;
|
|
111
|
+
let pulled = 0;
|
|
112
|
+
let wakeSender: (() => void) | null = null;
|
|
113
|
+
const nudge = (): void => {
|
|
114
|
+
const w = wakeSender;
|
|
115
|
+
wakeSender = null;
|
|
116
|
+
w?.();
|
|
117
|
+
};
|
|
118
|
+
const stalled = (): Promise<void> =>
|
|
119
|
+
new Promise<void>((r) => {
|
|
120
|
+
wakeSender = r;
|
|
121
|
+
});
|
|
122
|
+
dc.bufferedAmountLowThreshold = LOW_WATER_MARK;
|
|
123
|
+
const sendRaw = (bytes: Uint8Array): void => {
|
|
124
|
+
(dc as unknown as { send: (data: Uint8Array) => void }).send(bytes);
|
|
125
|
+
};
|
|
126
|
+
/** set when this call is abandoned (cancelled, or the peer failed): the sender stops waiting */
|
|
127
|
+
let aborted = false;
|
|
128
|
+
/** waits for credit for `n` bytes and room in the send buffer; false once the call is over */
|
|
129
|
+
const room = async (n: number): Promise<boolean> => {
|
|
130
|
+
while (
|
|
131
|
+
!aborted &&
|
|
132
|
+
dc.readyState === "open" &&
|
|
133
|
+
(credit < n || dc.bufferedAmount > HIGH_WATER_MARK)
|
|
134
|
+
)
|
|
135
|
+
await stalled();
|
|
136
|
+
return !aborted && dc.readyState === "open";
|
|
137
|
+
};
|
|
138
|
+
/** returns credit for what our consumer pulled, half a window at a time */
|
|
139
|
+
const consumed = (n: number): void => {
|
|
140
|
+
pulled += n;
|
|
141
|
+
if (pulled < RECEIVE_WINDOW / 2 || dc.readyState !== "open") return;
|
|
142
|
+
const frame = new Uint8Array(5);
|
|
143
|
+
frame[0] = TYPE_CREDIT;
|
|
144
|
+
new DataView(frame.buffer).setUint32(1, pulled);
|
|
145
|
+
pulled = 0;
|
|
146
|
+
try {
|
|
147
|
+
sendRaw(frame);
|
|
148
|
+
} catch {
|
|
149
|
+
/* closing */
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
49
153
|
const maybeClose = (): void => {
|
|
50
|
-
if (peerEnded && localEnded) {
|
|
154
|
+
if (peerEnded && localEnded && !answering.has(dc)) {
|
|
51
155
|
try {
|
|
52
156
|
dc.close();
|
|
53
157
|
} catch {
|
|
@@ -74,6 +178,9 @@ async function* runStream(
|
|
|
74
178
|
peerEnded = true;
|
|
75
179
|
incoming.done();
|
|
76
180
|
maybeClose();
|
|
181
|
+
} else if (type === TYPE_CREDIT && payload.byteLength >= 4) {
|
|
182
|
+
credit += new DataView(payload.buffer, payload.byteOffset, 4).getUint32(0);
|
|
183
|
+
nudge();
|
|
77
184
|
} else if (type === TYPE_ERROR) {
|
|
78
185
|
let err: Error;
|
|
79
186
|
try {
|
|
@@ -88,6 +195,7 @@ async function* runStream(
|
|
|
88
195
|
};
|
|
89
196
|
|
|
90
197
|
const onClose = (): void => {
|
|
198
|
+
nudge();
|
|
91
199
|
if (!peerEnded) {
|
|
92
200
|
incoming.done(new TransportClosedError());
|
|
93
201
|
peerEnded = true;
|
|
@@ -96,16 +204,33 @@ async function* runStream(
|
|
|
96
204
|
|
|
97
205
|
dc.addEventListener("message", onMessage);
|
|
98
206
|
dc.addEventListener("close", onClose);
|
|
207
|
+
dc.addEventListener("bufferedamountlow", nudge);
|
|
208
|
+
// what arrived before we listened, in order
|
|
209
|
+
for (const ev of takeHeld(dc)) {
|
|
210
|
+
if (ev.type === "message") onMessage(ev as MessageEvent);
|
|
211
|
+
else onClose();
|
|
212
|
+
}
|
|
213
|
+
if (dc.readyState === "closed") onClose();
|
|
214
|
+
// The listeners stay until both halves are done: after the peer's END our own sending may still
|
|
215
|
+
// wait for the peer's CREDIT frames (a reply larger than the window, sent after the input ended).
|
|
216
|
+
let inboundFinished = false;
|
|
217
|
+
const detach = (): void => {
|
|
218
|
+
if (!inboundFinished || !localEnded) return;
|
|
219
|
+
dc.removeEventListener("message", onMessage);
|
|
220
|
+
dc.removeEventListener("close", onClose);
|
|
221
|
+
dc.removeEventListener("bufferedamountlow", nudge);
|
|
222
|
+
};
|
|
99
223
|
|
|
100
224
|
// Outbound pump runs in parallel with inbound iteration.
|
|
101
225
|
const outbound = (async () => {
|
|
102
226
|
try {
|
|
103
227
|
for await (const chunk of toChunks(DC_MTU - FRAME_OVERHEAD)(input)) {
|
|
104
|
-
if (
|
|
228
|
+
if (!(await room(chunk.byteLength))) return;
|
|
229
|
+
credit -= chunk.byteLength;
|
|
105
230
|
const framed = new Uint8Array(chunk.byteLength + 1);
|
|
106
231
|
framed[0] = TYPE_DATA;
|
|
107
232
|
framed.set(chunk, 1);
|
|
108
|
-
(
|
|
233
|
+
sendRaw(framed);
|
|
109
234
|
}
|
|
110
235
|
if (dc.readyState === "open") {
|
|
111
236
|
const end = new Uint8Array([TYPE_END]);
|
|
@@ -127,6 +252,7 @@ async function* runStream(
|
|
|
127
252
|
} finally {
|
|
128
253
|
localEnded = true;
|
|
129
254
|
maybeClose();
|
|
255
|
+
detach();
|
|
130
256
|
}
|
|
131
257
|
})();
|
|
132
258
|
|
|
@@ -134,13 +260,15 @@ async function* runStream(
|
|
|
134
260
|
try {
|
|
135
261
|
for await (const chunk of incoming.iterate()) {
|
|
136
262
|
yield chunk;
|
|
263
|
+
consumed(chunk.byteLength); // pulled: the peer may send that much again
|
|
137
264
|
}
|
|
138
265
|
inboundDrained = true;
|
|
139
266
|
} finally {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
267
|
+
inboundFinished = true;
|
|
268
|
+
detach();
|
|
269
|
+
// On an early exit, stop the outbound pump (it may be waiting for credit
|
|
270
|
+
// the peer will never send now), close the channel, then let the pump
|
|
271
|
+
// settle — but ONLY on an early exit.
|
|
144
272
|
//
|
|
145
273
|
// Awaiting it unconditionally deadlocks a responder. `serve` feeds the
|
|
146
274
|
// handler from the values this generator yields and closes the handler's
|
|
@@ -154,7 +282,8 @@ async function* runStream(
|
|
|
154
282
|
// there is nothing of theirs left to truncate; outbound keeps running on
|
|
155
283
|
// its own and `maybeClose` still closes the channel once both halves end.
|
|
156
284
|
if (!inboundDrained) {
|
|
157
|
-
|
|
285
|
+
aborted = true;
|
|
286
|
+
nudge();
|
|
158
287
|
// The consumer walked away mid-response (`.return()` on the generator),
|
|
159
288
|
// or the peer errored. Either way this call is over while the peer may
|
|
160
289
|
// still be producing, and a DataChannel has no half-close to say so —
|
|
@@ -164,11 +293,14 @@ async function* runStream(
|
|
|
164
293
|
// `onClose` then ends its inbound queue, which closes the handler's
|
|
165
294
|
// input and runs the handler's `finally`. Without this, cancellation is
|
|
166
295
|
// silently local and the handler leaks until the connection drops.
|
|
296
|
+
// The close comes before waiting on the pump: a pump stalled on credit
|
|
297
|
+
// would otherwise hold the cancellation back.
|
|
167
298
|
try {
|
|
168
299
|
dc.close();
|
|
169
300
|
} catch {
|
|
170
301
|
/* already closing */
|
|
171
302
|
}
|
|
303
|
+
await outbound.catch(() => {});
|
|
172
304
|
}
|
|
173
305
|
}
|
|
174
306
|
}
|
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";
|
package/src/port-mux.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/** The label prefix of a port's data channel: channels without it are someone else's. */
|
|
2
|
+
export const PORT_LABEL_PREFIX = "webrun-port/";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The largest message a data channel delivers between any two browsers (Firefox to Chrome cuts
|
|
6
|
+
* off at 16 KiB). A post past it fails the port with a `RangeError` rather than vanishing.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_PORT_MAX_FRAME_BYTES = 16 * 1024;
|
|
9
|
+
|
|
10
|
+
/** What one message on a port may carry before the stream tier chunks: 256 bytes under the frame limit (spec D10). */
|
|
11
|
+
export const DEFAULT_PORT_MAX_MESSAGE_SIZE = DEFAULT_PORT_MAX_FRAME_BYTES - 256;
|
|
12
|
+
|
|
13
|
+
/** `whenDrained` resolves once the channel's `bufferedAmount` is at most this. */
|
|
14
|
+
export const PORT_DRAIN_LOW_WATER = 256 * 1024;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How a port's messages become bytes and back: any value the stream tier posts. `webrun-msgpack`'s
|
|
18
|
+
* `{ encode: serialize, decode: deserialize }` fits.
|
|
19
|
+
*/
|
|
20
|
+
export interface ValueCodec {
|
|
21
|
+
encode(value: unknown): Uint8Array;
|
|
22
|
+
decode(bytes: Uint8Array): unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Listener = (event: MessageEvent) => void | Promise<void>;
|
|
26
|
+
|
|
27
|
+
/** One port: a data channel as a `MessageTarget` of values, with its close signal on it. */
|
|
28
|
+
export interface DataChannelPort {
|
|
29
|
+
addEventListener(type: "message", listener: Listener): void;
|
|
30
|
+
removeEventListener(type: "message", listener: Listener): void;
|
|
31
|
+
/** Sends one message. `RangeError`, failing the port, past `maxFrameBytes`. */
|
|
32
|
+
postMessage(message: unknown): void;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
readonly bufferedAmount: number;
|
|
35
|
+
whenDrained(): Promise<void>;
|
|
36
|
+
/** Aborts when the channel closes or errs, the connection fails, or the port is closed here. */
|
|
37
|
+
readonly closeSignal: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WebRtcPortMuxOptions {
|
|
41
|
+
/** A connected `RTCPeerConnection` with SCTP negotiated. Signalling is the caller's. */
|
|
42
|
+
pc: RTCPeerConnection;
|
|
43
|
+
/** Encodes each message; both ends must agree. */
|
|
44
|
+
codec: ValueCodec;
|
|
45
|
+
/**
|
|
46
|
+
* Called for each port the peer opens (an inbound channel with the port label). `false` refuses
|
|
47
|
+
* it. A mux without `onPort` (a caller's) ignores inbound ports, leaving them to the serving
|
|
48
|
+
* mux on the same connection; of several serving muxes on one connection, the first takes each.
|
|
49
|
+
*/
|
|
50
|
+
onPort?: (port: DataChannelPort) => boolean | undefined;
|
|
51
|
+
/** Reported as `maxMessageSize`. Default {@link DEFAULT_PORT_MAX_MESSAGE_SIZE}. */
|
|
52
|
+
maxMessageSize?: number;
|
|
53
|
+
/** Default {@link DEFAULT_PORT_MAX_FRAME_BYTES}. */
|
|
54
|
+
maxFrameBytes?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A `webrun-rpc` `PortMux` by shape; this package imports nothing from it. */
|
|
58
|
+
export interface WebRtcPortMux {
|
|
59
|
+
openPort(meta?: unknown, options?: { signal?: AbortSignal }): Promise<DataChannelPort>;
|
|
60
|
+
close(reason?: unknown): Promise<void>;
|
|
61
|
+
readonly maxMessageSize: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let counter = 0;
|
|
65
|
+
/** Inbound port channels a serving mux has taken: on one connection, each port is answered once. */
|
|
66
|
+
const claimed = new WeakSet<RTCDataChannel>();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Native MessagePorts over WebRTC: every port is its own `RTCDataChannel` (ordered, reliable),
|
|
70
|
+
* labelled `webrun-port/…`. Hand it to `webrun-rpc` as a port source and its stream tier runs one
|
|
71
|
+
* call per port, with its backpressure: a producer stays one confirmed chunk ahead of its
|
|
72
|
+
* consumer, where this package's `connect` / `serve` allow a 4 MiB window.
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* const codec = { encode: serialize, decode: deserialize }; // webrun-msgpack
|
|
76
|
+
* await serve({ mux: overPorts((onPort) => webrtcPortMux({ pc, codec, onPort })) }, handler);
|
|
77
|
+
* const { call } = await connect({ mux: overPorts(() => webrtcPortMux({ pc, codec })) });
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
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.
|
|
82
|
+
*/
|
|
83
|
+
export async function webrtcPortMux(options: WebRtcPortMuxOptions): Promise<WebRtcPortMux> {
|
|
84
|
+
const { pc, codec, onPort } = options;
|
|
85
|
+
const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_PORT_MAX_FRAME_BYTES;
|
|
86
|
+
const maxMessageSize = options.maxMessageSize ?? DEFAULT_PORT_MAX_MESSAGE_SIZE;
|
|
87
|
+
const live = new Set<DataChannelPort>();
|
|
88
|
+
let closed = false;
|
|
89
|
+
|
|
90
|
+
const connectionGone = new AbortController();
|
|
91
|
+
const onConnectionState = () => {
|
|
92
|
+
if (pc.connectionState === "failed" || pc.connectionState === "closed")
|
|
93
|
+
connectionGone.abort(
|
|
94
|
+
new Error(`webrun-streams-webrtc: the connection is ${pc.connectionState}`),
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
pc.addEventListener("connectionstatechange", onConnectionState);
|
|
98
|
+
onConnectionState(); // a connection that has failed already
|
|
99
|
+
|
|
100
|
+
const wrap = (dc: RTCDataChannel): DataChannelPort => {
|
|
101
|
+
dc.binaryType = "arraybuffer";
|
|
102
|
+
dc.bufferedAmountLowThreshold = PORT_DRAIN_LOW_WATER;
|
|
103
|
+
const life = new AbortController();
|
|
104
|
+
const end = (reason: Error) => {
|
|
105
|
+
if (!life.signal.aborted) life.abort(reason);
|
|
106
|
+
};
|
|
107
|
+
const listeners = new Set<Listener>();
|
|
108
|
+
let held: unknown[] | undefined = [];
|
|
109
|
+
const deliver = (data: unknown) => {
|
|
110
|
+
if (closeSignal.aborted) return;
|
|
111
|
+
const event = new MessageEvent("message", { data });
|
|
112
|
+
for (const l of [...listeners]) {
|
|
113
|
+
try {
|
|
114
|
+
Promise.resolve(l(event)).catch(() => undefined);
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const onMessage = (ev: MessageEvent) => {
|
|
119
|
+
if (closeSignal.aborted) return;
|
|
120
|
+
let data: unknown;
|
|
121
|
+
try {
|
|
122
|
+
const raw = ev.data;
|
|
123
|
+
data = codec.decode(raw instanceof ArrayBuffer ? new Uint8Array(raw) : (raw as Uint8Array));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
end(
|
|
126
|
+
new Error("webrun-streams-webrtc: a frame the port's codec cannot decode", {
|
|
127
|
+
cause: err,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (held) held.push(data);
|
|
133
|
+
else deliver(data);
|
|
134
|
+
};
|
|
135
|
+
const onClose = () => end(new Error("webrun-streams-webrtc: the data channel closed"));
|
|
136
|
+
const onError = (ev: Event) =>
|
|
137
|
+
end(
|
|
138
|
+
new Error(
|
|
139
|
+
`webrun-streams-webrtc: the data channel failed: ${(ev as unknown as { error?: { message?: string } }).error?.message ?? "unknown"}`,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
dc.addEventListener("message", onMessage);
|
|
143
|
+
dc.addEventListener("close", onClose);
|
|
144
|
+
dc.addEventListener("error", onError);
|
|
145
|
+
if (dc.readyState === "closing" || dc.readyState === "closed") onClose();
|
|
146
|
+
const closeSignal = AbortSignal.any([life.signal, connectionGone.signal]);
|
|
147
|
+
const cleanUp = () => {
|
|
148
|
+
live.delete(port);
|
|
149
|
+
dc.removeEventListener("message", onMessage);
|
|
150
|
+
listeners.clear();
|
|
151
|
+
held = undefined;
|
|
152
|
+
if (dc.readyState !== "closed") dc.close();
|
|
153
|
+
};
|
|
154
|
+
const port: DataChannelPort = {
|
|
155
|
+
closeSignal,
|
|
156
|
+
get bufferedAmount() {
|
|
157
|
+
return dc.bufferedAmount;
|
|
158
|
+
},
|
|
159
|
+
whenDrained() {
|
|
160
|
+
if (closeSignal.aborted || dc.bufferedAmount <= PORT_DRAIN_LOW_WATER)
|
|
161
|
+
return Promise.resolve();
|
|
162
|
+
return new Promise<void>((resolve) => {
|
|
163
|
+
const done = () => {
|
|
164
|
+
dc.removeEventListener("bufferedamountlow", done);
|
|
165
|
+
closeSignal.removeEventListener("abort", done);
|
|
166
|
+
resolve();
|
|
167
|
+
};
|
|
168
|
+
dc.addEventListener("bufferedamountlow", done);
|
|
169
|
+
closeSignal.addEventListener("abort", done);
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
postMessage(message) {
|
|
173
|
+
if (closeSignal.aborted || dc.readyState !== "open") return; // `closeSignal` says so
|
|
174
|
+
const bytes = codec.encode(message);
|
|
175
|
+
if (bytes.byteLength > maxFrameBytes) {
|
|
176
|
+
const err = new RangeError(
|
|
177
|
+
`webrun-streams-webrtc: a ${bytes.byteLength}-byte message exceeds maxFrameBytes (${maxFrameBytes})`,
|
|
178
|
+
);
|
|
179
|
+
end(err);
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
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
|
+
}
|
|
188
|
+
},
|
|
189
|
+
addEventListener(_type, listener) {
|
|
190
|
+
listeners.add(listener);
|
|
191
|
+
if (held) {
|
|
192
|
+
// delivered after this tick, as a MessagePort dispatches its queue: every listener
|
|
193
|
+
// added meanwhile (the stream tier adds two in a row) gets the held messages
|
|
194
|
+
const pending = held;
|
|
195
|
+
held = undefined;
|
|
196
|
+
queueMicrotask(() => {
|
|
197
|
+
for (const data of pending) deliver(data);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
removeEventListener(_type, listener) {
|
|
202
|
+
listeners.delete(listener);
|
|
203
|
+
},
|
|
204
|
+
async close() {
|
|
205
|
+
end(new Error("webrun-streams-webrtc: port closed"));
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
live.add(port);
|
|
209
|
+
// a port whose channel or connection was gone already: an abort listener would never fire
|
|
210
|
+
if (closeSignal.aborted) cleanUp();
|
|
211
|
+
else closeSignal.addEventListener("abort", cleanUp, { once: true });
|
|
212
|
+
return port;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const onChannel = (ev: RTCDataChannelEvent) => {
|
|
216
|
+
const dc = ev.channel;
|
|
217
|
+
if (!dc.label.startsWith(PORT_LABEL_PREFIX)) return; // someone else's channel
|
|
218
|
+
// A mux that accepts no ports (a caller's) leaves inbound ports to the serving mux on the
|
|
219
|
+
// same connection; of several serving muxes, the first takes each port.
|
|
220
|
+
if (!onPort || closed || claimed.has(dc)) return;
|
|
221
|
+
claimed.add(dc);
|
|
222
|
+
const port = wrap(dc);
|
|
223
|
+
let accepted = false;
|
|
224
|
+
try {
|
|
225
|
+
accepted = onPort(port) !== false;
|
|
226
|
+
} catch {
|
|
227
|
+
accepted = false;
|
|
228
|
+
}
|
|
229
|
+
if (!accepted) void port.close();
|
|
230
|
+
};
|
|
231
|
+
pc.addEventListener("datachannel", onChannel);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
maxMessageSize,
|
|
235
|
+
async openPort(_meta, opts) {
|
|
236
|
+
if (closed) throw new Error("webrun-streams-webrtc: port mux closed");
|
|
237
|
+
const signal = opts?.signal;
|
|
238
|
+
signal?.throwIfAborted();
|
|
239
|
+
if (connectionGone.signal.aborted) throw connectionGone.signal.reason;
|
|
240
|
+
const dc = pc.createDataChannel(`${PORT_LABEL_PREFIX}${(counter++).toString(36)}`, {
|
|
241
|
+
ordered: true,
|
|
242
|
+
});
|
|
243
|
+
const port = wrap(dc);
|
|
244
|
+
await new Promise<void>((resolve, reject) => {
|
|
245
|
+
if (port.closeSignal.aborted) return reject(port.closeSignal.reason);
|
|
246
|
+
if (dc.readyState === "open") return resolve();
|
|
247
|
+
const settle = () => {
|
|
248
|
+
dc.removeEventListener("open", done);
|
|
249
|
+
port.closeSignal.removeEventListener("abort", fail);
|
|
250
|
+
signal?.removeEventListener("abort", cancel);
|
|
251
|
+
};
|
|
252
|
+
const done = () => {
|
|
253
|
+
settle();
|
|
254
|
+
resolve();
|
|
255
|
+
};
|
|
256
|
+
const fail = () => {
|
|
257
|
+
settle();
|
|
258
|
+
reject(port.closeSignal.reason);
|
|
259
|
+
};
|
|
260
|
+
const cancel = () => {
|
|
261
|
+
settle();
|
|
262
|
+
void port.close();
|
|
263
|
+
reject(signal?.reason);
|
|
264
|
+
};
|
|
265
|
+
dc.addEventListener("open", done);
|
|
266
|
+
port.closeSignal.addEventListener("abort", fail, { once: true });
|
|
267
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
268
|
+
});
|
|
269
|
+
if (signal) {
|
|
270
|
+
const onAbort = () => void port.close();
|
|
271
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
272
|
+
port.closeSignal.addEventListener(
|
|
273
|
+
"abort",
|
|
274
|
+
() => signal.removeEventListener("abort", onAbort),
|
|
275
|
+
{ once: true },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return port;
|
|
279
|
+
},
|
|
280
|
+
async close() {
|
|
281
|
+
if (closed) return;
|
|
282
|
+
closed = true;
|
|
283
|
+
pc.removeEventListener("datachannel", onChannel);
|
|
284
|
+
pc.removeEventListener("connectionstatechange", onConnectionState);
|
|
285
|
+
await Promise.allSettled([...live].map((p) => p.close()));
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|