@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 CHANGED
@@ -1,16 +1,164 @@
1
1
  # @statewalker/webrun-streams-webrtc
2
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`.
3
+ WebRTC `Connect` / `Serve` adapter in the `webrun-streams-*` family, with
4
+ **native multi-stream** — one `RTCDataChannel` per logical call.
4
5
 
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
+ ## 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 { connect, serve } from "@statewalker/webrun-streams-webrtc";
103
+ import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
9
104
 
10
- const { call } = await connect({ pc }); // RTCPeerConnection
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,CAoBrC,CAAC"}
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
- await outbound.catch(() => {});
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
- const inputFromPeer = peekInput(dc);
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.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
- ".": "./src/index.ts"
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.1.1"
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.1.1"
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",
@@ -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
- const inputFromPeer = peekInput(dc);
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 close() doesn't truncate in-flight sends.
141
- await outbound.catch(() => {});
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.