@statewalker/webrun-streams-webrtc 0.1.6 → 0.4.0
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 +93 -15
- package/dist/connect-serve.d.ts.map +1 -1
- package/dist/duplex-over-data-channel.d.ts +36 -1
- package/dist/duplex-over-data-channel.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +428 -49
- package/dist/port-mux.d.ts +74 -0
- package/dist/port-mux.d.ts.map +1 -0
- package/package.json +9 -5
- package/src/connect-serve.ts +72 -27
- package/src/duplex-over-data-channel.ts +197 -10
- package/src/index.ts +11 -0
- package/src/port-mux.ts +288 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ between the two peers with no server in the data path.
|
|
|
12
12
|
## Why it exists
|
|
13
13
|
|
|
14
14
|
WebRTC can open as many data channels as needed, so concurrency comes from the
|
|
15
|
-
transport and this adapter
|
|
15
|
+
transport and this adapter needs no multiplexer over it. What an
|
|
16
16
|
`RTCDataChannel` lacks is what request/response needs:
|
|
17
17
|
|
|
18
18
|
1. **No half-close.** A channel is open or closed. There is no way to say "I
|
|
@@ -44,6 +44,8 @@ below.
|
|
|
44
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
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
46
|
| `WebRtcParams` | `{ pc: RTCPeerConnection }`. |
|
|
47
|
+
| `webrtcPortMux(options)` | Native MessagePorts: a `webrun-rpc` `PortMux` (by shape) whose every port is its own data channel (labelled `webrun-port/…`), for `overPorts`. `options`: `pc`, `codec` (a value codec: `{ encode: serialize, decode: deserialize }` from `webrun-msgpack`), `onPort(port)` (to accept ports), `maxMessageSize`, `maxFrameBytes`. See "MessagePorts over WebRTC" below. |
|
|
48
|
+
| `DEFAULT_PORT_MAX_FRAME_BYTES`, `DEFAULT_PORT_MAX_MESSAGE_SIZE`, `PORT_DRAIN_LOW_WATER`, `PORT_LABEL_PREFIX` | 16 KiB, 16 KiB − 256, 256 KiB, `"webrun-port/"`. |
|
|
47
49
|
|
|
48
50
|
## Examples
|
|
49
51
|
|
|
@@ -121,11 +123,46 @@ for await (const chunk of duplexOverDataChannel(dc, request())) {
|
|
|
121
123
|
| 1 byte | rest of the message |
|
|
122
124
|
+--------+---------------------------+
|
|
123
125
|
|
|
124
|
-
DATA
|
|
125
|
-
END
|
|
126
|
-
ERROR
|
|
126
|
+
DATA 0x00 body bytes, at most 16 KiB - 1 per message
|
|
127
|
+
END 0x01 this side has finished sending (half-close)
|
|
128
|
+
ERROR 0x02 JSON of serializeError(err): message, stack, custom fields
|
|
129
|
+
CREDIT 0x03 big-endian u32: payload bytes the receiver's consumer pulled
|
|
127
130
|
```
|
|
128
131
|
|
|
132
|
+
### Flow control
|
|
133
|
+
|
|
134
|
+
Two limits keep memory bounded whatever the speed of either side:
|
|
135
|
+
|
|
136
|
+
- **Send buffer.** Before each `DATA` message the sender waits while the
|
|
137
|
+
channel holds more than `HIGH_WATER_MARK` (1 MiB) unsent
|
|
138
|
+
(`bufferedAmount`), until `bufferedamountlow` fires at `LOW_WATER_MARK`
|
|
139
|
+
(256 KiB).
|
|
140
|
+
- **Receive window.** A sender may have at most `RECEIVE_WINDOW` (4 MiB) of
|
|
141
|
+
payload outstanding that the receiving consumer has not pulled. The receiver
|
|
142
|
+
returns credit with `CREDIT` frames as its consumer pulls, half a window at a
|
|
143
|
+
time. On the responder, the next chunk is pulled only once the handler took
|
|
144
|
+
the previous one, so a slow handler stalls the caller.
|
|
145
|
+
|
|
146
|
+
Both ends must speak `CREDIT`: a sender facing a peer that never returns
|
|
147
|
+
credit stops after the first window. The receiver enforces the window too: a
|
|
148
|
+
peer that sends more than the window plus the credit it was returned fails the
|
|
149
|
+
call. The receiver drops what it queued, closes the channel, and its side of
|
|
150
|
+
the call throws (on the responder, the handler's input throws).
|
|
151
|
+
|
|
152
|
+
Each side keeps listening for `CREDIT` until both directions are done, so a
|
|
153
|
+
reply larger than the window, sent after the input ended, still flows. When
|
|
154
|
+
the responder's handler finishes (or throws) without reading all its input,
|
|
155
|
+
the rest of the caller's upload is read and dropped, so the caller's upload
|
|
156
|
+
finishes rather than waiting for credit. A caller that cancels, or gets an
|
|
157
|
+
error, stops its own upload and closes the channel at once.
|
|
158
|
+
|
|
159
|
+
A finished call's channel is closed by the caller, once it has the
|
|
160
|
+
responder's `END` or `ERROR`: a responder that closed right after sending them
|
|
161
|
+
could lose them in flight. Each side also holds the frames that arrive before
|
|
162
|
+
its stream starts listening, so a responder that answers at once (a handler
|
|
163
|
+
that throws, a reply with no input to wait for) is not faster than the
|
|
164
|
+
caller's listener.
|
|
165
|
+
|
|
129
166
|
```
|
|
130
167
|
caller responder
|
|
131
168
|
| createDataChannel("webrun/…") | datachannel event -> handler(input)
|
|
@@ -139,14 +176,55 @@ is at most 16 KiB, a size that every browser's SCTP implementation accepts. The
|
|
|
139
176
|
channel's `binaryType` is set to `"arraybuffer"`; text messages and empty
|
|
140
177
|
messages are ignored. The channel is closed once both directions have ended.
|
|
141
178
|
|
|
179
|
+
### MessagePorts over WebRTC, and the port stack on them
|
|
180
|
+
|
|
181
|
+
`connect` / `serve` above run their own framing and credit window per data
|
|
182
|
+
channel, so a consumer that stops reading lets its producer get up to 4 MiB
|
|
183
|
+
ahead (L6). `webrtcPortMux` gives `webrun-rpc`'s port stack a data channel per
|
|
184
|
+
port instead, and its stream tier keeps a producer within one confirmed chunk:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { deserialize, serialize } from "@statewalker/webrun-msgpack";
|
|
188
|
+
import { connect, overPorts, serve } from "@statewalker/webrun-rpc";
|
|
189
|
+
import { webrtcPortMux } from "@statewalker/webrun-streams-webrtc";
|
|
190
|
+
|
|
191
|
+
const codec = { encode: serialize, decode: deserialize };
|
|
192
|
+
await serve({ mux: overPorts((onPort) => webrtcPortMux({ pc, codec, onPort })) }, handler);
|
|
193
|
+
const { call } = await connect({ mux: overPorts(() => webrtcPortMux({ pc: other, codec })) });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Ports are ordered, reliable channels labelled `webrun-port/…`; a mux ignores
|
|
197
|
+
inbound channels without that label, and this package's `serve()` ignores
|
|
198
|
+
channels with it, so ports share a connection with `connect` / `serve` and with
|
|
199
|
+
anyone else's channels. On one connection, a mux without `onPort` (a caller's)
|
|
200
|
+
leaves inbound ports to the serving mux, so both directions can call; of
|
|
201
|
+
several serving muxes, the first takes each port. Each message is one
|
|
202
|
+
data channel message, so `maxMessageSize` defaults to 16 KiB − 256 (Firefox to
|
|
203
|
+
Chrome cuts off at 16 KiB; the port stack's framing needs the margin), and a
|
|
204
|
+
message past `maxFrameBytes` fails the port with a `RangeError` instead of
|
|
205
|
+
being dropped. Each port carries `closeSignal`, aborted when its channel closes
|
|
206
|
+
or errs, the connection becomes `failed` or `closed`, or the port is closed
|
|
207
|
+
here. A refused port (`onPort` returning `false`) is closed, so the opener's
|
|
208
|
+
call fails. Messages that arrive before a port's first listener are held for
|
|
209
|
+
it, delivered after that tick to every listener added by then. A connection
|
|
210
|
+
that has failed already makes `openPort` reject, as does an aborted `signal`.
|
|
211
|
+
`whenDrained()` waits on `bufferedamountlow` (256 KiB). The suite runs a
|
|
212
|
+
second conformance pair over these ports (`tests/port-mux.test.ts`), with L6 at
|
|
213
|
+
a one-chunk bound.
|
|
214
|
+
|
|
142
215
|
### How cancellation reaches the handler
|
|
143
216
|
|
|
144
217
|
`END` means "I am done sending", and the peer answers it by carrying on, so it
|
|
145
218
|
cannot express "stop". When the caller leaves early (`.return()` on the call's
|
|
146
|
-
generator, or an error), `duplexOverDataChannel`
|
|
147
|
-
|
|
148
|
-
`close` event ends the inbound side with `TransportClosedError
|
|
149
|
-
|
|
219
|
+
generator, or an error), `duplexOverDataChannel` closes the channel and then
|
|
220
|
+
waits for its own outbound pump to settle. On the responder, the channel's
|
|
221
|
+
`close` event ends the inbound side with `TransportClosedError`; a caller whose
|
|
222
|
+
input throws sends `ERROR` instead, which ends it with that error. Either way
|
|
223
|
+
the handler's input ends with the error (its next read throws it, so a handler
|
|
224
|
+
never takes a truncated upload for a complete one) before the responder waits
|
|
225
|
+
for its own outbound pump, which may be waiting for the handler's next output.
|
|
226
|
+
The handler's `finally` then runs, whether it was reading its input or
|
|
227
|
+
producing output.
|
|
150
228
|
|
|
151
229
|
On a normal finish the outbound pump is not awaited. The responder feeds the
|
|
152
230
|
handler's input from this generator and closes that input only when the
|
|
@@ -173,6 +251,7 @@ SDP, must be opened before `serve` is registered or on a different connection.
|
|
|
173
251
|
| Channel closes before it opens | `DataChannel closed before open` |
|
|
174
252
|
| Channel error before it opens | `DataChannel error: <message or "unknown">` |
|
|
175
253
|
| Channel closes before the peer sent `END` | `TransportClosedError` (`transport closed`) |
|
|
254
|
+
| The peer sends past the receive window without credit | `webrun-streams-webrtc: the peer sent past the receive window (4194304 bytes) without credit` |
|
|
176
255
|
| Handler throws | The caller's `for await` throws the deserialised error, with `message`, `stack` and custom fields |
|
|
177
256
|
|
|
178
257
|
On the responder, a channel task that fails (usually a caller that cancelled)
|
|
@@ -180,14 +259,13 @@ is caught, so it does not become an unhandled rejection.
|
|
|
180
259
|
|
|
181
260
|
### Constraints
|
|
182
261
|
|
|
183
|
-
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
buffer, which closes the channel when it overflows.
|
|
187
|
-
- Conformance runs in Chromium through Playwright: `pnpm run test:browser`.
|
|
262
|
+
- Conformance and the flow-control tests (100 MiB under the high-water mark;
|
|
263
|
+
a sleeping reader stalls the sender within the window) run in Chromium
|
|
264
|
+
through Playwright: `pnpm run test:browser`, also in CI.
|
|
188
265
|
Under `pnpm test` (Node) the suite is registered as skipped, because Node
|
|
189
|
-
has no `RTCPeerConnection`. L6
|
|
190
|
-
|
|
266
|
+
has no `RTCPeerConnection`. L6 asserts the per-channel window: a consumer
|
|
267
|
+
that stops reading lets the producer get at most `RECEIVE_WINDOW` (4 MiB)
|
|
268
|
+
ahead, measured at exactly that.
|
|
191
269
|
|
|
192
270
|
### Dependencies
|
|
193
271
|
|
|
@@ -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;
|
|
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;AAS1E,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,CA+CrC,CAAC"}
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send-side backpressure: a sender waits while the DataChannel holds more than this many bytes
|
|
3
|
+
* unsent (`bufferedAmount`), until it drains to {@link LOW_WATER_MARK} (`bufferedamountlow`).
|
|
4
|
+
* Without it a fast producer fills the browser's send queue (Chrome fails `send` past 16 MiB).
|
|
5
|
+
*/
|
|
6
|
+
export declare const HIGH_WATER_MARK: number;
|
|
7
|
+
export declare const LOW_WATER_MARK: number;
|
|
8
|
+
/**
|
|
9
|
+
* Receive-side backpressure: a sender may have at most this many payload bytes outstanding that
|
|
10
|
+
* the receiving consumer has not yet pulled. The receiver returns credit (`CREDIT` frames) as its
|
|
11
|
+
* consumer pulls, half a window at a time. A DataChannel delivers every message as it arrives,
|
|
12
|
+
* whether or not anyone reads, so without credit a slow reader's queue grows without bound. The
|
|
13
|
+
* window is larger than the send buffer's high-water mark so a long round trip does not throttle a
|
|
14
|
+
* fast link: the buffer limit is what bounds the sender's memory.
|
|
15
|
+
*
|
|
16
|
+
* The receiver enforces it: a peer that sends more than the window plus the credit returned fails
|
|
17
|
+
* the call (the queued payload is dropped, the stream throws, the channel is closed).
|
|
18
|
+
*/
|
|
19
|
+
export declare const RECEIVE_WINDOW: number;
|
|
20
|
+
export declare function markAnswering(dc: RTCDataChannel): void;
|
|
21
|
+
/** starts holding a channel's events until {@link duplexOverDataChannel} takes them */
|
|
22
|
+
export declare function holdEvents(dc: RTCDataChannel): void;
|
|
1
23
|
/**
|
|
2
24
|
* Drive one `Duplex` over one `RTCDataChannel`. The DataChannel hosts a tiny
|
|
3
25
|
* in-band framing protocol:
|
|
@@ -6,11 +28,24 @@
|
|
|
6
28
|
*
|
|
7
29
|
* Types are `DATA` (0x00, body bytes), `END` (0x01, signals half-close — the
|
|
8
30
|
* sender is done emitting; the channel stays open for the other direction),
|
|
9
|
-
*
|
|
31
|
+
* `ERROR` (0x02, followed by a serialised `Error`) and `CREDIT` (0x03, a
|
|
32
|
+
* big-endian u32: payload bytes the receiver's consumer has pulled, which the
|
|
33
|
+
* sender may now send again; see {@link RECEIVE_WINDOW}).
|
|
10
34
|
*
|
|
11
35
|
* `RTCDataChannel` has no native half-close, so `END` is emitted by the
|
|
12
36
|
* sender once its input iterator exhausts; the channel is only physically
|
|
13
37
|
* closed once both sides have ended.
|
|
14
38
|
*/
|
|
15
39
|
export declare function duplexOverDataChannel(dc: RTCDataChannel, input: AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array>;
|
|
40
|
+
export interface RunStreamHooks {
|
|
41
|
+
/**
|
|
42
|
+
* Called once when the inbound side fails (the peer's `ERROR`, the channel closing, a peer
|
|
43
|
+
* past the receive window), before the stream waits for its outbound pump to settle. A
|
|
44
|
+
* responder ends its handler's input here: the pump may be waiting for the handler's next
|
|
45
|
+
* output while the handler waits for its next input.
|
|
46
|
+
*/
|
|
47
|
+
onInboundFailed?(err: Error): void;
|
|
48
|
+
}
|
|
49
|
+
/** {@link duplexOverDataChannel}, with hooks for `serve` */
|
|
50
|
+
export declare function runStream(dc: RTCDataChannel, input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>, hooks?: RunStreamHooks): AsyncGenerator<Uint8Array>;
|
|
16
51
|
//# sourceMappingURL=duplex-over-data-channel.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"duplex-over-data-channel.d.ts","sourceRoot":"","sources":["../src/duplex-over-data-channel.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"duplex-over-data-channel.d.ts","sourceRoot":"","sources":["../src/duplex-over-data-channel.ts"],"names":[],"mappings":"AAcA;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAc,CAAC;AAC3C,eAAO,MAAM,cAAc,QAAa,CAAC;AACzC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc,QAAkB,CAAC;AAoB9C,wBAAgB,aAAa,CAAC,EAAE,EAAE,cAAc,GAAG,IAAI,CAEtD;AAED,uFAAuF;AACvF,wBAAgB,UAAU,CAAC,EAAE,EAAE,cAAc,GAAG,IAAI,CAUnD;AAWD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAC/B,cAAc,CAAC,UAAU,CAAC,CAE5B;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,eAAe,CAAC,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,CAAC;CACpC;AAED,4DAA4D;AAC5D,wBAAuB,SAAS,CAC9B,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,EACvD,KAAK,GAAE,cAAmB,GACzB,cAAc,CAAC,UAAU,CAAC,CA4O5B"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { connect, serve, type WebRtcParams } from "./connect-serve.js";
|
|
2
2
|
export { duplexOverDataChannel } from "./duplex-over-data-channel.js";
|
|
3
|
+
export { type DataChannelPort, DEFAULT_PORT_MAX_FRAME_BYTES, DEFAULT_PORT_MAX_MESSAGE_SIZE, PORT_DRAIN_LOW_WATER, PORT_LABEL_PREFIX, type ValueCodec, type WebRtcPortMux, type WebRtcPortMuxOptions, webrtcPortMux, } from "./port-mux.js";
|
|
3
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +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"}
|
|
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;AACtE,OAAO,EACL,KAAK,eAAe,EACpB,4BAA4B,EAC5B,6BAA6B,EAC7B,oBAAoB,EACpB,iBAAiB,EACjB,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,aAAa,GACd,MAAM,eAAe,CAAC"}
|