@statewalker/webrun-streams-ws 0.1.1 → 0.2.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,24 +1,176 @@
1
1
  # @statewalker/webrun-streams-ws
2
2
 
3
- WebSocket-backed `Connect` / `Serve` adapter in the `webrun-streams-*` family. Each `WebSocket` is one byte channel; `emulateMux` provides the multi-stream layer on top.
3
+ WebSocket-backed `Connect` / `Serve` adapter in the `webrun-streams-*` family.
4
+
5
+ ## What this is
6
+
7
+ A binding between a `WebSocket` and the [`webrun-streams`](../webrun-streams)
8
+ `Duplex` seam. One `WebSocket` becomes one
9
+ [`ByteChannel`](../webrun-streams#the-duplex-seam); `emulateMux` layers many
10
+ concurrent logical calls on top of that single socket.
11
+
12
+ The point is that your handler never mentions WebSockets. It is an ordinary
13
+ `Duplex` — `(input: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array>` —
14
+ so the same function runs unchanged over a `MessagePort`, a WebRTC data
15
+ channel, libp2p, or an in-process pipe. Swapping transports is swapping the
16
+ import.
17
+
18
+ ## Why it exists
19
+
20
+ A raw `WebSocket` gives you one unordered-in-practice message pipe with no
21
+ notion of a request, no concurrency, no half-close, and no way to propagate an
22
+ error from the far end as an `Error`. Everything above it has to reinvent
23
+ framing, correlation and teardown.
24
+
25
+ This adapter supplies the missing piece exactly once: it wraps the socket as a
26
+ `ByteChannel`, and `emulateMux` turns that into as many independent
27
+ back-pressured byte streams as you need — each with proper end-of-stream,
28
+ mid-stream cancellation, and error propagation.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ npm install @statewalker/webrun-streams-ws
34
+ ```
35
+
36
+ In the browser the global `WebSocket` is used automatically. In Node, supply a
37
+ constructor — the [`ws`](https://www.npmjs.com/package/ws) package works as-is:
38
+
39
+ ```sh
40
+ npm install ws
41
+ ```
42
+
43
+ ## Getting started
44
+
45
+ Serve a handler over an in-process `WebSocketServer` and call it:
4
46
 
5
47
  ```ts
6
48
  import { connect, serve } from "@statewalker/webrun-streams-ws";
7
49
  import { WebSocket as NodeWebSocket, WebSocketServer } from "ws";
8
50
 
9
51
  const wss = new WebSocketServer({ port: 8080 });
52
+
53
+ // The handler is a plain Duplex: bytes in, bytes out.
10
54
  const stop = await serve(
11
- { onConnection: (cb) => { wss.on("connection", cb); return () => wss.off("connection", cb); } },
12
- async function* (input) { for await (const c of input) yield c; },
55
+ {
56
+ onConnection: (cb) => {
57
+ wss.on("connection", cb);
58
+ return () => wss.off("connection", cb);
59
+ },
60
+ },
61
+ async function* echo(input) {
62
+ for await (const chunk of input) yield chunk;
63
+ },
13
64
  );
14
65
 
15
- const { call } = await connect({ url: "ws://localhost:8080", WebSocketCtor: NodeWebSocket });
66
+ const { call, close } = await connect({
67
+ url: "ws://localhost:8080",
68
+ WebSocketCtor: NodeWebSocket, // omit in the browser
69
+ });
70
+
71
+ for await (const chunk of call([new TextEncoder().encode("hello")])) {
72
+ console.log(new TextDecoder().decode(chunk)); // "hello"
73
+ }
74
+
75
+ await close();
76
+ await stop();
16
77
  ```
17
78
 
79
+ In a browser the client side is just:
80
+
81
+ ```ts
82
+ const { call, close } = await connect({ url: "wss://example.com/socket" });
83
+ ```
84
+
85
+ ### Concurrent calls
86
+
87
+ Each `call(...)` is an independent stream over the same socket. They interleave
88
+ without interfering:
89
+
90
+ ```ts
91
+ import { collectBytes } from "@statewalker/webrun-streams";
92
+
93
+ const [a, b] = await Promise.all([
94
+ collectBytes(call(requestA)),
95
+ collectBytes(call(requestB)),
96
+ ]);
97
+ ```
98
+
99
+ ### Carrying HTTP over it
100
+
101
+ Pair with [`webrun-http-streams`](../webrun-http-streams) to move real
102
+ `Request` / `Response` objects across the socket:
103
+
104
+ ```ts
105
+ import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
106
+
107
+ const response = await fetchOverDuplex(call, new Request("http://x/api/todo"));
108
+ ```
109
+
110
+ ## API
111
+
112
+ ### `connect(params): Promise<{ call, close }>`
113
+
114
+ Opens a `WebSocket` and resolves once it is open. Type: `Connect<ConnectWsParams>`.
115
+
116
+ | Field | Type | Default | Meaning |
117
+ | --- | --- | --- | --- |
118
+ | `url` | `string` | — | WebSocket URL (`ws://` or `wss://`). |
119
+ | `protocols` | `string \| string[]` | — | Subprotocol(s) passed to the constructor. |
120
+ | `WebSocketCtor` | constructor | global `WebSocket` | Constructor to use. Required where there is no global `WebSocket` (Node). |
121
+ | `mux` | `EmulateMuxOptions` | `emulateMux`'s own | Flow-control tuning (`mtu`, `maxStreamBuffer`) forwarded to `emulateMux`. `side` is always `"initiator"` regardless of `mux.side`. |
122
+
123
+ Resolves to `{ call: Duplex, close: () => Promise<void> }`. Each `call(input)`
124
+ opens a fresh logical stream; `close()` tears down the socket and every stream
125
+ on it.
126
+
127
+ ### `serve(params, handler): Promise<() => Promise<void>>`
128
+
129
+ Registers `handler` against a source of inbound sockets. Type: `Serve<ServeWsParams>`.
130
+
131
+ | Field | Type | Meaning |
132
+ | --- | --- | --- |
133
+ | `onConnection` | `(cb: (ws: WebSocketLike) => void) => () => void` | Subscribes to inbound connections; returns an unsubscribe function. |
134
+ | `mux` | `EmulateMuxOptions` | Flow-control tuning (`mtu`, `maxStreamBuffer`) forwarded to `emulateMux`. `side` is always `"responder"` regardless of `mux.side`. |
135
+
136
+ The indirection means this package never depends on a particular server
137
+ library — wire it to `ws`, to a Deno/Bun handler, or to your own accept loop.
138
+ Returns an idempotent teardown.
139
+
140
+ ### `byteChannelFromWebSocket(ws): ByteChannel`
141
+
142
+ Wraps a single socket as a `ByteChannel` (`send` / `recv` / `closed` / `close`).
143
+ Use this when you want to drive `emulateMux` yourself, or reuse a socket you
144
+ already own.
145
+
146
+ ### `WebSocketLike` / `WS_READY_STATE`
147
+
148
+ The structural socket interface this package accepts, and the
149
+ `CONNECTING`/`OPEN`/`CLOSING`/`CLOSED` constants. Typing against `WebSocketLike`
150
+ rather than the DOM `WebSocket` is what lets the same code accept Node's `ws`.
151
+
18
152
  ## Conformance
19
153
 
20
- Passes every level of `@statewalker/webrun-streams-conformance` against an in-process `WebSocketServer`.
154
+ Passes every level (L0–L6) of
155
+ [`@statewalker/webrun-streams-conformance`](../webrun-streams-conformance)
156
+ against an in-process `WebSocketServer`: body sizes up to 10 MiB, concurrent
157
+ calls, half-close, mid-stream cancellation, error propagation with stack and
158
+ custom fields preserved, idempotent teardown, and flow control against a slow
159
+ consumer at a small advertised window.
160
+
161
+ ```sh
162
+ pnpm --filter @statewalker/webrun-streams-ws test
163
+ ```
164
+
165
+ ## Dependencies
166
+
167
+ | Dependency | Kind | Why |
168
+ | --- | --- | --- |
169
+ | [`@statewalker/webrun-streams`](../webrun-streams) | runtime | The `Duplex` / `ByteChannel` seam and `emulateMux`. |
170
+ | `ws` | dev / your choice | Only for the Node tests. Consumers pass their own constructor. |
171
+
172
+ No other runtime dependencies. ESM only (`"type": "module"`).
21
173
 
22
174
  ## License
23
175
 
24
- MIT
176
+ MIT © statewalker — see [LICENSE](../../LICENSE).
package/dist/connect.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Connect } from "@statewalker/webrun-streams";
1
+ import { type Connect, type EmulateMuxOptions } from "@statewalker/webrun-streams";
2
2
  import { type WebSocketLike } from "./websocket-like.js";
