@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 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 does not use `emulateMux`. What an
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 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
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` 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`.
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
- - **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`.
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's small window does not apply here (there is
190
- no `emulateMux`), so L6 is an integrity check.
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;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"}
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
- * and `ERROR` (0x02, followed by a serialised `Error`).
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":"AAiBA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAC/B,cAAc,CAAC,UAAU,CAAC,CAE5B"}
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
@@ -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"}