@statewalker/webrun-streams-webrtc 0.1.1
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/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/connect-serve.d.ts +18 -0
- package/dist/connect-serve.d.ts.map +1 -0
- package/dist/duplex-over-data-channel.d.ts +16 -0
- package/dist/duplex-over-data-channel.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +302 -0
- package/package.json +46 -0
- package/src/connect-serve.ts +182 -0
- package/src/duplex-over-data-channel.ts +193 -0
- package/src/index.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026 statewalker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @statewalker/webrun-streams-webrtc
|
|
2
|
+
|
|
3
|
+
WebRTC native multi-stream `Connect` / `Serve` adapter. Each `call(input)` opens a fresh `RTCDataChannel` on the supplied `RTCPeerConnection`; the responder listens on `pc.ondatachannel`.
|
|
4
|
+
|
|
5
|
+
DataChannels have no native half-close, so the adapter carries a tiny 1-byte frame protocol inside each DC message: `DATA` (0x00, body bytes), `END` (0x01, half-close), `ERROR` (0x02, serialised error). Outbound bytes are chunked at 16 KiB.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { connect, serve } from "@statewalker/webrun-streams-webrtc";
|
|
9
|
+
|
|
10
|
+
const { call } = await connect({ pc }); // RTCPeerConnection
|
|
11
|
+
const stop = await serve({ pc }, handler);
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## License
|
|
15
|
+
|
|
16
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Connect, Serve } from "@statewalker/webrun-streams";
|
|
2
|
+
export interface WebRtcParams {
|
|
3
|
+
/** Open `RTCPeerConnection`. Signalling, auth, and connection setup are the caller's responsibility. */
|
|
4
|
+
pc: RTCPeerConnection;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Caller-side: each `call(input)` invocation opens a new `RTCDataChannel` on
|
|
8
|
+
* `pc` and runs the call over it. The DataChannel's `label` is generated
|
|
9
|
+
* locally and the responder picks the channel up via `pc.ondatachannel`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const connect: Connect<WebRtcParams>;
|
|
12
|
+
/**
|
|
13
|
+
* Server-side: registers `pc.ondatachannel` and routes each inbound channel
|
|
14
|
+
* to `handler`. Returns a teardown that stops accepting new channels;
|
|
15
|
+
* channels already in flight continue until their `Duplex` completes.
|
|
16
|
+
*/
|
|
17
|
+
export declare const serve: Serve<WebRtcParams>;
|
|
18
|
+
//# sourceMappingURL=connect-serve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAG1E,MAAM,WAAW,YAAY;IAC3B,wGAAwG;IACxG,EAAE,EAAE,iBAAiB,CAAC;CACvB;AAED;;;;GAIG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,YAAY,CAyCzC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,YAAY,CAoBrC,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive one `Duplex` over one `RTCDataChannel`. The DataChannel hosts a tiny
|
|
3
|
+
* in-band framing protocol:
|
|
4
|
+
*
|
|
5
|
+
* [1-byte type][payload bytes...]
|
|
6
|
+
*
|
|
7
|
+
* Types are `DATA` (0x00, body bytes), `END` (0x01, signals half-close — the
|
|
8
|
+
* sender is done emitting; the channel stays open for the other direction),
|
|
9
|
+
* and `ERROR` (0x02, followed by a serialised `Error`).
|
|
10
|
+
*
|
|
11
|
+
* `RTCDataChannel` has no native half-close, so `END` is emitted by the
|
|
12
|
+
* sender once its input iterator exhausts; the channel is only physically
|
|
13
|
+
* closed once both sides have ended.
|
|
14
|
+
*/
|
|
15
|
+
export declare function duplexOverDataChannel(dc: RTCDataChannel, input: AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array>;
|
|
16
|
+
//# sourceMappingURL=duplex-over-data-channel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duplex-over-data-channel.d.ts","sourceRoot":"","sources":["../src/duplex-over-data-channel.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAC/B,cAAc,CAAC,UAAU,CAAC,CAE5B"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { TransportClosedError, deserializeError, serializeError, toChunks } from "@statewalker/webrun-streams";
|
|
2
|
+
//#region src/duplex-over-data-channel.ts
|
|
3
|
+
const TYPE_DATA = 0;
|
|
4
|
+
const TYPE_END = 1;
|
|
5
|
+
const TYPE_ERROR = 2;
|
|
6
|
+
const textEncoder = new TextEncoder();
|
|
7
|
+
const textDecoder = new TextDecoder();
|
|
8
|
+
/**
|
|
9
|
+
* Drive one `Duplex` over one `RTCDataChannel`. The DataChannel hosts a tiny
|
|
10
|
+
* in-band framing protocol:
|
|
11
|
+
*
|
|
12
|
+
* [1-byte type][payload bytes...]
|
|
13
|
+
*
|
|
14
|
+
* Types are `DATA` (0x00, body bytes), `END` (0x01, signals half-close — the
|
|
15
|
+
* sender is done emitting; the channel stays open for the other direction),
|
|
16
|
+
* and `ERROR` (0x02, followed by a serialised `Error`).
|
|
17
|
+
*
|
|
18
|
+
* `RTCDataChannel` has no native half-close, so `END` is emitted by the
|
|
19
|
+
* sender once its input iterator exhausts; the channel is only physically
|
|
20
|
+
* closed once both sides have ended.
|
|
21
|
+
*/
|
|
22
|
+
function duplexOverDataChannel(dc, input) {
|
|
23
|
+
return runStream(dc, input);
|
|
24
|
+
}
|
|
25
|
+
async function* runStream(dc, input) {
|
|
26
|
+
dc.binaryType = "arraybuffer";
|
|
27
|
+
const incoming = makeInboundQueue();
|
|
28
|
+
let peerEnded = false;
|
|
29
|
+
let localEnded = false;
|
|
30
|
+
const maybeClose = () => {
|
|
31
|
+
if (peerEnded && localEnded) try {
|
|
32
|
+
dc.close();
|
|
33
|
+
} catch {}
|
|
34
|
+
};
|
|
35
|
+
const onMessage = (event) => {
|
|
36
|
+
const data = event.data;
|
|
37
|
+
let bytes;
|
|
38
|
+
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
|
39
|
+
else if (data instanceof Uint8Array) bytes = data;
|
|
40
|
+
else if (ArrayBuffer.isView(data)) {
|
|
41
|
+
const view = data;
|
|
42
|
+
bytes = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
|
|
43
|
+
} else return;
|
|
44
|
+
if (bytes.byteLength === 0) return;
|
|
45
|
+
const type = bytes[0];
|
|
46
|
+
const payload = bytes.subarray(1);
|
|
47
|
+
if (type === TYPE_DATA) incoming.push(new Uint8Array(payload));
|
|
48
|
+
else if (type === TYPE_END) {
|
|
49
|
+
peerEnded = true;
|
|
50
|
+
incoming.done();
|
|
51
|
+
maybeClose();
|
|
52
|
+
} else if (type === TYPE_ERROR) {
|
|
53
|
+
let err;
|
|
54
|
+
try {
|
|
55
|
+
err = deserializeError(JSON.parse(textDecoder.decode(payload)));
|
|
56
|
+
} catch {
|
|
57
|
+
err = new Error(textDecoder.decode(payload));
|
|
58
|
+
}
|
|
59
|
+
peerEnded = true;
|
|
60
|
+
incoming.done(err);
|
|
61
|
+
maybeClose();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const onClose = () => {
|
|
65
|
+
if (!peerEnded) {
|
|
66
|
+
incoming.done(new TransportClosedError());
|
|
67
|
+
peerEnded = true;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
dc.addEventListener("message", onMessage);
|
|
71
|
+
dc.addEventListener("close", onClose);
|
|
72
|
+
const outbound = (async () => {
|
|
73
|
+
try {
|
|
74
|
+
for await (const chunk of toChunks(16383)(input)) {
|
|
75
|
+
if (dc.readyState !== "open") return;
|
|
76
|
+
const framed = new Uint8Array(chunk.byteLength + 1);
|
|
77
|
+
framed[0] = TYPE_DATA;
|
|
78
|
+
framed.set(chunk, 1);
|
|
79
|
+
dc.send(framed);
|
|
80
|
+
}
|
|
81
|
+
if (dc.readyState === "open") {
|
|
82
|
+
const end = new Uint8Array([TYPE_END]);
|
|
83
|
+
dc.send(end);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (dc.readyState === "open") {
|
|
87
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
88
|
+
const payload = textEncoder.encode(JSON.stringify(serializeError(e)));
|
|
89
|
+
const framed = new Uint8Array(payload.byteLength + 1);
|
|
90
|
+
framed[0] = TYPE_ERROR;
|
|
91
|
+
framed.set(payload, 1);
|
|
92
|
+
try {
|
|
93
|
+
dc.send(framed);
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
localEnded = true;
|
|
98
|
+
maybeClose();
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
try {
|
|
102
|
+
for await (const chunk of incoming.iterate()) yield chunk;
|
|
103
|
+
} finally {
|
|
104
|
+
dc.removeEventListener("message", onMessage);
|
|
105
|
+
dc.removeEventListener("close", onClose);
|
|
106
|
+
await outbound.catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function makeInboundQueue() {
|
|
110
|
+
const slots = [];
|
|
111
|
+
let wake = null;
|
|
112
|
+
let closed = false;
|
|
113
|
+
return {
|
|
114
|
+
push(chunk) {
|
|
115
|
+
if (closed) return;
|
|
116
|
+
slots.push({
|
|
117
|
+
type: "value",
|
|
118
|
+
value: chunk
|
|
119
|
+
});
|
|
120
|
+
wake?.();
|
|
121
|
+
},
|
|
122
|
+
done(err) {
|
|
123
|
+
if (closed) return;
|
|
124
|
+
slots.push({
|
|
125
|
+
type: "done",
|
|
126
|
+
err
|
|
127
|
+
});
|
|
128
|
+
wake?.();
|
|
129
|
+
},
|
|
130
|
+
iterate() {
|
|
131
|
+
return (async function* () {
|
|
132
|
+
try {
|
|
133
|
+
while (true) {
|
|
134
|
+
if (slots.length === 0) {
|
|
135
|
+
await new Promise((r) => {
|
|
136
|
+
wake = r;
|
|
137
|
+
});
|
|
138
|
+
wake = null;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const slot = slots.shift();
|
|
142
|
+
if (slot.type === "done") {
|
|
143
|
+
if (slot.err) throw slot.err;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
yield slot.value;
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
closed = true;
|
|
150
|
+
slots.length = 0;
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/connect-serve.ts
|
|
158
|
+
/**
|
|
159
|
+
* Caller-side: each `call(input)` invocation opens a new `RTCDataChannel` on
|
|
160
|
+
* `pc` and runs the call over it. The DataChannel's `label` is generated
|
|
161
|
+
* locally and the responder picks the channel up via `pc.ondatachannel`.
|
|
162
|
+
*/
|
|
163
|
+
const connect = async ({ pc }) => {
|
|
164
|
+
let counter = 0;
|
|
165
|
+
let closed = false;
|
|
166
|
+
const open = /* @__PURE__ */ new Set();
|
|
167
|
+
const onConnectionClose = () => {
|
|
168
|
+
closed = true;
|
|
169
|
+
};
|
|
170
|
+
pc.addEventListener("connectionstatechange", () => {
|
|
171
|
+
if (pc.connectionState === "closed" || pc.connectionState === "failed") onConnectionClose();
|
|
172
|
+
});
|
|
173
|
+
const call = (input) => {
|
|
174
|
+
if (closed) return (async function* () {
|
|
175
|
+
throw new Error("webrun-streams-webrtc: connection closed");
|
|
176
|
+
})();
|
|
177
|
+
const label = `webrun/${Date.now().toString(36)}/${(counter++).toString(36)}`;
|
|
178
|
+
const dc = pc.createDataChannel(label);
|
|
179
|
+
open.add(dc);
|
|
180
|
+
dc.addEventListener("close", () => open.delete(dc));
|
|
181
|
+
return (async function* () {
|
|
182
|
+
await waitForOpen(dc);
|
|
183
|
+
yield* duplexOverDataChannel(dc, toAsyncIterable(input));
|
|
184
|
+
})();
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
call,
|
|
188
|
+
async close() {
|
|
189
|
+
closed = true;
|
|
190
|
+
for (const dc of open) try {
|
|
191
|
+
dc.close();
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Server-side: registers `pc.ondatachannel` and routes each inbound channel
|
|
198
|
+
* to `handler`. Returns a teardown that stops accepting new channels;
|
|
199
|
+
* channels already in flight continue until their `Duplex` completes.
|
|
200
|
+
*/
|
|
201
|
+
const serve = async ({ pc }, handler) => {
|
|
202
|
+
const onChannel = (ev) => {
|
|
203
|
+
const dc = ev.channel;
|
|
204
|
+
(async () => {
|
|
205
|
+
await waitForOpen(dc);
|
|
206
|
+
const inputFromPeer = peekInput(dc);
|
|
207
|
+
const out = handler(inputFromPeer.input);
|
|
208
|
+
for await (const chunk of duplexOverDataChannel(dc, out)) inputFromPeer.deliver(chunk);
|
|
209
|
+
inputFromPeer.done();
|
|
210
|
+
})();
|
|
211
|
+
};
|
|
212
|
+
pc.addEventListener("datachannel", onChannel);
|
|
213
|
+
let torn = false;
|
|
214
|
+
return async () => {
|
|
215
|
+
if (torn) return;
|
|
216
|
+
torn = true;
|
|
217
|
+
pc.removeEventListener("datachannel", onChannel);
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
function waitForOpen(dc) {
|
|
221
|
+
if (dc.readyState === "open") return Promise.resolve();
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const cleanup = () => {
|
|
224
|
+
dc.removeEventListener("open", onOpen);
|
|
225
|
+
dc.removeEventListener("close", onClose);
|
|
226
|
+
dc.removeEventListener("error", onError);
|
|
227
|
+
};
|
|
228
|
+
const onOpen = () => {
|
|
229
|
+
cleanup();
|
|
230
|
+
resolve();
|
|
231
|
+
};
|
|
232
|
+
const onClose = () => {
|
|
233
|
+
cleanup();
|
|
234
|
+
reject(/* @__PURE__ */ new Error("DataChannel closed before open"));
|
|
235
|
+
};
|
|
236
|
+
const onError = (ev) => {
|
|
237
|
+
cleanup();
|
|
238
|
+
reject(/* @__PURE__ */ new Error(`DataChannel error: ${ev.error?.message ?? "unknown"}`));
|
|
239
|
+
};
|
|
240
|
+
dc.addEventListener("open", onOpen);
|
|
241
|
+
dc.addEventListener("close", onClose);
|
|
242
|
+
dc.addEventListener("error", onError);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function toAsyncIterable(input) {
|
|
246
|
+
if (input[Symbol.asyncIterator]) return input;
|
|
247
|
+
const it = input[Symbol.iterator]();
|
|
248
|
+
return { [Symbol.asyncIterator]() {
|
|
249
|
+
return { next: () => Promise.resolve(it.next()) };
|
|
250
|
+
} };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* For the server side, the handler needs an `input` async iterable backed by
|
|
254
|
+
* the peer's bytes — which arrive via `duplexOverDataChannel`'s yields on this
|
|
255
|
+
* very `dc`. This helper builds a queue/iterator pair so the handler reads
|
|
256
|
+
* from `input` while we drive `duplexOverDataChannel` and tee chunks into it.
|
|
257
|
+
*
|
|
258
|
+
* The arrangement: `duplexOverDataChannel(dc, handlerOutput)` returns the
|
|
259
|
+
* inbound stream as its own AsyncGenerator. We `deliver()` each chunk into
|
|
260
|
+
* the handler's input queue.
|
|
261
|
+
*/
|
|
262
|
+
function peekInput(_dc) {
|
|
263
|
+
const slots = [];
|
|
264
|
+
let wake = null;
|
|
265
|
+
let closed = false;
|
|
266
|
+
return {
|
|
267
|
+
input: (async function* () {
|
|
268
|
+
try {
|
|
269
|
+
while (true) {
|
|
270
|
+
if (slots.length === 0) {
|
|
271
|
+
await new Promise((r) => {
|
|
272
|
+
wake = r;
|
|
273
|
+
});
|
|
274
|
+
wake = null;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const slot = slots.shift();
|
|
278
|
+
if (!slot) continue;
|
|
279
|
+
if (slot.type === "done") return;
|
|
280
|
+
yield slot.value;
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
closed = true;
|
|
284
|
+
}
|
|
285
|
+
})(),
|
|
286
|
+
deliver(chunk) {
|
|
287
|
+
if (closed) return;
|
|
288
|
+
slots.push({
|
|
289
|
+
type: "value",
|
|
290
|
+
value: chunk
|
|
291
|
+
});
|
|
292
|
+
wake?.();
|
|
293
|
+
},
|
|
294
|
+
done() {
|
|
295
|
+
if (closed) return;
|
|
296
|
+
slots.push({ type: "done" });
|
|
297
|
+
wake?.();
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
302
|
+
export { connect, duplexOverDataChannel, serve };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/webrun-streams-webrtc",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "WebRTC DataChannel-backed Connect/Serve adapter (native multi-stream) in the webrun-streams-* family",
|
|
7
|
+
"homepage": "https://github.com/statewalker/webrun-wire",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mikhail Kotelnikov",
|
|
10
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git@github.com:statewalker/webrun-wire.git"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@statewalker/webrun-streams": "0.1.1"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@roamhq/wrtc": "^0.8.0",
|
|
29
|
+
"@types/node": "^26.2.0",
|
|
30
|
+
"rimraf": "^6.1.3",
|
|
31
|
+
"rolldown": "^1.2.4",
|
|
32
|
+
"typescript": "^7.0.2",
|
|
33
|
+
"vitest": "^4.1.10",
|
|
34
|
+
"@statewalker/webrun-streams-conformance": "0.1.1"
|
|
35
|
+
},
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:browser": "vitest run --config vitest.browser.config.ts",
|
|
44
|
+
"lint": "biome check src tests"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Connect, Duplex, Serve } from "@statewalker/webrun-streams";
|
|
2
|
+
import { duplexOverDataChannel } from "./duplex-over-data-channel.js";
|
|
3
|
+
|
|
4
|
+
export interface WebRtcParams {
|
|
5
|
+
/** Open `RTCPeerConnection`. Signalling, auth, and connection setup are the caller's responsibility. */
|
|
6
|
+
pc: RTCPeerConnection;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Caller-side: each `call(input)` invocation opens a new `RTCDataChannel` on
|
|
11
|
+
* `pc` and runs the call over it. The DataChannel's `label` is generated
|
|
12
|
+
* locally and the responder picks the channel up via `pc.ondatachannel`.
|
|
13
|
+
*/
|
|
14
|
+
export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
15
|
+
let counter = 0;
|
|
16
|
+
let closed = false;
|
|
17
|
+
const open = new Set<RTCDataChannel>();
|
|
18
|
+
const onConnectionClose = (): void => {
|
|
19
|
+
closed = true;
|
|
20
|
+
};
|
|
21
|
+
pc.addEventListener("connectionstatechange", () => {
|
|
22
|
+
if (pc.connectionState === "closed" || pc.connectionState === "failed") onConnectionClose();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const call: Duplex = (input) => {
|
|
26
|
+
if (closed) {
|
|
27
|
+
return (async function* () {
|
|
28
|
+
if ((0 as number) === 0) throw new Error("webrun-streams-webrtc: connection closed");
|
|
29
|
+
yield new Uint8Array(0);
|
|
30
|
+
})();
|
|
31
|
+
}
|
|
32
|
+
const label = `webrun/${Date.now().toString(36)}/${(counter++).toString(36)}`;
|
|
33
|
+
const dc = pc.createDataChannel(label);
|
|
34
|
+
open.add(dc);
|
|
35
|
+
dc.addEventListener("close", () => open.delete(dc));
|
|
36
|
+
return (async function* () {
|
|
37
|
+
await waitForOpen(dc);
|
|
38
|
+
yield* duplexOverDataChannel(dc, toAsyncIterable(input));
|
|
39
|
+
})();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
call,
|
|
44
|
+
async close() {
|
|
45
|
+
closed = true;
|
|
46
|
+
for (const dc of open) {
|
|
47
|
+
try {
|
|
48
|
+
dc.close();
|
|
49
|
+
} catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Server-side: registers `pc.ondatachannel` and routes each inbound channel
|
|
59
|
+
* to `handler`. Returns a teardown that stops accepting new channels;
|
|
60
|
+
* channels already in flight continue until their `Duplex` completes.
|
|
61
|
+
*/
|
|
62
|
+
export const serve: Serve<WebRtcParams> = async ({ pc }, handler: Duplex) => {
|
|
63
|
+
const onChannel = (ev: RTCDataChannelEvent): void => {
|
|
64
|
+
const dc = ev.channel;
|
|
65
|
+
void (async () => {
|
|
66
|
+
await waitForOpen(dc);
|
|
67
|
+
const inputFromPeer = peekInput(dc);
|
|
68
|
+
const out = handler(inputFromPeer.input);
|
|
69
|
+
for await (const chunk of duplexOverDataChannel(dc, out)) {
|
|
70
|
+
inputFromPeer.deliver(chunk);
|
|
71
|
+
}
|
|
72
|
+
inputFromPeer.done();
|
|
73
|
+
})();
|
|
74
|
+
};
|
|
75
|
+
pc.addEventListener("datachannel", onChannel);
|
|
76
|
+
let torn = false;
|
|
77
|
+
return async () => {
|
|
78
|
+
if (torn) return;
|
|
79
|
+
torn = true;
|
|
80
|
+
pc.removeEventListener("datachannel", onChannel);
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function waitForOpen(dc: RTCDataChannel): Promise<void> {
|
|
85
|
+
if (dc.readyState === "open") return Promise.resolve();
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const cleanup = (): void => {
|
|
88
|
+
dc.removeEventListener("open", onOpen);
|
|
89
|
+
dc.removeEventListener("close", onClose);
|
|
90
|
+
dc.removeEventListener("error", onError);
|
|
91
|
+
};
|
|
92
|
+
const onOpen = (): void => {
|
|
93
|
+
cleanup();
|
|
94
|
+
resolve();
|
|
95
|
+
};
|
|
96
|
+
const onClose = (): void => {
|
|
97
|
+
cleanup();
|
|
98
|
+
reject(new Error("DataChannel closed before open"));
|
|
99
|
+
};
|
|
100
|
+
const onError = (ev: Event): void => {
|
|
101
|
+
cleanup();
|
|
102
|
+
reject(
|
|
103
|
+
new Error(
|
|
104
|
+
`DataChannel error: ${(ev as unknown as { error?: { message?: string } }).error?.message ?? "unknown"}`,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
dc.addEventListener("open", onOpen);
|
|
109
|
+
dc.addEventListener("close", onClose);
|
|
110
|
+
dc.addEventListener("error", onError);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toAsyncIterable(
|
|
115
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
116
|
+
): AsyncIterable<Uint8Array> {
|
|
117
|
+
if ((input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]) {
|
|
118
|
+
return input as AsyncIterable<Uint8Array>;
|
|
119
|
+
}
|
|
120
|
+
const it = (input as Iterable<Uint8Array>)[Symbol.iterator]();
|
|
121
|
+
return {
|
|
122
|
+
[Symbol.asyncIterator]() {
|
|
123
|
+
return {
|
|
124
|
+
next: () => Promise.resolve(it.next()),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface PeekInput {
|
|
131
|
+
input: AsyncGenerator<Uint8Array>;
|
|
132
|
+
deliver(chunk: Uint8Array): void;
|
|
133
|
+
done(): void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* For the server side, the handler needs an `input` async iterable backed by
|
|
138
|
+
* the peer's bytes — which arrive via `duplexOverDataChannel`'s yields on this
|
|
139
|
+
* very `dc`. This helper builds a queue/iterator pair so the handler reads
|
|
140
|
+
* from `input` while we drive `duplexOverDataChannel` and tee chunks into it.
|
|
141
|
+
*
|
|
142
|
+
* The arrangement: `duplexOverDataChannel(dc, handlerOutput)` returns the
|
|
143
|
+
* inbound stream as its own AsyncGenerator. We `deliver()` each chunk into
|
|
144
|
+
* the handler's input queue.
|
|
145
|
+
*/
|
|
146
|
+
function peekInput(_dc: RTCDataChannel): PeekInput {
|
|
147
|
+
const slots: Array<{ type: "value"; value: Uint8Array } | { type: "done" }> = [];
|
|
148
|
+
let wake: (() => void) | null = null;
|
|
149
|
+
let closed = false;
|
|
150
|
+
const input = (async function* () {
|
|
151
|
+
try {
|
|
152
|
+
while (true) {
|
|
153
|
+
if (slots.length === 0) {
|
|
154
|
+
await new Promise<void>((r) => {
|
|
155
|
+
wake = r;
|
|
156
|
+
});
|
|
157
|
+
wake = null;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const slot = slots.shift();
|
|
161
|
+
if (!slot) continue;
|
|
162
|
+
if (slot.type === "done") return;
|
|
163
|
+
yield slot.value;
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
closed = true;
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
return {
|
|
170
|
+
input,
|
|
171
|
+
deliver(chunk: Uint8Array): void {
|
|
172
|
+
if (closed) return;
|
|
173
|
+
slots.push({ type: "value", value: chunk });
|
|
174
|
+
wake?.();
|
|
175
|
+
},
|
|
176
|
+
done(): void {
|
|
177
|
+
if (closed) return;
|
|
178
|
+
slots.push({ type: "done" });
|
|
179
|
+
wake?.();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deserializeError,
|
|
3
|
+
serializeError,
|
|
4
|
+
TransportClosedError,
|
|
5
|
+
toChunks,
|
|
6
|
+
} from "@statewalker/webrun-streams";
|
|
7
|
+
|
|
8
|
+
const TYPE_DATA = 0x00;
|
|
9
|
+
const TYPE_END = 0x01;
|
|
10
|
+
const TYPE_ERROR = 0x02;
|
|
11
|
+
|
|
12
|
+
const DC_MTU = 16 * 1024; // conservative across browsers
|
|
13
|
+
const FRAME_OVERHEAD = 1; // 1-byte type tag
|
|
14
|
+
|
|
15
|
+
const textEncoder = new TextEncoder();
|
|
16
|
+
const textDecoder = new TextDecoder();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drive one `Duplex` over one `RTCDataChannel`. The DataChannel hosts a tiny
|
|
20
|
+
* in-band framing protocol:
|
|
21
|
+
*
|
|
22
|
+
* [1-byte type][payload bytes...]
|
|
23
|
+
*
|
|
24
|
+
* Types are `DATA` (0x00, body bytes), `END` (0x01, signals half-close — the
|
|
25
|
+
* sender is done emitting; the channel stays open for the other direction),
|
|
26
|
+
* and `ERROR` (0x02, followed by a serialised `Error`).
|
|
27
|
+
*
|
|
28
|
+
* `RTCDataChannel` has no native half-close, so `END` is emitted by the
|
|
29
|
+
* sender once its input iterator exhausts; the channel is only physically
|
|
30
|
+
* closed once both sides have ended.
|
|
31
|
+
*/
|
|
32
|
+
export function duplexOverDataChannel(
|
|
33
|
+
dc: RTCDataChannel,
|
|
34
|
+
input: AsyncIterable<Uint8Array>,
|
|
35
|
+
): AsyncGenerator<Uint8Array> {
|
|
36
|
+
return runStream(dc, input);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function* runStream(
|
|
40
|
+
dc: RTCDataChannel,
|
|
41
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
42
|
+
): AsyncGenerator<Uint8Array> {
|
|
43
|
+
dc.binaryType = "arraybuffer";
|
|
44
|
+
|
|
45
|
+
const incoming = makeInboundQueue();
|
|
46
|
+
let peerEnded = false;
|
|
47
|
+
let localEnded = false;
|
|
48
|
+
|
|
49
|
+
const maybeClose = (): void => {
|
|
50
|
+
if (peerEnded && localEnded) {
|
|
51
|
+
try {
|
|
52
|
+
dc.close();
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const onMessage = (event: MessageEvent): void => {
|
|
60
|
+
const data = event.data as unknown;
|
|
61
|
+
let bytes: Uint8Array;
|
|
62
|
+
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
|
63
|
+
else if (data instanceof Uint8Array) bytes = data;
|
|
64
|
+
else if (ArrayBuffer.isView(data)) {
|
|
65
|
+
const view = data as ArrayBufferView;
|
|
66
|
+
bytes = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
|
|
67
|
+
} else return;
|
|
68
|
+
if (bytes.byteLength === 0) return;
|
|
69
|
+
const type = bytes[0];
|
|
70
|
+
const payload = bytes.subarray(1);
|
|
71
|
+
if (type === TYPE_DATA) {
|
|
72
|
+
incoming.push(new Uint8Array(payload));
|
|
73
|
+
} else if (type === TYPE_END) {
|
|
74
|
+
peerEnded = true;
|
|
75
|
+
incoming.done();
|
|
76
|
+
maybeClose();
|
|
77
|
+
} else if (type === TYPE_ERROR) {
|
|
78
|
+
let err: Error;
|
|
79
|
+
try {
|
|
80
|
+
err = deserializeError(JSON.parse(textDecoder.decode(payload)));
|
|
81
|
+
} catch {
|
|
82
|
+
err = new Error(textDecoder.decode(payload));
|
|
83
|
+
}
|
|
84
|
+
peerEnded = true;
|
|
85
|
+
incoming.done(err);
|
|
86
|
+
maybeClose();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const onClose = (): void => {
|
|
91
|
+
if (!peerEnded) {
|
|
92
|
+
incoming.done(new TransportClosedError());
|
|
93
|
+
peerEnded = true;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
dc.addEventListener("message", onMessage);
|
|
98
|
+
dc.addEventListener("close", onClose);
|
|
99
|
+
|
|
100
|
+
// Outbound pump runs in parallel with inbound iteration.
|
|
101
|
+
const outbound = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
for await (const chunk of toChunks(DC_MTU - FRAME_OVERHEAD)(input)) {
|
|
104
|
+
if (dc.readyState !== "open") return;
|
|
105
|
+
const framed = new Uint8Array(chunk.byteLength + 1);
|
|
106
|
+
framed[0] = TYPE_DATA;
|
|
107
|
+
framed.set(chunk, 1);
|
|
108
|
+
(dc as unknown as { send: (data: Uint8Array) => void }).send(framed);
|
|
109
|
+
}
|
|
110
|
+
if (dc.readyState === "open") {
|
|
111
|
+
const end = new Uint8Array([TYPE_END]);
|
|
112
|
+
(dc as unknown as { send: (data: Uint8Array) => void }).send(end);
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (dc.readyState === "open") {
|
|
116
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
117
|
+
const payload = textEncoder.encode(JSON.stringify(serializeError(e)));
|
|
118
|
+
const framed = new Uint8Array(payload.byteLength + 1);
|
|
119
|
+
framed[0] = TYPE_ERROR;
|
|
120
|
+
framed.set(payload, 1);
|
|
121
|
+
try {
|
|
122
|
+
(dc as unknown as { send: (data: Uint8Array) => void }).send(framed);
|
|
123
|
+
} catch {
|
|
124
|
+
/* ignore */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
localEnded = true;
|
|
129
|
+
maybeClose();
|
|
130
|
+
}
|
|
131
|
+
})();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
for await (const chunk of incoming.iterate()) {
|
|
135
|
+
yield chunk;
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
dc.removeEventListener("message", onMessage);
|
|
139
|
+
dc.removeEventListener("close", onClose);
|
|
140
|
+
// Wait for outbound to settle so close() doesn't truncate in-flight sends.
|
|
141
|
+
await outbound.catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface InboundQueue {
|
|
146
|
+
push(chunk: Uint8Array): void;
|
|
147
|
+
done(err?: Error): void;
|
|
148
|
+
iterate(): AsyncGenerator<Uint8Array>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function makeInboundQueue(): InboundQueue {
|
|
152
|
+
type Slot = { type: "value"; value: Uint8Array } | { type: "done"; err?: Error };
|
|
153
|
+
const slots: Slot[] = [];
|
|
154
|
+
let wake: (() => void) | null = null;
|
|
155
|
+
let closed = false;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
push(chunk: Uint8Array): void {
|
|
159
|
+
if (closed) return;
|
|
160
|
+
slots.push({ type: "value", value: chunk });
|
|
161
|
+
wake?.();
|
|
162
|
+
},
|
|
163
|
+
done(err?: Error): void {
|
|
164
|
+
if (closed) return;
|
|
165
|
+
slots.push({ type: "done", err });
|
|
166
|
+
wake?.();
|
|
167
|
+
},
|
|
168
|
+
iterate(): AsyncGenerator<Uint8Array> {
|
|
169
|
+
return (async function* () {
|
|
170
|
+
try {
|
|
171
|
+
while (true) {
|
|
172
|
+
if (slots.length === 0) {
|
|
173
|
+
await new Promise<void>((r) => {
|
|
174
|
+
wake = r;
|
|
175
|
+
});
|
|
176
|
+
wake = null;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const slot = slots.shift() as Slot;
|
|
180
|
+
if (slot.type === "done") {
|
|
181
|
+
if (slot.err) throw slot.err;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
yield slot.value;
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
closed = true;
|
|
188
|
+
slots.length = 0;
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
package/src/index.ts
ADDED