@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/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
|
+
}
|