@statewalker/webrun-streams-libp2p 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @statewalker/webrun-streams-libp2p
2
+
3
+ libp2p native multi-stream `Connect` / `Serve` adapter. Each `call(input)` opens a new libp2p `Stream` via `node.dialProtocol(peer, [protocol])`; the responder registers via `node.handle(protocol, ...)`. Default protocol id: `/webrun-streams/1.0.0`. Targets libp2p 3.x.
4
+
5
+ ```ts
6
+ import { connect, serve } from "@statewalker/webrun-streams-libp2p";
7
+
8
+ const { call, close } = await connect({ node, peer, protocol: "/my-app/1.0.0" });
9
+ const stop = await serve({ node, protocol: "/my-app/1.0.0" }, handler);
10
+ ```
11
+
12
+ ## Knowing who is calling
13
+
14
+ `Duplex` is bytes-only and gains no new parameter, so the serving side exposes identity through a separate entry point: `serveConnections` builds a handler **per inbound stream** and hands it the connection libp2p authenticated.
15
+
16
+ ```ts
17
+ import { serveConnections } from "@statewalker/webrun-streams-libp2p";
18
+
19
+ const stop = await serveConnections({ node }, (context) => {
20
+ const who = context.remotePeer; // PeerId, from the Noise handshake
21
+ return async function* handler(input) {
22
+ for await (const chunk of input) yield respondTo(who, chunk);
23
+ };
24
+ });
25
+ ```
26
+
27
+ `context.remotePeer` is `connection.remotePeer` — the peer id libp2p proved when the connection was encrypted. It is the one identity claim on the serving side a request payload cannot forge, and it reaches the handler by closure rather than by argument, so no caller can influence it. Build any per-peer state inside `makeHandler`, not outside it: the function runs once per stream, and reusing one handler for every connection is exactly how identity-by-closure gets broken.
28
+
29
+ `serve` is `serveConnections` with the context ignored, so both share the same framing, teardown and failure handling.
30
+
31
+ ## Stream limits
32
+
33
+ libp2p caps how many streams for one protocol may be open at once **per connection**, and past the cap a new stream is reset rather than queued. This adapter opens one stream per `call`, so a caller with more concurrent requests than the cap starts seeing rejected calls with no other symptom. The three knobs below are passed straight through to `node.dialProtocol` / `node.handle` — this package sets no defaults of its own, and an option left unset is omitted from the options object entirely so libp2p's own default applies.
34
+
35
+ | Option | On | libp2p default (3.3.8) |
36
+ | --- | --- | --- |
37
+ | `maxInboundStreams` | `serve` / `serveConnections` | **32** |
38
+ | `maxOutboundStreams` | `connect`, `serve` / `serveConnections` | **64** |
39
+ | `runOnLimitedConnection` | `connect`, `serve` / `serveConnections` | unset — libp2p refuses this protocol on a limited connection |
40
+
41
+ ```ts
42
+ const stop = await serve({ node, maxInboundStreams: 128 }, handler);
43
+ const { call } = await connect({ node, peer, maxOutboundStreams: 128 });
44
+ ```
45
+
46
+ `runOnLimitedConnection` opts in to running over a connection with limits on how much data can be transferred or how long it can stay open — a relayed circuit being the usual case. This package does not decide that trade-off for you; it only exposes the knob. `tests/stream-limits.test.ts` pins both halves: a raised `maxInboundStreams` really does admit more than 32 concurrent streams, and an unconfigured server still stops at exactly 32.
47
+
48
+ ## Framing
49
+
50
+ The adapter puts a small frame on top of the libp2p stream:
51
+
52
+ [type:1][length:varint][payload]
53
+
54
+ `DATA` (`0x00`) carries body bytes; `ERROR` (`0x02`) carries a JSON-serialised `Error`. The error frame exists because yamux's native `StreamResetError` discards the message — without it, a handler that throws reaches the caller as an anonymous reset. Normal end-of-input is libp2p's own `close()`, not a frame.
55
+
56
+ ## Flow control, and what libp2p 3.x does not give you for free
57
+
58
+ libp2p 3.x streams are **push-based**: `stream.send(chunk)` returns `false` when the write buffer is full, and the stream emits `'drain'` when it can take more. (2.x's pull-based `sink(AsyncIterable)` is gone, and with it the illusion that yamux's credit window applies backpressure to a source on its own.) The outbound pump therefore honours `send()`'s return value and waits for the real `'drain'` event.
59
+
60
+ It deliberately does **not** use `stream.onDrain()`: that method memoises one promise for the stream's whole lifetime and never clears it (`@libp2p/utils@7.3.2`), so past the first backpressure cycle it resolves immediately while the buffer is still full — a write loop built on it floods the buffer without bound. `tests/backpressure.test.ts` is a regression test for exactly that.
61
+
62
+ Two bounds keep a misbehaving peer from parking a long-lived server:
63
+
64
+ - **Drain timeout** — `DEFAULT_DRAIN_TIMEOUT_MS` (5 minutes), overridable per connection via `drainTimeoutMs` on `connect`/`serve`/`serveConnections` params. A peer that requests something and then stops reading, without closing or resetting, produces no event at all; unbounded, it parks the serving side's pump, the handler and everything buffered behind it forever. The bound is generous on purpose — resetting a slow-but-alive peer is the thing backpressure exists to avoid — and expiry is logged, then unwound by aborting the stream.
65
+ - **Close timeout** — 5 s (2.x's own `DEFAULT_SEND_CLOSE_WRITE_TIMEOUT`) around the graceful `stream.close()`, falling back to `abort()`. Because the pump awaits drain before every `send()`, at most the final chunk is outstanding at close time. A trip is logged, including that the peer may see truncated data.
66
+
67
+ ## Failure handling
68
+
69
+ One inbound stream failing never takes down the serving process. `duplexOverStream` rejects on the read side when a peer sends an `ERROR` frame or resets mid-request — which happens in ordinary use, a browser tab closed mid-request being enough — and `serveConnections` catches that per stream and logs it. Callers see their own errors as usual: cancelling a `call` (i.e. `.return()` on the returned generator) sends a reset, and `close()` on the connection aborts every stream it still holds open.
70
+
71
+ ## Tests
72
+
73
+ ```bash
74
+ pnpm test # backpressure, drain timeout, identity, resilience
75
+ WEBRUN_STREAMS_LIBP2P=1 pnpm test # + the framing/conformance suite
76
+ ```
77
+
78
+ The conformance suite is opt-in because it spins up two real libp2p TCP nodes in-process.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,96 @@
1
+ import type { Libp2p, PeerId } from "@libp2p/interface";
2
+ import type { Multiaddr } from "@multiformats/multiaddr";
3
+ import type { Connect, Duplex, Serve } from "@statewalker/webrun-streams";
4
+ export declare const DEFAULT_PROTOCOL = "/webrun-streams/1.0.0";
5
+ export interface ConnectLibp2pParams {
6
+ node: Libp2p;
7
+ peer: PeerId | Multiaddr;
8
+ /** libp2p protocol id; defaults to `/webrun-streams/1.0.0`. */
9
+ protocol?: string;
10
+ /**
11
+ * How long to wait for a backpressured stream to drain before dropping the
12
+ * peer; defaults to `DEFAULT_DRAIN_TIMEOUT_MS` (5 minutes).
13
+ */
14
+ drainTimeoutMs?: number;
15
+ /**
16
+ * How many outgoing streams for this protocol libp2p allows to be open at
17
+ * the same time on one connection; passed through to `node.dialProtocol`.
18
+ * Left unset, libp2p's own default (64) applies.
19
+ */
20
+ maxOutboundStreams?: number;
21
+ /**
22
+ * Opt-in to dialing over a connection with limits on how much data can be
23
+ * transferred or how long it can be open for (e.g. a relayed circuit);
24
+ * passed through to `node.dialProtocol`. Left unset, libp2p refuses to open
25
+ * this protocol's stream on such a connection — this package does not
26
+ * decide that trade-off for the caller, it only exposes the knob.
27
+ */
28
+ runOnLimitedConnection?: boolean;
29
+ }
30
+ export interface ServeLibp2pParams {
31
+ node: Libp2p;
32
+ /** libp2p protocol id; defaults to `/webrun-streams/1.0.0`. */
33
+ protocol?: string;
34
+ /**
35
+ * How long to wait for a backpressured stream to drain before dropping the
36
+ * peer; defaults to `DEFAULT_DRAIN_TIMEOUT_MS` (5 minutes). This is the only
37
+ * bound on a peer that requests something and then stops reading without
38
+ * closing, since the caller-side `.return`/abort escape does not exist here.
39
+ */
40
+ drainTimeoutMs?: number;
41
+ /**
42
+ * How many incoming streams for this protocol libp2p allows to be open at
43
+ * the same time on one connection; passed through to `node.handle`. Left
44
+ * unset, libp2p's own default (32) applies — past it, a new inbound stream
45
+ * is reset rather than queued, so a caller that opens one stream per
46
+ * in-flight request will start seeing rejected calls above that count.
47
+ */
48
+ maxInboundStreams?: number;
49
+ /**
50
+ * How many outgoing streams for this protocol libp2p allows to be open at
51
+ * the same time on one connection; passed through to `node.handle`. Left
52
+ * unset, libp2p's own default (64) applies.
53
+ */
54
+ maxOutboundStreams?: number;
55
+ /**
56
+ * Opt-in to accepting streams for this protocol over a connection with
57
+ * limits on how much data can be transferred or how long it can be open
58
+ * for (e.g. a relayed circuit); passed through to `node.handle`. Left
59
+ * unset, libp2p refuses to open this protocol's stream on such a
60
+ * connection — this package does not decide that trade-off for the
61
+ * consumer, it only exposes the knob.
62
+ */
63
+ runOnLimitedConnection?: boolean;
64
+ }
65
+ /**
66
+ * Caller-side: each `call(input)` opens a new libp2p `Stream` via
67
+ * `node.dialProtocol(peer, [protocol])` and runs the call over it.
68
+ */
69
+ export declare const connect: Connect<ConnectLibp2pParams>;
70
+ /**
71
+ * Server-side: registers `node.handle(protocol, ...)`. Each inbound stream is
72
+ * wrapped as a `Duplex` and handed to `handler`. Identity-unaware; use
73
+ * `serveConnections` when the handler needs to know who is calling.
74
+ */
75
+ export declare const serve: Serve<ServeLibp2pParams>;
76
+ /** What the serving side knows about the connection a stream arrived on. */
77
+ export interface ConnectionContext {
78
+ /**
79
+ * The peer id libp2p's Noise handshake proved for this connection. This is
80
+ * the only identity claim on the serving side that cannot be forged by the
81
+ * request payload.
82
+ */
83
+ remotePeer: PeerId;
84
+ }
85
+ /**
86
+ * Builds a handler for one inbound connection. Called once per stream, so the
87
+ * connection stays reachable in the returned Duplex's closure — `Duplex` is
88
+ * bytes-only (ADR-0004) and gains no new parameter.
89
+ */
90
+ export type ServeConnectionsHandler = (context: ConnectionContext) => Duplex;
91
+ /**
92
+ * Like `serve`, but the handler is built per inbound stream and is told which
93
+ * peer libp2p proved on that connection.
94
+ */
95
+ export declare function serveConnections({ node, protocol, drainTimeoutMs, maxInboundStreams, maxOutboundStreams, runOnLimitedConnection, }: ServeLibp2pParams, makeHandler: ServeConnectionsHandler): Promise<() => Promise<void>>;
96
+ //# sourceMappingURL=connect-serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,MAAM,EACN,MAAM,EAGP,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAG1E,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AAExD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;OAMG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;OAOG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAqBD;;;GAGG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,mBAAmB,CAuEhD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,iBAAiB,CACF,CAAC;AAE1C,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,OAAO,EAAE,iBAAiB,KAAK,MAAM,CAAC;AAE7E;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,EACE,IAAI,EACJ,QAAQ,EACR,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,sBAAsB,GACvB,EAAE,iBAAiB,EACpB,WAAW,EAAE,uBAAuB,GACnC,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAiD9B"}
@@ -0,0 +1,68 @@
1
+ import type { Stream } from "@libp2p/interface";
2
+ /**
3
+ * Default bound for {@link waitForDrain}'s wait for the peer to make room in
4
+ * its receive window. Without a bound, a peer that requests something and then
5
+ * simply stops reading — alive, so no `close` event ever fires — parks the
6
+ * serving side's outbound pump forever, holding the stream, the handler and
7
+ * whatever the handler buffered. On the serving side there is no escape hatch:
8
+ * `connect`'s `.return`/abort override is a *caller*-side affordance.
9
+ *
10
+ * The number is deliberately generous, because a bound that is too tight
11
+ * resets a slow-but-alive peer mid-transfer — exactly what backpressure exists
12
+ * to avoid. Five minutes covers a peer draining a full yamux receive window
13
+ * (256 KiB by default) at under 1 KiB/s, i.e. slower than any link on which
14
+ * the transfer would complete anyway; a peer slower than that is
15
+ * indistinguishable from one that has stopped reading altogether. Override via
16
+ * `drainTimeoutMs` when a deployment knows better.
17
+ */
18
+ export declare const DEFAULT_DRAIN_TIMEOUT_MS = 300000;
19
+ /**
20
+ * Options for {@link duplexOverStream}. The `onPeerInputEnd` hook is the seam
21
+ * that lets the server side close its input queue as soon as the peer's source
22
+ * exhausts — without it, the server-side `serve` would deadlock waiting for the
23
+ * outbound pump to finish, which itself waits for the handler, which waits for
24
+ * inputQueue.done.
25
+ */
26
+ export interface DuplexOverStreamOptions {
27
+ /**
28
+ * Fired when the peer's source ends (peer closed write, sent an ERROR frame,
29
+ * or the stream itself was torn down). Idempotent. The optional `err`
30
+ * argument carries the deserialized error from an ERROR frame, if any.
31
+ */
32
+ onPeerInputEnd?(err?: Error): void;
33
+ /**
34
+ * Fired only when the peer's source ended naturally — i.e., consumer did not
35
+ * `.return()` mid-stream. Connect/serve uses this to decide whether to
36
+ * gracefully close vs forcibly abort the underlying stream on teardown.
37
+ */
38
+ onSourceCompleted?(): void;
39
+ /**
40
+ * How long to wait for a backpressured stream to drain before giving up on
41
+ * the peer. Defaults to {@link DEFAULT_DRAIN_TIMEOUT_MS}.
42
+ */
43
+ drainTimeoutMs?: number;
44
+ }
45
+ /**
46
+ * Drive one `Duplex` over one libp2p `Stream` using a small in-band framing
47
+ * protocol:
48
+ *
49
+ * [1-byte type][varint length][payload bytes]
50
+ *
51
+ * Types are `DATA` (0x00, body bytes) and `ERROR` (0x02, followed by a
52
+ * JSON-serialised `Error`). Normal end-of-input is signalled by libp2p's
53
+ * `close()`. The frame layer exists so we can preserve `Error` fidelity
54
+ * across the wire (yamux's native stream reset only carries "stream reset").
55
+ */
56
+ export declare function duplexOverStream(stream: Stream, input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>, opts?: DuplexOverStreamOptions): AsyncGenerator<Uint8Array>;
57
+ /**
58
+ * Close a `Stream`'s writable end, bounded by `timeoutMs`. Plain
59
+ * `stream.close()` awaits the write queue draining and the peer
60
+ * acknowledging with no bound of its own — a peer that stops reading
61
+ * without resetting (a suspended tab, a paused container, `SIGSTOP`) means
62
+ * it never settles, which would otherwise hang every caller waiting on it
63
+ * (the outbound pump here, and `connect`/`serve`'s teardown in
64
+ * `connect-serve.ts`). On timeout we fall back to a hard `abort()` so the
65
+ * caller is never left hanging.
66
+ */
67
+ export declare function closeStream(stream: Stream, timeoutMs?: number): Promise<void>;
68
+ //# sourceMappingURL=duplex-over-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duplex-over-stream.d.ts","sourceRoot":"","sources":["../src/duplex-over-stream.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAoB,MAAM,mBAAmB,CAAC;AAclE;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,wBAAwB,SAAU,CAAC;AAKhD;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACtC;;;;OAIG;IACH,cAAc,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;IACnC;;;;OAIG;IACH,iBAAiB,CAAC,IAAI,IAAI,CAAC;IAC3B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAuB,gBAAgB,CACrC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,EACvD,IAAI,GAAE,uBAA4B,GACjC,cAAc,CAAC,UAAU,CAAC,CAoF5B;AA2CD;;;;;;;;;GASG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,MAAiC,GAC3C,OAAO,CAAC,IAAI,CAAC,CAsBf"}
@@ -0,0 +1,3 @@
1
+ export { type ConnectionContext, type ConnectLibp2pParams, connect, DEFAULT_PROTOCOL, type ServeConnectionsHandler, type ServeLibp2pParams, serve, serveConnections, } from "./connect-serve.js";
2
+ export { DEFAULT_DRAIN_TIMEOUT_MS, type DuplexOverStreamOptions, duplexOverStream, } from "./duplex-over-stream.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,OAAO,EACP,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,iBAAiB,EACtB,KAAK,EACL,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,wBAAwB,EACxB,KAAK,uBAAuB,EAC5B,gBAAgB,GACjB,MAAM,yBAAyB,CAAC"}