@statewalker/httpeers-libp2p 0.1.3

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/dist/browser.d.ts +33 -0
  4. package/dist/browser.d.ts.map +1 -0
  5. package/dist/browser.js +77 -0
  6. package/dist/duplex.d.ts +86 -0
  7. package/dist/duplex.d.ts.map +1 -0
  8. package/dist/duplex.js +101 -0
  9. package/dist/hop-reserve.d.ts +105 -0
  10. package/dist/hop-reserve.d.ts.map +1 -0
  11. package/dist/hop-reserve.js +269 -0
  12. package/dist/hub-link.d.ts +131 -0
  13. package/dist/hub-link.d.ts.map +1 -0
  14. package/dist/hub-link.js +217 -0
  15. package/dist/hub-relay.d.ts +129 -0
  16. package/dist/hub-relay.d.ts.map +1 -0
  17. package/dist/hub-relay.js +154 -0
  18. package/dist/identity.d.ts +84 -0
  19. package/dist/identity.d.ts.map +1 -0
  20. package/dist/identity.js +91 -0
  21. package/dist/index.d.ts +28 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +26 -0
  24. package/dist/link.d.ts +49 -0
  25. package/dist/link.d.ts.map +1 -0
  26. package/dist/link.js +88 -0
  27. package/dist/multiaddr-parts.d.ts +21 -0
  28. package/dist/multiaddr-parts.d.ts.map +1 -0
  29. package/dist/multiaddr-parts.js +27 -0
  30. package/dist/node.d.ts +33 -0
  31. package/dist/node.d.ts.map +1 -0
  32. package/dist/node.js +60 -0
  33. package/dist/reservation.d.ts +245 -0
  34. package/dist/reservation.d.ts.map +1 -0
  35. package/dist/reservation.js +396 -0
  36. package/dist/serve-peer.d.ts +91 -0
  37. package/dist/serve-peer.d.ts.map +1 -0
  38. package/dist/serve-peer.js +68 -0
  39. package/dist/timers.d.ts +36 -0
  40. package/dist/timers.d.ts.map +1 -0
  41. package/dist/timers.js +38 -0
  42. package/dist/transport.d.ts +314 -0
  43. package/dist/transport.d.ts.map +1 -0
  44. package/dist/transport.js +410 -0
  45. package/package.json +73 -0
  46. package/src/browser.ts +118 -0
  47. package/src/duplex.ts +172 -0
  48. package/src/hop-reserve.ts +295 -0
  49. package/src/hub-link.ts +281 -0
  50. package/src/hub-relay.ts +188 -0
  51. package/src/identity.ts +155 -0
  52. package/src/index.ts +114 -0
  53. package/src/link.ts +143 -0
  54. package/src/multiaddr-parts.ts +29 -0
  55. package/src/node.ts +63 -0
  56. package/src/reservation.ts +564 -0
  57. package/src/serve-peer.ts +141 -0
  58. package/src/timers.ts +47 -0
  59. package/src/transport.ts +538 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kotelnikov
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,173 @@
1
+ # @statewalker/httpeers-libp2p
2
+
3
+ The transport: nodes, identity, reachability, and `servePeer` — **the one
4
+ package in the extraction that knows what libp2p is.**
5
+
6
+ ```ts
7
+ import { createNode, generateKey, servePeer } from "@statewalker/httpeers-libp2p";
8
+ import { nodeTransports } from "@statewalker/httpeers-libp2p/node";
9
+
10
+ const node = await createNode({ privateKey: await generateKey(), transports: nodeTransports() });
11
+ const peer = await servePeer({ node, mounts, access: guard });
12
+
13
+ await peer.call("12D3KooWOther", new Request("http://peer.local/hello"));
14
+ ```
15
+
16
+ Everything above this package speaks `FetchHandler` and `Mounts`. Nothing above
17
+ it imports `libp2p`.
18
+
19
+ ## `servePeer` is the seam three assemblies collapse into
20
+
21
+ A hub, a Node member and a page were each wiring a node, a router, an inbound
22
+ handler and an outbound dialler by hand. `servePeer` is that arrangement, once:
23
+ it owns the wire and the router, and it takes `access` as a parameter rather
24
+ than knowing what a token is.
25
+
26
+ ```
27
+ servePeer ──owns──> the protocol handler, the router, the outbound dialler
28
+ ──takes──> access: (handler) => handler ← withAccess, from httpeers-access
29
+ ```
30
+
31
+ The two never import each other. `httpeers-access` has no transport and this
32
+ has no crypto policy; the graph runs `core → access` and `core → libp2p`, and
33
+ `tests/boundary.test.ts` fails if either starts pointing at the other.
34
+
35
+ `Peer.dispatch` is exposed deliberately — it is the inbound router *after*
36
+ identity binding and policy, which is what lets a local edge reuse the same
37
+ decision path instead of building a second one.
38
+
39
+ ## Transports are a parameter, and that was a defect fix
40
+
41
+ `createNode` **requires** `transports`. The prototype hard-coded `tcp()` inside
42
+ its node factory, so importing the transport module at all dragged a Node-only
43
+ transport into a browser bundle — in a package whose whole claim was that one
44
+ implementation runs on both.
45
+
46
+ | Import | Holds | Why it cannot be at the root |
47
+ |---|---|---|
48
+ | `.` | `createNode`, `servePeer`, identity, reachability, duplex | — |
49
+ | `./node` | `nodeTransports()` (adds `tcp`), `fileBytesStore` | `@libp2p/tcp` cannot run in a browser; `node:fs` cannot either |
50
+ | `./browser` | `browserTransports()`, `idbBytesStore` | IndexedDB |
51
+
52
+ The root's boundary test asserts `@libp2p/tcp` and `node:` never appear there.
53
+ Note what is *not* forbidden: `@libp2p/webrtc` is at the root, because a page
54
+ needs it and a Node process can load it too — though only if its native
55
+ dependency actually built. See the note on `node-datachannel` at the repository
56
+ root.
57
+
58
+ An explicit export list, never `export *`: the barrel this code came from ended
59
+ with `export * from "./transport-duplex.js"`, which is how a package that
60
+ documented itself as isomorphic pulled libp2p, TCP, Noise and yamux into every
61
+ consumer's bundle.
62
+
63
+ ## Reachability, which is most of what is here
64
+
65
+ A browser cannot listen for inbound TCP. It reserves a slot on a relay and
66
+ accepts an upgrade brokered over it, and the modules here are the steps of
67
+ that:
68
+
69
+ | Module | What it does |
70
+ |---|---|
71
+ | `reservation` | `dialRelay`, `waitForCircuitReservation`, `circuitAddrs`, `superviseRelay` (+ `reservationHealthy`, `renewalIntervalMs`) |
72
+ | `hop-reserve` | `requestRelayReservation` — ask the relay itself, in its own protocol |
73
+ | `timers` | the injectable `Timers` seam (`worker-timers` in a background tab) |
74
+ | `hub-link` | `reachHub`, `reachHubRelayed`, `reserveOnHub` (+ `HubReservationError`), `leaveRelay`, `superviseHubReservation` |
75
+ | `hub-relay` | `hubRelayService`, `membershipGater`, `releaseReservation` — a hub relaying for **its own members and nobody else** |
76
+ | `identity` | `generateKey`, `peerIdOf`, `signerOf`, the persisted `identityStore` |
77
+ | `duplex` | the second altitude — see below |
78
+
79
+ **A reservation yields two addresses and only one works.** The bare
80
+ `/p2p-circuit` entry is a *limited* connection on which libp2p silently refuses
81
+ the protocol; the `/webrtc`-suffixed one is the one to publish. `circuitAddrs`
82
+ returns both, labelled, so nobody has to rediscover which is which by pasting
83
+ the wrong one.
84
+
85
+ **Only the relay knows whether you are reserved.** `node.getMultiaddrs()` is
86
+ the node's own belief, and on 2026-09-19 a deployed hub held that belief for
87
+ hours while the relay held no reservation at all: the websocket was still up,
88
+ every member got `NO_RESERVATION`, and the supervisor — which decided from that
89
+ address list — saw nothing wrong. So `superviseRelay` asks the relay, on a
90
+ schedule derived from the TTL the relay itself granted (`renewalIntervalMs`: a
91
+ quarter of it, jittered down into its upper quarter; 22.5–30 minutes against a
92
+ two-hour TTL). The request is a circuit-relay v2 `HOP RESERVE` over the
93
+ existing connection (`requestRelayReservation`) — libp2p's own `addRelay` short
94
+ -circuits on its cached entry, is not reachable from `Libp2p`, and blacklists a
95
+ relay locally on failure. Because a relay's reservation store is keyed by peer,
96
+ that one request renews an entry that exists and re-creates one that does not,
97
+ reusing the slot and leaving live circuits alone.
98
+
99
+ The address list now proves a **loss** (it is gone) and never proves health (it
100
+ is there). `supervisor.state()` carries the whole picture — status,
101
+ `verifiedAt`, `expiresAt`, `lostSince`, consecutive failures, renewal and
102
+ restore counts, last error — and `reservationHealthy(state)` is the rule a
103
+ healthcheck should use: reserved, or lost for less than two minutes. Every
104
+ transition logs one line naming the relay and the reason; the old
105
+ implementation swallowed all of them.
106
+
107
+ `scripts/probe-hub.mjs <hubPeerId>` answers the same question from OUTSIDE, as
108
+ a member would: it dials `<relay>/p2p-circuit/p2p/<hub>` with an independent
109
+ libp2p client and prints one JSON line. Run it from this package's directory so
110
+ its dependencies resolve. That probe is how the incident was confirmed to be
111
+ real rather than a member-side fault, and it is the check to run first next
112
+ time.
113
+
114
+ **A refused reservation says which refusal it was.** libp2p reports every
115
+ failed reservation alike ("Some configured addresses failed to be listened
116
+ on"), with the relay's status only in the text. `reserveOnHub` reads it out and
117
+ throws a `HubReservationError` with `status` (the circuit-relay status, or
118
+ `null`) and `refusal`: `store-full` (`RESERVATION_REFUSED` — the hub's relay
119
+ holds as many reservations as it grants), `resource-limit`
120
+ (`RESOURCE_LIMIT_EXCEEDED`), `not-a-member` (`PERMISSION_DENIED`), `no-relay`
121
+ (the hub runs no relay service), `no-answer` (a timeout or a dropped link) or
122
+ `other`. It used to throw one message naming `PERMISSION_DENIED` whatever had
123
+ happened, and a full store was diagnosed in production as a membership problem.
124
+
125
+ **`membershipGater` takes a thunk, not a value.** A hub's node must exist
126
+ before the member store that answers "is this a member" does, and the thunk is
127
+ read at decision time, which is what closes that ordering cycle.
128
+
129
+ **A hub's reservation store is sized for a mesh, not for a public relay.**
130
+ libp2p's default holds 15 reservations for two hours each and keeps one after
131
+ its holder hangs up; a hub on it refused every member with
132
+ `RESERVATION_REFUSED` after fifteen distinct peers. `hubRelayService` sizes the
133
+ store at `HUB_MAX_RESERVATIONS` (4096; `{ maxReservations }` overrides it),
134
+ releases a reservation when its holder's last connection closes, and
135
+ `releaseReservation(relay, peerId)` frees a revoked member's slot at once. None
136
+ of this touches the per-circuit data limits (128 KiB, 2 min), which are what
137
+ keep a hub a signalling channel: the store says how *many* members are
138
+ reachable through the hub, not how *much* may cross it.
139
+
140
+ ## `signerOf` — the bridge that stops a mesh naming nobody
141
+
142
+ A hub signs membership tokens with the same key its node speaks with. Build the
143
+ node from one key and mint with another, and every token's `mesh` claim names a
144
+ peer nobody is talking to — a failure that looks like a policy bug.
145
+
146
+ ```ts
147
+ const signer = signerOf(hubKey); // { mesh: peerIdOf(hubKey), seed: key.raw.slice(0, 32) }
148
+ ```
149
+
150
+ The return type is declared **structurally** rather than imported from
151
+ `httpeers-access`, so this package does not depend on it. A mismatch is a
152
+ compile error in whichever assembly wires the two together, which is where it
153
+ belongs.
154
+
155
+ ## The second altitude: duplex
156
+
157
+ A fetch-only contract cannot express a WebSocket — both sides talking with
158
+ neither input closed. `serveDuplex` / `openDuplex` run one `Duplex` per libp2p
159
+ stream, addressed by path through their own mount table, on a separate
160
+ protocol. This exists because the requirement did, not because `FetchHandler`
161
+ was inconvenient: it is a different shape, so it gets a different seam rather
162
+ than a flag.
163
+
164
+ ## Tests
165
+
166
+ **68, plus 4 skipped** (the platform-entry exemptions in the boundary test).
167
+ `tests/hub-link.test.ts` pins how `reserveOnHub` reads each refusal out of
168
+ libp2p's error text. `tests/serve-peer.test.ts` stands up two real nodes over a real Noise
169
+ handshake and checks that what arrives carries the identity the **transport**
170
+ proved rather than anything the caller said.
171
+
172
+ The whole stack — relay, hub, members, tokens — is exercised together in
173
+ `@statewalker/httpeers-conformance`.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The browser profile.
3
+ *
4
+ * A page cannot listen and cannot open a TCP socket. It reaches the mesh by
5
+ * reserving on a relay and being dialled back through the circuit, so its
6
+ * transports are the three that make that work:
7
+ *
8
+ * webSockets — to reach the relay itself, which does listen.
9
+ * circuitRelayTransport — to be dialled through that reservation.
10
+ * webRTC — so that once two members have exchanged an offer
11
+ * over the circuit, the data goes directly and the
12
+ * hub stays a signalling channel.
13
+ *
14
+ * `tcp()` is absent because it cannot exist here, which is the whole reason
15
+ * this file is separate from `./node`.
16
+ */
17
+ import type { BytesStore } from "./identity.js";
18
+ import type { TransportFactory } from "./transport.js";
19
+ /** Transports for a page. Fresh instances per call — libp2p's are not reusable. */
20
+ export declare function browserTransports(): TransportFactory[];
21
+ /**
22
+ * A `BytesStore` in IndexedDB — where a page keeps its identity.
23
+ *
24
+ * NOT `localStorage`, and the difference matters: a key is bytes, and
25
+ * `localStorage` stores strings, so every read and write would go through a
26
+ * base64 round trip that can silently truncate. IndexedDB stores a
27
+ * `Uint8Array` as itself.
28
+ *
29
+ * Written against the raw IndexedDB API rather than a wrapper so this package
30
+ * keeps its single dependency. It is thirty lines and it never changes.
31
+ */
32
+ export declare function idbBytesStore(name?: string, store?: string): BytesStore;
33
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAkCvD,mFAAmF;AACnF,wBAAgB,iBAAiB,IAAI,gBAAgB,EAAE,CAMtD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,IAAI,SAAa,EAAE,KAAK,SAAa,GAAG,UAAU,CA0C/E"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The browser profile.
3
+ *
4
+ * A page cannot listen and cannot open a TCP socket. It reaches the mesh by
5
+ * reserving on a relay and being dialled back through the circuit, so its
6
+ * transports are the three that make that work:
7
+ *
8
+ * webSockets — to reach the relay itself, which does listen.
9
+ * circuitRelayTransport — to be dialled through that reservation.
10
+ * webRTC — so that once two members have exchanged an offer
11
+ * over the circuit, the data goes directly and the
12
+ * hub stays a signalling channel.
13
+ *
14
+ * `tcp()` is absent because it cannot exist here, which is the whole reason
15
+ * this file is separate from `./node`.
16
+ */
17
+ import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
18
+ import { webRTC } from "@libp2p/webrtc";
19
+ import { webSockets } from "@libp2p/websockets";
20
+ /** Transports for a page. Fresh instances per call — libp2p's are not reusable. */
21
+ export function browserTransports() {
22
+ return [
23
+ webSockets(),
24
+ circuitRelayTransport(),
25
+ webRTC(),
26
+ ];
27
+ }
28
+ /**
29
+ * A `BytesStore` in IndexedDB — where a page keeps its identity.
30
+ *
31
+ * NOT `localStorage`, and the difference matters: a key is bytes, and
32
+ * `localStorage` stores strings, so every read and write would go through a
33
+ * base64 round trip that can silently truncate. IndexedDB stores a
34
+ * `Uint8Array` as itself.
35
+ *
36
+ * Written against the raw IndexedDB API rather than a wrapper so this package
37
+ * keeps its single dependency. It is thirty lines and it never changes.
38
+ */
39
+ export function idbBytesStore(name = "httpeers", store = "identity") {
40
+ const open = () => new Promise((resolve, reject) => {
41
+ const request = indexedDB.open(name, 1);
42
+ request.onupgradeneeded = () => {
43
+ if (!request.result.objectStoreNames.contains(store)) {
44
+ request.result.createObjectStore(store);
45
+ }
46
+ };
47
+ request.onsuccess = () => resolve(request.result);
48
+ request.onerror = () => reject(request.error);
49
+ });
50
+ const run = async (mode, act) => {
51
+ const db = await open();
52
+ try {
53
+ return await new Promise((resolve, reject) => {
54
+ const request = act(db.transaction(store, mode).objectStore(store));
55
+ request.onsuccess = () => resolve(request.result);
56
+ request.onerror = () => reject(request.error);
57
+ });
58
+ }
59
+ finally {
60
+ db.close();
61
+ }
62
+ };
63
+ return {
64
+ async get(key) {
65
+ const value = await run("readonly", (s) => s.get(key));
66
+ if (value === undefined)
67
+ return undefined;
68
+ return value instanceof Uint8Array ? value : new Uint8Array(value);
69
+ },
70
+ async set(key, value) {
71
+ await run("readwrite", (s) => s.put(value, key));
72
+ },
73
+ async delete(key) {
74
+ await run("readwrite", (s) => s.delete(key));
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 09 — the second altitude: duplex streams over the same mesh.
3
+ *
4
+ * WHY THIS EXISTS. `Handler = (Request) => Promise<Response>` cannot express a
5
+ * WebSocket, an A2UI message stream, or anything else where both ends keep
6
+ * talking. It never could: `fetch` is not the foundation of this stack but a
7
+ * NARROWING of it. `@statewalker/webrun-http-streams` builds fetch semantics on
8
+ * a `Duplex`, and `httpeers.core`'s transport performs that narrowing at two
9
+ * call sites. So adding duplex REMOVES a restriction rather than adding a
10
+ * layer — webrun-wire's ADR-0004 calls `Duplex` "the canonical adapter seam".
11
+ *
12
+ * Duplex = (input: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array>
13
+ *
14
+ * THREE THINGS THIS FILE IS CAREFUL ABOUT:
15
+ *
16
+ * 1. IDENTITY IS AN ARGUMENT, NOT A LOOKUP. The fetch path carries the
17
+ * proven peer in a `WeakMap<Request, …>` written inside the per-stream
18
+ * closure. A duplex has no `Request` to key on, so `DuplexContext.peerId`
19
+ * is passed to the handler. `serveConnections` gives us the only claim
20
+ * that cannot be forged by the payload: `ConnectionContext.remotePeer`.
21
+ * 2. A SEPARATE PROTOCOL ID. `/httpeers-duplex/1.0.0` lives beside
22
+ * `/httpeers/1.0.0`, so the fetch path is untouched and cannot regress.
23
+ * 3. THE OPEN FRAME IS THE EXISTING ENVELOPE. `encodeMessage`/`decodeMessage`
24
+ * from webrun-http-streams — JSON line, then body bytes. Nothing new on
25
+ * the wire.
26
+ */
27
+ import type { Libp2p } from "@libp2p/interface";
28
+ import type { Duplex } from "@statewalker/webrun-streams";
29
+ export declare const DUPLEX_PROTOCOL = "/httpeers-duplex/1.0.0";
30
+ /** What a duplex handler is told. The peer is an argument because there is no Request to key a map on. */
31
+ export interface DuplexContext {
32
+ /** Proven by the Noise handshake. Never read from the payload. */
33
+ readonly peerId: string;
34
+ readonly path: string;
35
+ /** Whatever the caller put in the open frame. A CLAIM, never proof — see `peerId`. */
36
+ readonly token?: string;
37
+ }
38
+ export type DuplexHandler = (input: AsyncIterable<Uint8Array>, ctx: DuplexContext) => AsyncIterable<Uint8Array>;
39
+ export interface DuplexMounts {
40
+ /** Longest-prefix on segment boundaries, like the fetch router. */
41
+ provide(prefix: string, handler: DuplexHandler): () => void;
42
+ match(path: string): {
43
+ handler: DuplexHandler;
44
+ prefix: string;
45
+ } | null;
46
+ list(): string[];
47
+ }
48
+ export declare function createDuplexMounts(): DuplexMounts;
49
+ /** Thrown into the caller's stream when no mount claims the path. */
50
+ export declare const NO_MOUNT = "httpeers-duplex: no mount for ";
51
+ export interface ServeDuplexInit {
52
+ node: Libp2p;
53
+ mounts: DuplexMounts;
54
+ protocol?: string;
55
+ /** Left unset, libp2p's default (32) applies — the ceiling on concurrent inbound streams. */
56
+ maxInboundStreams?: number;
57
+ /** A relayed circuit is a limited connection; libp2p refuses this protocol on one unless told otherwise. */
58
+ runOnLimitedConnection?: boolean;
59
+ }
60
+ /** Register the duplex protocol. Returns the teardown `serveConnections` gives us. */
61
+ export declare function serveDuplex(init: ServeDuplexInit): Promise<() => Promise<void>>;
62
+ export interface OpenDuplexInit {
63
+ node: Libp2p;
64
+ peerId: string;
65
+ path: string;
66
+ token?: string;
67
+ protocol?: string;
68
+ runOnLimitedConnection?: boolean;
69
+ }
70
+ export interface PeerDuplex {
71
+ readonly peerId: string;
72
+ readonly path: string;
73
+ /** Feed it outbound bytes, iterate the inbound ones. */
74
+ readonly call: Duplex;
75
+ close(): Promise<void>;
76
+ }
77
+ /**
78
+ * Open a duplex stream to `(peerId, path)`.
79
+ *
80
+ * ADDRESSED BY A PAIR, NEVER BY A URL. A URL is the fetch layer's addressing
81
+ * scheme and a duplex stream has no request-line; reusing `peer://…` here
82
+ * would also re-open the URL-derived-peer hole the ghost's pin exists to
83
+ * close (`new URL("peer://a@b/x").host === "b"`, measured in rung 06).
84
+ */
85
+ export declare function openDuplex(init: OpenDuplexInit): Promise<PeerDuplex>;
86
+ //# sourceMappingURL=duplex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duplex.d.ts","sourceRoot":"","sources":["../src/duplex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAGhD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAO1D,eAAO,MAAM,eAAe,2BAA2B,CAAC;AAExD,0GAA0G;AAC1G,MAAM,WAAW,aAAa;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sFAAsF;IACtF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,MAAM,aAAa,GAAG,CAC1B,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,EAChC,GAAG,EAAE,aAAa,KACf,aAAa,CAAC,UAAU,CAAC,CAAC;AAE/B,MAAM,WAAW,YAAY;IAC3B,mEAAmE;IACnE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,MAAM,IAAI,CAAC;IAC5D,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACvE,IAAI,IAAI,MAAM,EAAE,CAAC;CAClB;AAED,wBAAgB,kBAAkB,IAAI,YAAY,CAuBjD;AAED,qEAAqE;AACrE,eAAO,MAAM,QAAQ,mCAAmC,CAAC;AAEzD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4GAA4G;IAC5G,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,sFAAsF;AACtF,wBAAsB,WAAW,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CA0BrF;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CAmB1E"}
package/dist/duplex.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 09 — the second altitude: duplex streams over the same mesh.
3
+ *
4
+ * WHY THIS EXISTS. `Handler = (Request) => Promise<Response>` cannot express a
5
+ * WebSocket, an A2UI message stream, or anything else where both ends keep
6
+ * talking. It never could: `fetch` is not the foundation of this stack but a
7
+ * NARROWING of it. `@statewalker/webrun-http-streams` builds fetch semantics on
8
+ * a `Duplex`, and `httpeers.core`'s transport performs that narrowing at two
9
+ * call sites. So adding duplex REMOVES a restriction rather than adding a
10
+ * layer — webrun-wire's ADR-0004 calls `Duplex` "the canonical adapter seam".
11
+ *
12
+ * Duplex = (input: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array>
13
+ *
14
+ * THREE THINGS THIS FILE IS CAREFUL ABOUT:
15
+ *
16
+ * 1. IDENTITY IS AN ARGUMENT, NOT A LOOKUP. The fetch path carries the
17
+ * proven peer in a `WeakMap<Request, …>` written inside the per-stream
18
+ * closure. A duplex has no `Request` to key on, so `DuplexContext.peerId`
19
+ * is passed to the handler. `serveConnections` gives us the only claim
20
+ * that cannot be forged by the payload: `ConnectionContext.remotePeer`.
21
+ * 2. A SEPARATE PROTOCOL ID. `/httpeers-duplex/1.0.0` lives beside
22
+ * `/httpeers/1.0.0`, so the fetch path is untouched and cannot regress.
23
+ * 3. THE OPEN FRAME IS THE EXISTING ENVELOPE. `encodeMessage`/`decodeMessage`
24
+ * from webrun-http-streams — JSON line, then body bytes. Nothing new on
25
+ * the wire.
26
+ */
27
+ import { peerIdFromString } from "@libp2p/peer-id";
28
+ import { decodeMessage, encodeMessage } from "@statewalker/webrun-http-streams";
29
+ import { connect, serveConnections, } from "@statewalker/webrun-streams-libp2p";
30
+ export const DUPLEX_PROTOCOL = "/httpeers-duplex/1.0.0";
31
+ export function createDuplexMounts() {
32
+ const table = [];
33
+ const norm = (p) => (p.endsWith("/") && p !== "/" ? p.slice(0, -1) : p);
34
+ return {
35
+ provide(prefix, handler) {
36
+ const entry = { prefix: norm(prefix), handler };
37
+ table.push(entry);
38
+ table.sort((a, b) => b.prefix.length - a.prefix.length);
39
+ return () => {
40
+ const at = table.indexOf(entry);
41
+ if (at >= 0)
42
+ table.splice(at, 1);
43
+ };
44
+ },
45
+ match(path) {
46
+ const target = norm(path);
47
+ const hit = table.find((m) => m.prefix === "/" || target === m.prefix || target.startsWith(`${m.prefix}/`));
48
+ return hit == null ? null : { handler: hit.handler, prefix: hit.prefix };
49
+ },
50
+ list: () => table.map((m) => m.prefix),
51
+ };
52
+ }
53
+ /** Thrown into the caller's stream when no mount claims the path. */
54
+ export const NO_MOUNT = "httpeers-duplex: no mount for ";
55
+ /** Register the duplex protocol. Returns the teardown `serveConnections` gives us. */
56
+ export async function serveDuplex(init) {
57
+ return await serveConnections({
58
+ node: init.node,
59
+ protocol: init.protocol ?? DUPLEX_PROTOCOL,
60
+ ...(init.maxInboundStreams == null ? {} : { maxInboundStreams: init.maxInboundStreams }),
61
+ ...(init.runOnLimitedConnection == null
62
+ ? {}
63
+ : { runOnLimitedConnection: init.runOnLimitedConnection }),
64
+ },
65
+ // Called ONCE PER INBOUND STREAM, which is what keeps the proven peer in
66
+ // the closure and out of the payload.
67
+ (context) => {
68
+ const peerId = context.remotePeer.toString();
69
+ return async function* (input) {
70
+ const { envelope, body } = await decodeMessage(input);
71
+ const found = init.mounts.match(envelope.path);
72
+ if (found == null)
73
+ throw new Error(`${NO_MOUNT}${envelope.path}`);
74
+ yield* found.handler(body, {
75
+ peerId,
76
+ path: envelope.path,
77
+ ...(envelope.token == null ? {} : { token: envelope.token }),
78
+ });
79
+ };
80
+ });
81
+ }
82
+ /**
83
+ * Open a duplex stream to `(peerId, path)`.
84
+ *
85
+ * ADDRESSED BY A PAIR, NEVER BY A URL. A URL is the fetch layer's addressing
86
+ * scheme and a duplex stream has no request-line; reusing `peer://…` here
87
+ * would also re-open the URL-derived-peer hole the ghost's pin exists to
88
+ * close (`new URL("peer://a@b/x").host === "b"`, measured in rung 06).
89
+ */
90
+ export async function openDuplex(init) {
91
+ const conn = await connect({
92
+ node: init.node,
93
+ peer: peerIdFromString(init.peerId),
94
+ protocol: init.protocol ?? DUPLEX_PROTOCOL,
95
+ ...(init.runOnLimitedConnection == null
96
+ ? {}
97
+ : { runOnLimitedConnection: init.runOnLimitedConnection }),
98
+ });
99
+ const call = (input) => conn.call(encodeMessage({ path: init.path, ...(init.token == null ? {} : { token: init.token }) }, input));
100
+ return { peerId: init.peerId, path: init.path, call, close: conn.close };
101
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Asking the RELAY whether this node is still reserved -- and renewing the
3
+ * reservation in the same breath.
4
+ *
5
+ * THE QUESTION THIS MODULE EXISTS TO ANSWER. `node.getMultiaddrs()` reports
6
+ * what the node's own transport reservation store believes, and in the
7
+ * 2026-09-19 incident that belief was wrong for hours: the relay held no
8
+ * reservation, the websocket was still up, every member got
9
+ * `NO_RESERVATION`, and the hub saw nothing to repair. The only authority on
10
+ * "is there a reservation" is the relay, so this asks it, over the wire.
11
+ *
12
+ * WHY NOT LIBP2P'S OWN CALL. `@libp2p/circuit-relay-v2`'s transport keeps a
13
+ * `ReservationStore` whose `addRelay(peer, type)` is the renewal entry point,
14
+ * and it is unusable as a probe for three separate reasons:
15
+ *
16
+ * 1. It SHORT-CIRCUITS. If it still holds an entry, the connection is open
17
+ * and the recorded expiry is more than ten minutes away, it returns the
18
+ * cached entry without sending a byte -- so a proactive call answers from
19
+ * the very belief we are trying to check.
20
+ * 2. It is NOT REACHABLE. The store is a private component of the transport;
21
+ * nothing on `Libp2p` exposes it.
22
+ * 3. Its failure paths are SURPRISING. A dial or protocol failure puts the
23
+ * relay in a "previously invalid" cuckoo filter (`relayFilter`) so later
24
+ * attempts are refused locally, and a discovered reservation can be
25
+ * refused with `HadEnoughRelaysError` when no pending slot is free.
26
+ *
27
+ * So we speak HOP ourselves. A `RESERVE` request on the relay's own protocol
28
+ * is two bytes, and the answer is the relay's, not ours.
29
+ *
30
+ * IT REUSES THE SLOT, IT DOES NOT LEAK A SECOND ONE. The relay's reservation
31
+ * store is keyed by peer id: `reserve()` finds the existing entry, resets its
32
+ * expiry signal in place and returns OK (see `server/reservation-store.js` in
33
+ * the installed 4.2.11). A peer cannot hold two reservations on one relay, so
34
+ * "renew" and "re-reserve" are the same request -- which is also why this one
35
+ * call both DETECTS a loss and REPAIRS it.
36
+ *
37
+ * IT DOES NOT DISTURB LIVE CIRCUITS. It opens one short-lived stream on a
38
+ * connection that already exists; already-established circuits are separate
39
+ * connections and are untouched, and the relay's refresh path does not close
40
+ * anything.
41
+ *
42
+ * THE CODEC IS HAND-WRITTEN, four fields wide, because it has to be:
43
+ * `@libp2p/circuit-relay-v2` publishes only `.` in its `exports` map, so its
44
+ * generated `pb/index.js` cannot be imported, and vendoring protons to
45
+ * regenerate a message we send ONE variant of would be more code than this.
46
+ * The wire format is pinned by `tests/hop-reserve.test.ts` against bytes taken
47
+ * from that generated encoder, and end to end by `tests/reservation-renewal.test.ts`
48
+ * against a real relay -- which is the check that actually matters, since a
49
+ * codec that agrees only with its own tests proves nothing.
50
+ */
51
+ import type { Libp2p } from "@libp2p/interface";
52
+ /** circuit-relay v2 `Status`, by number. The relay answers one of these. */
53
+ export declare const HOP_STATUS_NAMES: Readonly<Record<number, string>>;
54
+ /** `Status.OK`. */
55
+ export declare const HOP_STATUS_OK = 100;
56
+ /** How long one RESERVE round trip may take, matching libp2p's own `DEFAULT_RESERVATION_COMPLETION_TIMEOUT`. */
57
+ export declare const HOP_RESERVE_TIMEOUT_MS = 5000;
58
+ /** What the relay granted. `expiresAt` is `null` when the relay sent no reservation body. */
59
+ export interface RelayReservationGrant {
60
+ /** ms since the epoch, from the relay's own `Reservation.expire` (Unix seconds). */
61
+ expiresAt: number | null;
62
+ /** `expiresAt` minus now, at the moment the answer arrived. `null` when unknown. */
63
+ ttlMs: number | null;
64
+ }
65
+ /** The relay answered, and the answer was not OK. `status` is its own `Status` number. */
66
+ export declare class RelayReservationRefusedError extends Error {
67
+ readonly relayPeerId: string;
68
+ readonly status: number;
69
+ constructor(relayPeerId: string, status: number);
70
+ }
71
+ /** There is no connection this node could ask the relay over. Not a refusal: nothing was asked. */
72
+ export declare class RelayNotConnectedError extends Error {
73
+ readonly relayPeerId: string;
74
+ constructor(relayPeerId: string);
75
+ }
76
+ /** The subset of `HopMessage` this module reads. Everything else is skipped. */
77
+ export interface HopAnswer {
78
+ /** `HopMessage.Type`: 0 RESERVE, 1 CONNECT, 2 STATUS. */
79
+ type?: number;
80
+ status?: number;
81
+ /** `Reservation.expire`, Unix SECONDS as the relay sent it. */
82
+ expire?: number;
83
+ }
84
+ /**
85
+ * Read the fields of a `HopMessage` this module needs, skipping the rest.
86
+ *
87
+ * Deliberately lenient about everything it does not use -- an unknown field,
88
+ * a voucher, a limit -- because a decoder that throws on a field a future
89
+ * relay adds would turn a healthy relay into a lost reservation.
90
+ */
91
+ export declare function decodeHopAnswer(data: Uint8Array | {
92
+ subarray(): Uint8Array;
93
+ }): HopAnswer;
94
+ /**
95
+ * Request (or renew) this node's reservation on `relayPeerId`, and return what
96
+ * the relay granted. Throws `RelayReservationRefusedError` on any non-OK
97
+ * status, `RelayNotConnectedError` when there is nothing to ask over, and
98
+ * whatever the stream threw otherwise. Every one of those means "not reserved
99
+ * as far as the relay is concerned", which is the only judgement the caller
100
+ * needs.
101
+ */
102
+ export declare function requestRelayReservation(node: Libp2p, relayPeerId: string, options?: {
103
+ signal?: AbortSignal;
104
+ }): Promise<RelayReservationGrant>;
105
+ //# sourceMappingURL=hop-reserve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hop-reserve.d.ts","sourceRoot":"","sources":["../src/hop-reserve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAGH,OAAO,KAAK,EAAc,MAAM,EAAU,MAAM,mBAAmB,CAAC;AAIpE,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAU7D,CAAC;AAEF,mBAAmB;AACnB,eAAO,MAAM,aAAa,MAAM,CAAC;AAYjC,gHAAgH;AAChH,eAAO,MAAM,sBAAsB,OAAQ,CAAC;AAE5C,6FAA6F;AAC7F,MAAM,WAAW,qBAAqB;IACpC,oFAAoF;IACpF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,oFAAoF;IACpF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,0FAA0F;AAC1F,qBAAa,4BAA6B,SAAQ,KAAK;IAEnD,QAAQ,CAAC,WAAW,EAAE,MAAM;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM;IAFzB,YACW,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EAOxB;CACF;AAED,mGAAmG;AACnG,qBAAa,sBAAuB,SAAQ,KAAK;IACnC,QAAQ,CAAC,WAAW,EAAE,MAAM;IAAxC,YAAqB,WAAW,EAAE,MAAM,EAKvC;CACF;AAED,gFAAgF;AAChF,MAAM,WAAW,SAAS;IACxB,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG;IAAE,QAAQ,IAAI,UAAU,CAAA;CAAE,GAAG,SAAS,CAoBxF;AAED;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,WAAW,CAAA;CAAO,GACrC,OAAO,CAAC,qBAAqB,CAAC,CA4BhC"}