@statewalker/webrun-streams-webrtc 0.1.1 → 0.1.2
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 +154 -6
- package/dist/connect-serve.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/package.json +10 -4
- package/src/connect-serve.ts +10 -2
- package/src/duplex-over-data-channel.ts +33 -2
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -1,16 +1,164 @@
|
|
|
1
1
|
# @statewalker/webrun-streams-webrtc
|
|
2
2
|
|
|
3
|
-
WebRTC
|
|
3
|
+
WebRTC `Connect` / `Serve` adapter in the `webrun-streams-*` family, with
|
|
4
|
+
**native multi-stream** — one `RTCDataChannel` per logical call.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
## What this is
|
|
7
|
+
|
|
8
|
+
A binding between an `RTCPeerConnection` and the
|
|
9
|
+
[`webrun-streams`](../webrun-streams) `Duplex` seam. Your handler is an
|
|
10
|
+
ordinary `Duplex` —
|
|
11
|
+
`(input: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array>` — and this
|
|
12
|
+
package carries it directly between two browsers with no server in the data
|
|
13
|
+
path.
|
|
14
|
+
|
|
15
|
+
Unlike the message-oriented adapters (WebSocket, MessagePort, LiveKit, PeerJS),
|
|
16
|
+
this one does **not** use `emulateMux`. WebRTC can open as many data channels as
|
|
17
|
+
you want, so each `call(input)` opens its own `RTCDataChannel` and the responder
|
|
18
|
+
picks it up via `pc.ondatachannel`. Concurrency is the transport's, not
|
|
19
|
+
emulated.
|
|
20
|
+
|
|
21
|
+
## Why it exists
|
|
22
|
+
|
|
23
|
+
`RTCDataChannel` is a good byte pipe with two gaps that every user hits:
|
|
24
|
+
|
|
25
|
+
1. **No half-close.** A channel is open or closed; there is no way to say
|
|
26
|
+
"I'm done sending, keep receiving". Request/response needs exactly that.
|
|
27
|
+
2. **No error channel.** If the far end throws, the near end sees a closed
|
|
28
|
+
channel and no reason.
|
|
29
|
+
|
|
30
|
+
This adapter closes both gaps with a one-byte frame header inside each data
|
|
31
|
+
channel message:
|
|
32
|
+
|
|
33
|
+
| Byte | Frame | Payload |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `0x00` | `DATA` | body bytes |
|
|
36
|
+
| `0x01` | `END` | — (half-close) |
|
|
37
|
+
| `0x02` | `ERROR` | serialised `Error` (message, stack, custom fields) |
|
|
38
|
+
|
|
39
|
+
Outbound bytes are chunked at 16 KiB to stay inside the SCTP message limits
|
|
40
|
+
that browsers enforce.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm install @statewalker/webrun-streams-webrtc
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
No peer dependencies. In the browser `RTCPeerConnection` is built in; in Node
|
|
49
|
+
tests it is supplied by [`@roamhq/wrtc`](https://www.npmjs.com/package/@roamhq/wrtc).
|
|
50
|
+
|
|
51
|
+
## Getting started
|
|
52
|
+
|
|
53
|
+
**Signalling, auth and connection setup are the caller's responsibility.** This
|
|
54
|
+
package takes an already-open `RTCPeerConnection` and does nothing else. If you
|
|
55
|
+
need signalling too, see
|
|
56
|
+
[`@statewalker/webrun-streams-signaling`](../webrun-streams-signaling), which
|
|
57
|
+
produces connected peers for you.
|
|
58
|
+
|
|
59
|
+
Responder side — register a handler:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { serve } from "@statewalker/webrun-streams-webrtc";
|
|
63
|
+
|
|
64
|
+
const stop = await serve({ pc }, async function* echo(input) {
|
|
65
|
+
for await (const chunk of input) yield chunk;
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Caller side — open calls over the same peer connection:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { connect } from "@statewalker/webrun-streams-webrtc";
|
|
73
|
+
|
|
74
|
+
const { call, close } = await connect({ pc });
|
|
75
|
+
|
|
76
|
+
for await (const chunk of call([new TextEncoder().encode("ping")])) {
|
|
77
|
+
console.log(new TextDecoder().decode(chunk)); // "ping"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await close();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Concurrent calls
|
|
84
|
+
|
|
85
|
+
Every `call(...)` gets its own data channel, so calls are genuinely parallel:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { collectBytes } from "@statewalker/webrun-streams";
|
|
89
|
+
|
|
90
|
+
const [a, b] = await Promise.all([
|
|
91
|
+
collectBytes(call(requestA)),
|
|
92
|
+
collectBytes(call(requestB)),
|
|
93
|
+
]);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Carrying HTTP over it
|
|
97
|
+
|
|
98
|
+
Pair with [`webrun-http-streams`](../webrun-http-streams) to exchange real
|
|
99
|
+
`Request` / `Response` objects — including streaming bodies and SSE — directly
|
|
100
|
+
between two browsers:
|
|
6
101
|
|
|
7
102
|
```ts
|
|
8
|
-
import {
|
|
103
|
+
import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
|
|
9
104
|
|
|
10
|
-
const
|
|
11
|
-
const stop = await serve({ pc }, handler);
|
|
105
|
+
const response = await fetchOverDuplex(call, new Request("http://peer/api/events"));
|
|
12
106
|
```
|
|
13
107
|
|
|
108
|
+
See [`apps/p2p-demo`](../../apps/p2p-demo) for this running end to end.
|
|
109
|
+
|
|
110
|
+
## API
|
|
111
|
+
|
|
112
|
+
### `connect(params): Promise<{ call, close }>`
|
|
113
|
+
|
|
114
|
+
Type: `Connect<WebRtcParams>`.
|
|
115
|
+
|
|
116
|
+
| Field | Type | Meaning |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `pc` | `RTCPeerConnection` | An already-open peer connection. |
|
|
119
|
+
|
|
120
|
+
Each `call(input)` opens a fresh `RTCDataChannel`. `close()` tears down the
|
|
121
|
+
adapter's channels; it does not close `pc`, which you own.
|
|
122
|
+
|
|
123
|
+
### `serve(params, handler): Promise<() => Promise<void>>`
|
|
124
|
+
|
|
125
|
+
Type: `Serve<WebRtcParams>`. Listens on `pc.ondatachannel` and runs `handler`
|
|
126
|
+
for each inbound channel. Returns an idempotent teardown.
|
|
127
|
+
|
|
128
|
+
### `duplexOverDataChannel(channel, options?): Duplex`
|
|
129
|
+
|
|
130
|
+
The lower-level primitive: wraps a single `RTCDataChannel` as one `Duplex`,
|
|
131
|
+
implementing the DATA/END/ERROR framing described above. Use it when you manage
|
|
132
|
+
channel creation yourself.
|
|
133
|
+
|
|
134
|
+
### `WebRtcParams`
|
|
135
|
+
|
|
136
|
+
The parameter type shared by `connect` and `serve`.
|
|
137
|
+
|
|
138
|
+
## Conformance
|
|
139
|
+
|
|
140
|
+
Runs [`@statewalker/webrun-streams-conformance`](../webrun-streams-conformance)
|
|
141
|
+
against a real `RTCPeerConnection` pair — concurrency, half-close, mid-stream
|
|
142
|
+
cancellation, error propagation and idempotent teardown.
|
|
143
|
+
|
|
144
|
+
The suite is **browser-gated**: `tests/conformance.test.ts` checks for a
|
|
145
|
+
`window` global and registers the suite only there, so the plain Node run
|
|
146
|
+
reports it as skipped.
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
pnpm --filter @statewalker/webrun-streams-webrtc test:browser # runs the suite
|
|
150
|
+
pnpm --filter @statewalker/webrun-streams-webrtc test # reports it skipped
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Dependencies
|
|
154
|
+
|
|
155
|
+
| Dependency | Kind | Why |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| [`@statewalker/webrun-streams`](../webrun-streams) | runtime | The `Duplex` seam and error serialisation. |
|
|
158
|
+
| `@roamhq/wrtc` | dev | `RTCPeerConnection` for the Node conformance run. |
|
|
159
|
+
|
|
160
|
+
No runtime dependencies outside the workspace. ESM only (`"type": "module"`).
|
|
161
|
+
|
|
14
162
|
## License
|
|
15
163
|
|
|
16
|
-
MIT
|
|
164
|
+
MIT © statewalker — see [LICENSE](../../LICENSE).
|
|
@@ -1 +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,
|
|
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,CA4BrC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -98,12 +98,19 @@ async function* runStream(dc, input) {
|
|
|
98
98
|
maybeClose();
|
|
99
99
|
}
|
|
100
100
|
})();
|
|
101
|
+
let inboundDrained = false;
|
|
101
102
|
try {
|
|
102
103
|
for await (const chunk of incoming.iterate()) yield chunk;
|
|
104
|
+
inboundDrained = true;
|
|
103
105
|
} finally {
|
|
104
106
|
dc.removeEventListener("message", onMessage);
|
|
105
107
|
dc.removeEventListener("close", onClose);
|
|
106
|
-
|
|
108
|
+
if (!inboundDrained) {
|
|
109
|
+
await outbound.catch(() => {});
|
|
110
|
+
try {
|
|
111
|
+
dc.close();
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
107
114
|
}
|
|
108
115
|
}
|
|
109
116
|
function makeInboundQueue() {
|
|
@@ -201,13 +208,16 @@ const connect = async ({ pc }) => {
|
|
|
201
208
|
const serve = async ({ pc }, handler) => {
|
|
202
209
|
const onChannel = (ev) => {
|
|
203
210
|
const dc = ev.channel;
|
|
211
|
+
let inputFromPeer = null;
|
|
204
212
|
(async () => {
|
|
205
213
|
await waitForOpen(dc);
|
|
206
|
-
|
|
214
|
+
inputFromPeer = peekInput(dc);
|
|
207
215
|
const out = handler(inputFromPeer.input);
|
|
208
216
|
for await (const chunk of duplexOverDataChannel(dc, out)) inputFromPeer.deliver(chunk);
|
|
209
217
|
inputFromPeer.done();
|
|
210
|
-
})()
|
|
218
|
+
})().catch(() => {
|
|
219
|
+
inputFromPeer?.done();
|
|
220
|
+
});
|
|
211
221
|
};
|
|
212
222
|
pc.addEventListener("datachannel", onChannel);
|
|
213
223
|
let torn = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-streams-webrtc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "WebRTC DataChannel-backed Connect/Serve adapter (native multi-stream) in the webrun-streams-* family",
|
|
@@ -15,28 +15,34 @@
|
|
|
15
15
|
"url": "git@github.com:statewalker/webrun-wire.git"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
|
-
".":
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
}
|
|
19
22
|
},
|
|
20
23
|
"files": [
|
|
21
24
|
"dist",
|
|
22
25
|
"src"
|
|
23
26
|
],
|
|
24
27
|
"dependencies": {
|
|
25
|
-
"@statewalker/webrun-streams": "0.
|
|
28
|
+
"@statewalker/webrun-streams": "0.2.0"
|
|
26
29
|
},
|
|
27
30
|
"devDependencies": {
|
|
28
31
|
"@roamhq/wrtc": "^0.8.0",
|
|
29
32
|
"@types/node": "^26.2.0",
|
|
33
|
+
"@vitest/browser-playwright": "^4.1.10",
|
|
34
|
+
"playwright": "^1.62.1",
|
|
30
35
|
"rimraf": "^6.1.3",
|
|
31
36
|
"rolldown": "^1.2.4",
|
|
32
37
|
"typescript": "^7.0.2",
|
|
33
38
|
"vitest": "^4.1.10",
|
|
34
|
-
"@statewalker/webrun-streams-conformance": "0.
|
|
39
|
+
"@statewalker/webrun-streams-conformance": "0.2.0"
|
|
35
40
|
},
|
|
36
41
|
"sideEffects": false,
|
|
37
42
|
"publishConfig": {
|
|
38
43
|
"access": "public"
|
|
39
44
|
},
|
|
45
|
+
"types": "./dist/index.d.ts",
|
|
40
46
|
"scripts": {
|
|
41
47
|
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
42
48
|
"test": "vitest run",
|
package/src/connect-serve.ts
CHANGED
|
@@ -62,15 +62,23 @@ export const connect: Connect<WebRtcParams> = async ({ pc }) => {
|
|
|
62
62
|
export const serve: Serve<WebRtcParams> = async ({ pc }, handler: Duplex) => {
|
|
63
63
|
const onChannel = (ev: RTCDataChannelEvent): void => {
|
|
64
64
|
const dc = ev.channel;
|
|
65
|
+
let inputFromPeer: PeekInput | null = null;
|
|
65
66
|
void (async () => {
|
|
66
67
|
await waitForOpen(dc);
|
|
67
|
-
|
|
68
|
+
inputFromPeer = peekInput(dc);
|
|
68
69
|
const out = handler(inputFromPeer.input);
|
|
69
70
|
for await (const chunk of duplexOverDataChannel(dc, out)) {
|
|
70
71
|
inputFromPeer.deliver(chunk);
|
|
71
72
|
}
|
|
72
73
|
inputFromPeer.done();
|
|
73
|
-
})()
|
|
74
|
+
})().catch(() => {
|
|
75
|
+
// A caller that cancels mid-response closes the DataChannel, which
|
|
76
|
+
// surfaces here as TransportClosedError. That is the cancellation
|
|
77
|
+
// working, not a fault: this task is fire-and-forget, so without a
|
|
78
|
+
// catch it becomes an unhandled rejection that fails the page. Closing
|
|
79
|
+
// the handler's input is what runs the handler's own `finally`.
|
|
80
|
+
inputFromPeer?.done();
|
|
81
|
+
});
|
|
74
82
|
};
|
|
75
83
|
pc.addEventListener("datachannel", onChannel);
|
|
76
84
|
let torn = false;
|
|
@@ -130,15 +130,46 @@ async function* runStream(
|
|
|
130
130
|
}
|
|
131
131
|
})();
|
|
132
132
|
|
|
133
|
+
let inboundDrained = false;
|
|
133
134
|
try {
|
|
134
135
|
for await (const chunk of incoming.iterate()) {
|
|
135
136
|
yield chunk;
|
|
136
137
|
}
|
|
138
|
+
inboundDrained = true;
|
|
137
139
|
} finally {
|
|
138
140
|
dc.removeEventListener("message", onMessage);
|
|
139
141
|
dc.removeEventListener("close", onClose);
|
|
140
|
-
// Wait for outbound to settle so
|
|
141
|
-
|
|
142
|
+
// Wait for outbound to settle so an early exit doesn't truncate in-flight
|
|
143
|
+
// sends — but ONLY on an early exit.
|
|
144
|
+
//
|
|
145
|
+
// Awaiting it unconditionally deadlocks a responder. `serve` feeds the
|
|
146
|
+
// handler from the values this generator yields and closes the handler's
|
|
147
|
+
// input only once this generator completes. So on a normal finish the
|
|
148
|
+
// cycle is: inbound ends -> we await outbound -> outbound is draining the
|
|
149
|
+
// handler's output -> the handler is blocked on its input -> its input is
|
|
150
|
+
// closed only after we return. Nothing moves, and every test that runs a
|
|
151
|
+
// request to completion hangs.
|
|
152
|
+
//
|
|
153
|
+
// When inbound drained on its own the peer has already said it is done, so
|
|
154
|
+
// there is nothing of theirs left to truncate; outbound keeps running on
|
|
155
|
+
// its own and `maybeClose` still closes the channel once both halves end.
|
|
156
|
+
if (!inboundDrained) {
|
|
157
|
+
await outbound.catch(() => {});
|
|
158
|
+
// The consumer walked away mid-response (`.return()` on the generator),
|
|
159
|
+
// or the peer errored. Either way this call is over while the peer may
|
|
160
|
+
// still be producing, and a DataChannel has no half-close to say so —
|
|
161
|
+
// `TYPE_END` means "I am done sending", which the outbound pump has
|
|
162
|
+
// already said and which the peer answers by carrying on. Closing the
|
|
163
|
+
// channel is the only cancellation signal available: the responder's
|
|
164
|
+
// `onClose` then ends its inbound queue, which closes the handler's
|
|
165
|
+
// input and runs the handler's `finally`. Without this, cancellation is
|
|
166
|
+
// silently local and the handler leaks until the connection drops.
|
|
167
|
+
try {
|
|
168
|
+
dc.close();
|
|
169
|
+
} catch {
|
|
170
|
+
/* already closing */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
142
173
|
}
|
|
143
174
|
}
|
|
144
175
|
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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.
|