3
3
  export interface ConnectWsParams {
4
4
  /** WebSocket URL (`ws://` or `wss://`). */
@@ -10,6 +10,14 @@ export interface ConnectWsParams {
10
10
  * (browser); pass Node's `ws` package's `WebSocket` in Node.
11
11
  */
12
12
  WebSocketCtor?: new (url: string, protocols?: string | string[]) => WebSocketLike;
13
+ /**
14
+ * Flow-control tuning forwarded to `emulateMux` — `mtu` and
15
+ * `maxStreamBuffer`, which is the credit this side advertises. `side` here
16
+ * wins over `mux.side`. Defaults are `emulateMux`'s own; the conformance
17
+ * suite's L6 uses this to run at a window small enough that a sender
18
+ * genuinely stalls.
19
+ */
20
+ mux?: EmulateMuxOptions;
13
21
  }
14
22
  /**
15
23
  * Open a WebSocket to `params.url`, wrap it as a `ByteChannel`, and run it
@@ -1 +1 @@
1
- {"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAc,MAAM,6BAA6B,CAAC;AAEvE,OAAO,EAAE,KAAK,aAAa,EAAkB,MAAM,qBAAqB,CAAC;AAEzE,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC9B;;;OAGG;IACH,aAAa,CAAC,EAAE,KACd,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,KAC1B,aAAa,CAAC;CACpB;AAED;;;;GAIG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,eAAe,CAiB5C,CAAC"}
1
+ {"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,6BAA6B,CAAC;AAE/F,OAAO,EAAE,KAAK,aAAa,EAAkB,MAAM,qBAAqB,CAAC;AAEzE,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC9B;;;OAGG;IACH,aAAa,CAAC,EAAE,KACd,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,KAC1B,aAAa,CAAC;IACnB;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,iBAAiB,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,eAAe,CAiB5C,CAAC"}
package/dist/index.js CHANGED
@@ -119,7 +119,10 @@ const connect = async (params) => {
119
119
  const ws = new Ctor(params.url, params.protocols);
120
120
  await waitForOpen(ws);
121
121
  const channel = byteChannelFromWebSocket(ws);
122
- const mux = emulateMux(channel, { side: "initiator" });
122
+ const mux = emulateMux(channel, {
123
+ ...params.mux,
124
+ side: "initiator"
125
+ });
123
126
  return {
124
127
  call: mux.call,
125
128
  async close() {
@@ -166,7 +169,10 @@ function waitForOpen(ws) {
166
169
  async function serve(params, handler) {
167
170
  const off = params.onConnection((ws) => {
168
171
  const channel = byteChannelFromWebSocket(ws);
169
- const mux = emulateMux(channel, { side: "responder" });
172
+ const mux = emulateMux(channel, {
173
+ ...params.mux,
174
+ side: "responder"
175
+ });
170
176
  mux.serve(handler);
171
177
  channel.closed.then(() => mux.close());
172
178
  });
package/dist/serve.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Duplex } from "@statewalker/webrun-streams";
1
+ import { type Duplex, type EmulateMuxOptions } from "@statewalker/webrun-streams";
2
2
  import type { WebSocketLike } from "./websocket-like.js";
3
3
  export interface ServeWsParams {
4
4
  /**
@@ -7,6 +7,14 @@ export interface ServeWsParams {
7
7
  * server.
8
8
  */
9
9
  onConnection: (cb: (ws: WebSocketLike) => void) => () => void;
10
+ /**
11
+ * Flow-control tuning forwarded to `emulateMux` — `mtu` and
12
+ * `maxStreamBuffer`, which is the credit this side advertises. `side` here
13
+ * wins over `mux.side`. Defaults are `emulateMux`'s own; the conformance
14
+ * suite's L6 uses this to run at a window small enough that a sender
15
+ * genuinely stalls.
16
+ */
17
+ mux?: EmulateMuxOptions;
10
18
  }
