@statewalker/webrun-streams-webrtc 0.1.4 → 0.1.6
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 +135 -98
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -1,164 +1,201 @@
|
|
|
1
1
|
# @statewalker/webrun-streams-webrtc
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
**native multi-stream** — one `RTCDataChannel` per logical call.
|
|
3
|
+
## What it is
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
5
|
+
A WebRTC adapter for the `Duplex` seam from `@statewalker/webrun-streams`. It
|
|
6
|
+
takes an already-connected `RTCPeerConnection`. `connect` returns a `call`
|
|
7
|
+
function that opens one new `RTCDataChannel` per call; `serve` runs a handler
|
|
8
|
+
for every data channel the peer opens. The handler is an ordinary `Duplex`
|
|
9
|
+
(`(input) => AsyncGenerator<Uint8Array>`), and the bytes travel directly
|
|
10
|
+
between the two peers with no server in the data path.
|
|
20
11
|
|
|
21
12
|
## Why it exists
|
|
22
13
|
|
|
23
|
-
|
|
14
|
+
WebRTC can open as many data channels as needed, so concurrency comes from the
|
|
15
|
+
transport and this adapter does not use `emulateMux`. What an
|
|
16
|
+
`RTCDataChannel` lacks is what request/response needs:
|
|
24
17
|
|
|
25
|
-
1. **No half-close.** A channel is open or closed
|
|
26
|
-
|
|
27
|
-
2. **No error channel.**
|
|
18
|
+
1. **No half-close.** A channel is open or closed. There is no way to say "I
|
|
19
|
+
have finished sending, keep receiving".
|
|
20
|
+
2. **No error channel.** When the far end throws, the near end sees a closed
|
|
28
21
|
channel and no reason.
|
|
29
22
|
|
|
30
|
-
|
|
31
|
-
channel
|
|
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.
|
|
23
|
+
The adapter adds both with a one-byte frame type inside each data channel
|
|
24
|
+
message, and maps cancellation onto closing the channel.
|
|
41
25
|
|
|
42
|
-
##
|
|
26
|
+
## How to use
|
|
43
27
|
|
|
44
28
|
```sh
|
|
45
|
-
|
|
29
|
+
pnpm add @statewalker/webrun-streams-webrtc
|
|
46
30
|
```
|
|
47
31
|
|
|
48
|
-
No peer dependencies.
|
|
49
|
-
|
|
32
|
+
Runtime dependency: `@statewalker/webrun-streams`. No peer dependencies. One
|
|
33
|
+
entry point, `.`. It needs `RTCPeerConnection`, so it runs in browsers; in
|
|
34
|
+
Node it needs a WebRTC implementation that provides the same API.
|
|
50
35
|
|
|
51
|
-
|
|
36
|
+
Signalling, authentication and connection setup are the caller's job. Pass a
|
|
37
|
+
connection that is already `connected` and whose SDP negotiated a data
|
|
38
|
+
channel (SCTP) section; see "What the peer connection must already have"
|
|
39
|
+
below.
|
|
52
40
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
| Export | What it gives |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `connect({ pc })` | `Connect<WebRtcParams>`. Resolves `{ call, close }`. Each `call(input)` opens a new `RTCDataChannel` on `pc`, waits for it to open and runs the call over it. `close()` closes the channels this `connect` opened; it does not close `pc`. |
|
|
44
|
+
| `serve({ pc }, handler)` | `Serve<WebRtcParams>`. Listens for `datachannel` events on `pc` and runs `handler` on each channel. Resolves to an idempotent teardown that stops accepting new channels; calls in flight continue. |
|
|
45
|
+
| `duplexOverDataChannel(dc, input)` | The primitive under both: runs one call over one data channel. Sends `input` framed, and returns an `AsyncGenerator<Uint8Array>` of the peer's bytes. |
|
|
46
|
+
| `WebRtcParams` | `{ pc: RTCPeerConnection }`. |
|
|
58
47
|
|
|
59
|
-
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
### Responder
|
|
60
51
|
|
|
61
52
|
```ts
|
|
62
53
|
import { serve } from "@statewalker/webrun-streams-webrtc";
|
|
63
54
|
|
|
55
|
+
declare const pc: RTCPeerConnection; // connected, negotiated by your signalling
|
|
56
|
+
|
|
64
57
|
const stop = await serve({ pc }, async function* echo(input) {
|
|
65
58
|
for await (const chunk of input) yield chunk;
|
|
66
59
|
});
|
|
60
|
+
// later
|
|
61
|
+
await stop();
|
|
67
62
|
```
|
|
68
63
|
|
|
69
|
-
Caller
|
|
64
|
+
### Caller, with concurrent calls
|
|
70
65
|
|
|
71
66
|
```ts
|
|
67
|
+
import { collectBytes } from "@statewalker/webrun-streams";
|
|
72
68
|
import { connect } from "@statewalker/webrun-streams-webrtc";
|
|
73
69
|
|
|
70
|
+
declare const pc: RTCPeerConnection;
|
|
71
|
+
|
|
74
72
|
const { call, close } = await connect({ pc });
|
|
73
|
+
const enc = new TextEncoder();
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// Each call gets its own data channel, so these run in parallel.
|
|
76
|
+
const [a, b] = await Promise.all([
|
|
77
|
+
collectBytes(call([enc.encode("first")])),
|
|
78
|
+
collectBytes(call([enc.encode("second")])),
|
|
79
|
+
]);
|
|
79
80
|
|
|
80
|
-
await close();
|
|
81
|
+
await close(); // pc stays open; you own it
|
|
81
82
|
```
|
|
82
83
|
|
|
83
|
-
###
|
|
84
|
+
### HTTP between two browsers
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
`@statewalker/webrun-http-streams` carries `Request` / `Response`, including
|
|
87
|
+
streaming bodies, over any `Duplex`:
|
|
86
88
|
|
|
87
89
|
```ts
|
|
88
|
-
import {
|
|
90
|
+
import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
|
|
91
|
+
import { connect } from "@statewalker/webrun-streams-webrtc";
|
|
89
92
|
|
|
90
|
-
const
|
|
91
|
-
collectBytes(call(requestA)),
|
|
92
|
-
collectBytes(call(requestB)),
|
|
93
|
-
]);
|
|
94
|
-
```
|
|
93
|
+
declare const pc: RTCPeerConnection;
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
const { call } = await connect({ pc });
|
|
96
|
+
const response = await fetchOverDuplex(call, new Request("http://peer/api/events"));
|
|
97
|
+
```
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
`Request` / `Response` objects — including streaming bodies and SSE — directly
|
|
100
|
-
between two browsers:
|
|
99
|
+
### One call over a channel you created
|
|
101
100
|
|
|
102
101
|
```ts
|
|
103
|
-
import {
|
|
102
|
+
import { duplexOverDataChannel } from "@statewalker/webrun-streams-webrtc";
|
|
104
103
|
|
|
105
|
-
const
|
|
106
|
-
```
|
|
104
|
+
declare const dc: RTCDataChannel; // open, with a peer running duplexOverDataChannel too
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
async function* request() {
|
|
107
|
+
yield new TextEncoder().encode("ping");
|
|
108
|
+
}
|
|
109
|
+
for await (const chunk of duplexOverDataChannel(dc, request())) {
|
|
110
|
+
console.log(new TextDecoder().decode(chunk));
|
|
111
|
+
}
|
|
112
|
+
```
|
|
109
113
|
|
|
110
|
-
##
|
|
114
|
+
## Internals
|
|
111
115
|
|
|
112
|
-
###
|
|
116
|
+
### Frames inside a data channel
|
|
113
117
|
|
|
114
|
-
|
|
118
|
+
```
|
|
119
|
+
+--------+---------------------------+
|
|
120
|
+
| type | payload |
|
|
121
|
+
| 1 byte | rest of the message |
|
|
122
|
+
+--------+---------------------------+
|
|
123
|
+
|
|
124
|
+
DATA 0x00 body bytes, at most 16 KiB - 1 per message
|
|
125
|
+
END 0x01 this side has finished sending (half-close)
|
|
126
|
+
ERROR 0x02 JSON of serializeError(err): message, stack, custom fields
|
|
127
|
+
```
|
|
115
128
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
|
129
|
+
```
|
|
130
|
+
caller responder
|
|
131
|
+
| createDataChannel("webrun/…") | datachannel event -> handler(input)
|
|
132
|
+
| DATA, DATA, … END ----------> | handler's input ends
|
|
133
|
+
| <------------- DATA, … END | handler's output ends
|
|
134
|
+
| both sides ended: dc.close() |
|
|
135
|
+
```
|
|
119
136
|
|
|
120
|
-
|
|
121
|
-
|
|
137
|
+
Outbound chunks are split with `toChunks` so each message, with its type byte,
|
|
138
|
+
is at most 16 KiB, a size that every browser's SCTP implementation accepts. The
|
|
139
|
+
channel's `binaryType` is set to `"arraybuffer"`; text messages and empty
|
|
140
|
+
messages are ignored. The channel is closed once both directions have ended.
|
|
122
141
|
|
|
123
|
-
###
|
|
142
|
+
### How cancellation reaches the handler
|
|
124
143
|
|
|
125
|
-
|
|
126
|
-
|
|
144
|
+
`END` means "I am done sending", and the peer answers it by carrying on, so it
|
|
145
|
+
cannot express "stop". When the caller leaves early (`.return()` on the call's
|
|
146
|
+
generator, or an error), `duplexOverDataChannel` waits for its own outbound
|
|
147
|
+
pump to settle and then closes the channel. On the responder, the channel's
|
|
148
|
+
`close` event ends the inbound side with `TransportClosedError`, which closes
|
|
149
|
+
the handler's input and runs the handler's `finally`.
|
|
127
150
|
|
|
128
|
-
|
|
151
|
+
On a normal finish the outbound pump is not awaited. The responder feeds the
|
|
152
|
+
handler's input from this generator and closes that input only when the
|
|
153
|
+
generator returns; awaiting the pump there would wait for the handler, which
|
|
154
|
+
waits for its input, which waits for the generator.
|
|
129
155
|
|
|
130
|
-
|
|
131
|
-
implementing the DATA/END/ERROR framing described above. Use it when you manage
|
|
132
|
-
channel creation yourself.
|
|
156
|
+
### What the peer connection must already have
|
|
133
157
|
|
|
134
|
-
|
|
158
|
+
An offer contains a data channel section only if the connection already has a
|
|
159
|
+
data channel when the offer is created. Without that section SCTP is never
|
|
160
|
+
negotiated and every channel `connect` opens stays in `connecting`, so `call`
|
|
161
|
+
never starts. Create a data channel before the first offer.
|
|
135
162
|
|
|
136
|
-
|
|
163
|
+
`serve` treats every channel the peer opens on `pc` as a call, whatever its
|
|
164
|
+
label (`connect` labels its channels `webrun/<time>/<counter>`). Channels the
|
|
165
|
+
application opens for other purposes, including one created only to shape the
|
|
166
|
+
SDP, must be opened before `serve` is registered or on a different connection.
|
|
137
167
|
|
|
138
|
-
|
|
168
|
+
### Failure modes
|
|
139
169
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
| Situation | Error |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `call` after `close()`, or after `pc` became `closed` or `failed` | `webrun-streams-webrtc: connection closed` (a plain `Error`) |
|
|
173
|
+
| Channel closes before it opens | `DataChannel closed before open` |
|
|
174
|
+
| Channel error before it opens | `DataChannel error: <message or "unknown">` |
|
|
175
|
+
| Channel closes before the peer sent `END` | `TransportClosedError` (`transport closed`) |
|
|
176
|
+
| Handler throws | The caller's `for await` throws the deserialised error, with `message`, `stack` and custom fields |
|
|
143
177
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
reports it as skipped.
|
|
178
|
+
On the responder, a channel task that fails (usually a caller that cancelled)
|
|
179
|
+
is caught, so it does not become an unhandled rejection.
|
|
147
180
|
|
|
148
|
-
|
|
149
|
-
pnpm --filter @statewalker/webrun-streams-webrtc test:browser # runs the suite
|
|
150
|
-
pnpm --filter @statewalker/webrun-streams-webrtc test # reports it skipped
|
|
151
|
-
```
|
|
181
|
+
### Constraints
|
|
152
182
|
|
|
153
|
-
|
|
183
|
+
- **No flow control in the adapter.** Outbound messages go to `dc.send` as
|
|
184
|
+
fast as `input` yields; `bufferedAmount` is not checked, and the inbound
|
|
185
|
+
queue is not bounded. Very large or fast bodies rely on the browser's send
|
|
186
|
+
buffer, which closes the channel when it overflows.
|
|
187
|
+
- Conformance runs in Chromium through Playwright: `pnpm run test:browser`.
|
|
188
|
+
Under `pnpm test` (Node) the suite is registered as skipped, because Node
|
|
189
|
+
has no `RTCPeerConnection`. L6's small window does not apply here (there is
|
|
190
|
+
no `emulateMux`), so L6 is an integrity check.
|
|
154
191
|
|
|
155
|
-
|
|
156
|
-
| --- | --- | --- |
|
|
157
|
-
| [`@statewalker/webrun-streams`](../webrun-streams) | runtime | The `Duplex` seam and error serialisation. |
|
|
158
|
-
| `@roamhq/wrtc` | dev | `RTCPeerConnection` for the Node conformance run. |
|
|
192
|
+
### Dependencies
|
|
159
193
|
|
|
160
|
-
|
|
194
|
+
- `@statewalker/webrun-streams` (`workspace:^`): `toChunks`,
|
|
195
|
+
`serializeError` / `deserializeError`, `TransportClosedError` and the seam
|
|
196
|
+
types.
|
|
197
|
+
- No other runtime dependencies and no peer dependencies.
|
|
161
198
|
|
|
162
199
|
## License
|
|
163
200
|
|
|
164
|
-
MIT
|
|
201
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-streams-webrtc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|
|
7
|
-
"
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/statewalker/webrun-wire/issues"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/statewalker/webrun-wire/tree/main/packages/webrun-streams-webrtc#readme",
|
|
8
11
|
"author": {
|
|
9
12
|
"name": "Mikhail Kotelnikov",
|
|
10
13
|
"email": "mikhail.kotelnikov@gmail.com"
|
|
@@ -12,7 +15,8 @@
|
|
|
12
15
|
"license": "MIT",
|
|
13
16
|
"repository": {
|
|
14
17
|
"type": "git",
|
|
15
|
-
"url": "git
|
|
18
|
+
"url": "git+https://github.com/statewalker/webrun-wire.git",
|
|
19
|
+
"directory": "packages/webrun-streams-webrtc"
|
|
16
20
|
},
|
|
17
21
|
"exports": {
|
|
18
22
|
".": {
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
"src"
|
|
27
31
|
],
|
|
28
32
|
"dependencies": {
|
|
29
|
-
"@statewalker/webrun-streams": "^0.2.
|
|
33
|
+
"@statewalker/webrun-streams": "^0.2.3"
|
|
30
34
|
},
|
|
31
35
|
"devDependencies": {
|
|
32
36
|
"@biomejs/biome": "^2.5.15",
|
|
@@ -38,7 +42,7 @@
|
|
|
38
42
|
"rolldown": "^1.2.12",
|
|
39
43
|
"typescript": "^7.0.2",
|
|
40
44
|
"vitest": "^5.0.3",
|
|
41
|
-
"@statewalker/webrun-streams-conformance": "^0.3.
|
|
45
|
+
"@statewalker/webrun-streams-conformance": "^0.3.3"
|
|
42
46
|
},
|
|
43
47
|
"sideEffects": false,
|
|
44
48
|
"publishConfig": {
|