@statewalker/webrun-streams-webrtc 0.1.4 → 0.1.5

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.
Files changed (2) hide show
  1. package/README.md +135 -98
  2. package/package.json +9 -5
package/README.md CHANGED
@@ -1,164 +1,201 @@
1
1
  # @statewalker/webrun-streams-webrtc
2
2
 
3
- WebRTC `Connect` / `Serve` adapter in the `webrun-streams-*` family, with
4
- **native multi-stream** — one `RTCDataChannel` per logical call.
3
+ ## What it is
5
4
 
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.
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
- `RTCDataChannel` is a good byte pipe with two gaps that every user hits:
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; 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
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
- 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.
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
- ## Install
26
+ ## How to use
43
27
 
44
28
  ```sh
45
- npm install @statewalker/webrun-streams-webrtc
29
+ pnpm add @statewalker/webrun-streams-webrtc
46
30
  ```
47
31
 
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).
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
- ## Getting started
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
- **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.
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
- Responder side — register a handler:
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 side — open calls over the same peer connection:
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
- for await (const chunk of call([new TextEncoder().encode("ping")])) {
77
- console.log(new TextDecoder().decode(chunk)); // "ping"
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
- ### Concurrent calls
84
+ ### HTTP between two browsers
84
85
 
85
- Every `call(...)` gets its own data channel, so calls are genuinely parallel:
86
+ `@statewalker/webrun-http-streams` carries `Request` / `Response`, including
87
+ streaming bodies, over any `Duplex`:
86
88
 
87
89
  ```ts
88
- import { collectBytes } from "@statewalker/webrun-streams";
90
+ import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
91
+ import { connect } from "@statewalker/webrun-streams-webrtc";
89
92
 
90
- const [a, b] = await Promise.all([
91
- collectBytes(call(requestA)),
92
- collectBytes(call(requestB)),
93
- ]);
94
- ```
93
+ declare const pc: RTCPeerConnection;
95
94
 
96
- ### Carrying HTTP over it
95
+ const { call } = await connect({ pc });
96
+ const response = await fetchOverDuplex(call, new Request("http://peer/api/events"));
97
+ ```
97
98
 
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:
99
+ ### One call over a channel you created
101
100
 
102
101
  ```ts
103
- import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
102
+ import { duplexOverDataChannel } from "@statewalker/webrun-streams-webrtc";
104
103
 
105
- const response = await fetchOverDuplex(call, new Request("http://peer/api/events"));
106
- ```
104
+ declare const dc: RTCDataChannel; // open, with a peer running duplexOverDataChannel too
107
105
 
108
- See [`apps/p2p-demo`](../../apps/p2p-demo) for this running end to end.
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
- ## API
114
+ ## Internals
111
115
 
112
- ### `connect(params): Promise<{ call, close }>`
116
+ ### Frames inside a data channel
113
117
 
114
- Type: `Connect<WebRtcParams>`.
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
- | Field | Type | Meaning |
117
- | --- | --- | --- |
118
- | `pc` | `RTCPeerConnection` | An already-open peer connection. |
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
- Each `call(input)` opens a fresh `RTCDataChannel`. `close()` tears down the
121
- adapter's channels; it does not close `pc`, which you own.
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
- ### `serve(params, handler): Promise<() => Promise<void>>`
142
+ ### How cancellation reaches the handler
124
143
 
125
- Type: `Serve<WebRtcParams>`. Listens on `pc.ondatachannel` and runs `handler`
126
- for each inbound channel. Returns an idempotent teardown.
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
- ### `duplexOverDataChannel(channel, options?): Duplex`
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
- 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.
156
+ ### What the peer connection must already have
133
157
 
134
- ### `WebRtcParams`
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
- The parameter type shared by `connect` and `serve`.
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
- ## Conformance
168
+ ### Failure modes
139
169
 
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.
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
- 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.
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
- ```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
- ```
181
+ ### Constraints
152
182
 
153
- ## Dependencies
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
- | 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. |
192
+ ### Dependencies
159
193
 
160
- No runtime dependencies outside the workspace. ESM only (`"type": "module"`).
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 © statewalker — see [LICENSE](../../LICENSE).
201
+ MIT
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-streams-webrtc",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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
- "homepage": "https://github.com/statewalker/webrun-wire",
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@github.com:statewalker/webrun-wire.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.1"
33
+ "@statewalker/webrun-streams": "^0.2.2"
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.1"
45
+ "@statewalker/webrun-streams-conformance": "^0.3.2"
42
46
  },
43
47
  "sideEffects": false,
44
48
  "publishConfig": {