11
19
  /**
12
20
  * Register a `Duplex` handler against an inbound-WebSocket source. The
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAc,MAAM,6BAA6B,CAAC;AAEtE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,YAAY,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,aAAa,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;CAC/D;AAED;;;;;;;;GAQG;AACH,wBAAsB,KAAK,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAahG"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAE,KAAK,iBAAiB,EAAc,MAAM,6BAA6B,CAAC;AAE9F,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,YAAY,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,aAAa,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IAC9D;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,iBAAiB,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,KAAK,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAahG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-streams-ws",
3
- "version": "0.1.1",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "WebSocket-backed Connect/Serve adapter in the webrun-streams-* family",
@@ -15,32 +15,41 @@
15
15
  "url": "git@github.com:statewalker/webrun-wire.git"
16
16
  },
17
17
  "exports": {
18
- ".": "./src/index.ts"
18
+ ".": {
19
+ "source": "./src/index.ts",
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
19
23
  },
20
24
  "files": [
21
25
  "dist",
22
26
  "src"
23
27
  ],
24
28
  "dependencies": {
25
- "@statewalker/webrun-streams": "0.1.1"
29
+ "@statewalker/webrun-streams": "^0.2.1"
26
30
  },
27
31
  "devDependencies": {
28
- "@types/node": "^26.2.0",
29
- "@types/ws": "^8.5.13",
32
+ "@biomejs/biome": "^2.5.15",
33
+ "@types/node": "^26.6.4",
34
+ "@types/ws": "^8.18.2",
30
35
  "rimraf": "^6.1.3",
31
- "rolldown": "^1.2.4",
36
+ "rolldown": "^1.2.12",
32
37
  "typescript": "^7.0.2",
33
- "vitest": "^4.1.10",
34
- "ws": "^8.18.0",
35
- "@statewalker/webrun-streams-conformance": "0.1.1"
38
+ "vitest": "^5.0.3",
39
+ "ws": "^8.22.0",
40
+ "@statewalker/webrun-streams-conformance": "^0.3.1"
36
41
  },
37
42
  "sideEffects": false,
38
43
  "publishConfig": {
39
44
  "access": "public"
40
45
  },
46
+ "types": "./dist/index.d.ts",
41
47
  "scripts": {
42
48
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
43
49
  "test": "vitest run",
50
+ "test:bench": "vitest run --config vitest.bench.config.ts --reporter=verbose",
51
+ "typecheck": "tsc --noEmit",
52
+ "typecheck:tests": "tsc -p tsconfig.tests.json",
44
53
  "lint": "biome check src tests"
45
54
  }
46
55
  }
package/src/connect.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Connect, emulateMux } from "@statewalker/webrun-streams";
1
+ import { type Connect, type EmulateMuxOptions, emulateMux } from "@statewalker/webrun-streams";
2
2
  import { byteChannelFromWebSocket } from "./byte-channel.js";
3
3
  import { type WebSocketLike, WS_READY_STATE } from "./websocket-like.js";
4
4
 
@@ -15,6 +15,14 @@ export interface ConnectWsParams {
15
15
  url: string,
16
16
  protocols?: string | string[],
17
17
  ) => WebSocketLike;
18
+ /**
19
+ * Flow-control tuning forwarded to `emulateMux` — `mtu` and
20
+ * `maxStreamBuffer`, which is the credit this side advertises. `side` here
21
+ * wins over `mux.side`. Defaults are `emulateMux`'s own; the conformance
22
+ * suite's L6 uses this to run at a window small enough that a sender
23
+ * genuinely stalls.
24
+ */
25
+ mux?: EmulateMuxOptions;
18
26
  }
19
27
 
20
28
  /**
@@ -32,7 +40,7 @@ export const connect: Connect<ConnectWsParams> = async (params) => {
32
40
  const ws = new Ctor(params.url, params.protocols) as WebSocketLike;
33
41
  await waitForOpen(ws);
34
42
  const channel = byteChannelFromWebSocket(ws);
35
- const mux = emulateMux(channel, { side: "initiator" });
43
+ const mux = emulateMux(channel, { ...params.mux, side: "initiator" });
36
44
  return {
37
45
  call: mux.call,
38
46
  async close() {
package/src/serve.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Duplex, emulateMux } from "@statewalker/webrun-streams";
1
+ import { type Duplex, type EmulateMuxOptions, emulateMux } from "@statewalker/webrun-streams";
2
2
  import { byteChannelFromWebSocket } from "./byte-channel.js";
3
3
  import type { WebSocketLike } from "./websocket-like.js";
4
4
 
@@ -9,6 +9,14 @@ export interface ServeWsParams {
9
9
  * server.
10
10
  */
11
11
  onConnection: (cb: (ws: WebSocketLike) => void) => () => void;
12
+ /**
13
+ * Flow-control tuning forwarded to `emulateMux` — `mtu` and
14
+ * `maxStreamBuffer`, which is the credit this side advertises. `side` here
15
+ * wins over `mux.side`. Defaults are `emulateMux`'s own; the conformance
16
+ * suite's L6 uses this to run at a window small enough that a sender
17
+ * genuinely stalls.
18
+ */
19
+ mux?: EmulateMuxOptions;
12
20
  }
13
21
 
14
22
  /**
@@ -23,7 +31,7 @@ export interface ServeWsParams {
23
31
  export async function serve(params: ServeWsParams, handler: Duplex): Promise<() => Promise<void>> {
24
32
  const off = params.onConnection((ws) => {
25
33
  const channel = byteChannelFromWebSocket(ws);
26
- const mux = emulateMux(channel, { side: "responder" });
34
+ const mux = emulateMux(channel, { ...params.mux, side: "responder" });
27
35
  mux.serve(handler);
28
36
  void channel.closed.then(() => mux.close());
29
37
  });