@statewalker/httpeers-libp2p 0.1.4 → 0.1.5

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 (2) hide show
  1. package/README.md +180 -134
  2. package/package.json +9 -5
package/README.md CHANGED
@@ -1,173 +1,219 @@
1
1
  # @statewalker/httpeers-libp2p
2
2
 
3
- The transport: nodes, identity, reachability, and `servePeer` — **the one
4
- package in the extraction that knows what libp2p is.**
3
+ ## What it is
4
+
5
+ The httpeers transport: libp2p nodes, identity keys, reachability through
6
+ circuit relays, and `servePeer`, which puts a mount table on the wire and calls
7
+ other peers. It is the only httpeers package that imports libp2p; everything
8
+ above it speaks `FetchHandler` and `Mounts`.
9
+
10
+ ## Why it exists
11
+
12
+ A hub, a Node member and a page all need the same arrangement: a node, a
13
+ router, an inbound protocol handler and an outbound dialler — and, for a peer
14
+ that cannot accept inbound connections, a relay reservation that stays alive.
15
+ Getting reachability right is most of the work (a reservation that silently
16
+ lapses makes a peer unreachable with no error anywhere), so it is written once,
17
+ here, and every assembly takes it as a library instead of wiring libp2p by hand.
18
+
19
+ ## How to use
20
+
21
+ ```sh
22
+ pnpm add @statewalker/httpeers-libp2p
23
+ ```
24
+
25
+ libp2p and its transports are regular dependencies. Under Node,
26
+ `@libp2p/webrtc` needs the native `node-datachannel` module; with pnpm 10 it
27
+ must be listed in `onlyBuiltDependencies`, or pnpm skips its build script and
28
+ the import fails at runtime (see the repository root README).
29
+
30
+ | Import | Gives | Runs in |
31
+ |---|---|---|
32
+ | `.` | `createNode`, `servePeer`, identity (`generateKey`, `peerIdOf`, `signerOf`, `identityStore`), reachability, duplex | Node and browsers |
33
+ | `./node` | `nodeTransports()` (TCP), `fileBytesStore` | Node only (`@libp2p/tcp`, `node:fs`) |
34
+ | `./browser` | `browserTransports()` (WebSockets, circuit relay, WebRTC), `idbBytesStore` | browsers (IndexedDB) |
35
+
36
+ The root never reaches `@libp2p/tcp` or a `node:` builtin; `tests/boundary.test.ts`
37
+ asserts it. `@libp2p/webrtc` is at the root because a page needs it and a Node
38
+ process can load it too.
39
+
40
+ ## Examples
41
+
42
+ A Node peer serving a mount table and calling another peer:
5
43
 
6
44
  ```ts
45
+ import { createMounts } from "@statewalker/httpeers-core";
7
46
  import { createNode, generateKey, servePeer } from "@statewalker/httpeers-libp2p";
8
47
  import { nodeTransports } from "@statewalker/httpeers-libp2p/node";
9
48
 
10
- const node = await createNode({ privateKey: await generateKey(), transports: nodeTransports() });
11
- const peer = await servePeer({ node, mounts, access: guard });
49
+ const node = await createNode({
50
+ privateKey: await generateKey(),
51
+ transports: nodeTransports(),
52
+ listen: ["/ip4/0.0.0.0/tcp/0"],
53
+ });
54
+ const mounts = createMounts();
55
+ const peer = await servePeer({ node, mounts, access: guard }); // guard: e.g. withAccess(...)
12
56
 
13
- await peer.call("12D3KooWOther", new Request("http://peer.local/hello"));
57
+ const res = await peer.call(otherPeerId, new Request("http://peer.local/hello"));
14
58
  ```
15
59
 
16
- Everything above this package speaks `FetchHandler` and `Mounts`. Nothing above
17
- it imports `libp2p`.
60
+ Keeping a relay reservation alive and exposing its health:
18
61
 
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.
62
+ ```ts
63
+ import { reservationHealthy, superviseRelay } from "@statewalker/httpeers-libp2p";
25
64
 
26
- ```
27
- servePeer ──owns──> the protocol handler, the router, the outbound dialler
28
- ──takes──> access: (handler) => handler ← withAccess, from httpeers-access
65
+ const supervisor = superviseRelay({ node, relayAddr });
66
+ const healthy = reservationHealthy(supervisor.state()); // reserved, or lost < 2 min
29
67
  ```
30
68
 
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.
69
+ Signing tokens with the node's own key:
34
70
 
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.
71
+ ```ts
72
+ import { signerOf } from "@statewalker/httpeers-libp2p";
73
+ import { mintToken } from "@statewalker/httpeers-access/issuer";
74
+
75
+ const signer = signerOf(hubKey); // { mesh: peerIdOf(hubKey), seed }
76
+ const token = await mintToken({ signer, sub, roles: ["member"], ttlMs: 60_000 });
77
+ ```
38
78
 
39
- ## Transports are a parameter, and that was a defect fix
79
+ ## Internals
40
80
 
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.
81
+ ### `servePeer` owns the wire and takes policy as a parameter
45
82
 
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 |
83
+ ```
84
+ servePeer ──owns──> the protocol handler (/httpeers/1.0.0), the router, the outbound dialler
85
+ ──takes──> access: (handler) => handler e.g. withAccess from httpeers-access
86
+ ```
87
+
88
+ `httpeers-access` has no transport and this package has no access policy; the
89
+ graph runs `core → access` and `core → libp2p`, and the boundary test fails if
90
+ either points at the other. `Peer.dispatch` is the inbound router *after*
91
+ identity binding and policy, so a local edge reuses the same decision path.
92
+ Inbound identity comes from the Noise handshake and is bound with
93
+ `registerPeer` by `@statewalker/httpeers-bridge`.
51
94
 
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.
95
+ ### `createNode` requires transports
57
96
 
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.
97
+ There is no default transport list. A default containing `tcp()` would drag a
98
+ Node-only transport into every browser bundle that imports the root. Pass
99
+ `nodeTransports()` or `browserTransports()`; with none, `createNode` throws
100
+ `createNode: at least one transport is required. Use \`nodeTransports()\` …`.
101
+ The root uses an explicit export list, never `export *`, so nothing Node-only
102
+ leaks in through a barrel.
62
103
 
63
- ## Reachability, which is most of what is here
104
+ ### A browser is reachable only through a relay reservation
64
105
 
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:
106
+ A page cannot listen for inbound connections. It reserves a slot on a relay and
107
+ accepts a WebRTC upgrade brokered over it:
68
108
 
69
109
  | Module | What it does |
70
110
  |---|---|
71
- | `reservation` | `dialRelay`, `waitForCircuitReservation`, `circuitAddrs`, `superviseRelay` (+ `reservationHealthy`, `renewalIntervalMs`) |
72
- | `hop-reserve` | `requestRelayReservation` — ask the relay itself, in its own protocol |
111
+ | `reservation` | `dialRelay`, `waitForCircuitReservation`, `circuitAddrs`, `superviseRelay`, `reservationHealthy`, `renewalIntervalMs` |
112
+ | `hop-reserve` | `requestRelayReservation` — a circuit-relay v2 `HOP RESERVE` on the existing connection |
73
113
  | `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
114
+ | `hub-link` | `reachHub`, `reachHubRelayed`, `reserveOnHub`, `leaveRelay`, `superviseHubReservation` |
115
+ | `hub-relay` | `hubRelayService`, `membershipGater`, `releaseReservation` — a hub relaying for its own members only |
116
+ | `identity` | `generateKey`, `peerIdOf`, `signerOf`, `identityStore` |
117
+ | `duplex` | `serveDuplex`, `openDuplex`, `createDuplexMounts` — see below |
118
+
119
+ **A reservation yields two addresses, and only one works.** The bare
120
+ `/p2p-circuit` address is a *limited* connection on which libp2p silently
121
+ refuses the httpeers protocol; the `/webrtc`-suffixed one is the one to
122
+ publish. `circuitAddrs` returns both, labelled.
123
+
124
+ ### Only the relay knows whether you are reserved
125
+
126
+ `node.getMultiaddrs()` is the node's own belief. A node can keep listing a
127
+ circuit address for hours while the relay holds no reservation: the WebSocket
128
+ is still up, and every member trying to reach it gets `NO_RESERVATION`. So the
129
+ address list proves a **loss** (the address is gone), never health.
130
+
131
+ `superviseRelay` therefore asks the relay, with a `HOP RESERVE` over the
132
+ existing connection, on a schedule derived from the TTL the relay granted
133
+ (`renewalIntervalMs`: a quarter of it, jittered down into its upper quarter;
134
+ 22.5–30 minutes against a two-hour TTL, clamped to 1–30 minutes). libp2p's own
135
+ `addRelay` is not usable for this: it short-circuits on its cached entry, is not
136
+ reachable from `Libp2p`, and blacklists a relay locally on failure. A relay
137
+ keys reservations by peer, so one request renews an entry that exists and
138
+ re-creates one that does not, keeping live circuits.
139
+
140
+ `supervisor.state()` reports status, `verifiedAt`, `expiresAt`, `lostSince`,
141
+ consecutive failures, renewal and restore counts and the last error.
142
+ `reservationHealthy(state)` is the rule for a health check: reserved, or lost
143
+ for less than `RESERVATION_LOSS_GRACE_MS` (2 minutes). Every transition logs one
144
+ line naming the relay and the reason.
145
+
146
+ `scripts/probe-hub.mjs <hubPeerId>` checks reachability from **outside**, as a
147
+ member would: it reads the relay address from
148
+ `https://relay.httpeers.net/.well-known/httpeers-relay.json`, dials
149
+ `<relay>/p2p-circuit/p2p/<hub>` with an independent libp2p client and prints
150
+ one JSON line. Run it from this package's directory so its imports resolve.
151
+ It is the first check when members report a hub as unreachable.
152
+
153
+ ### A refused hub reservation says which refusal it was
154
+
155
+ libp2p reports every failed reservation the same way ("Some configured
156
+ addresses failed to be listened on"), with the relay's status only in the text.
157
+ `reserveOnHub` reads it out and throws `HubReservationError` with `status` and
158
+ `refusal`:
159
+
160
+ | `refusal` | Relay status | Message starts |
161
+ |---|---|---|
162
+ | `store-full` | `RESERVATION_REFUSED` | `hub-link: the hub (…) refused a reservation with RESERVATION_REFUSED: the hub's reservation store is full` |
163
+ | `resource-limit` | `RESOURCE_LIMIT_EXCEEDED` | `… refused a reservation with RESOURCE_LIMIT_EXCEEDED: the hub's relay is at a resource limit.` |
164
+ | `not-a-member` | `PERMISSION_DENIED` | `… this peer is not a member as far as the hub is concerned` |
165
+ | `no-relay` | — | `… does not relay at all -- it has no circuit-relay service` |
166
+ | `no-answer` | — | `… gave no answer to a reservation request.` |
167
+ | `other` | any other | `… refused a reservation with <status>.` |
168
+
169
+ A full store is not a membership problem, and the message says so.
170
+ `tests/hub-link.test.ts` pins each case.
171
+
172
+ ### A hub's reservation store is sized for a mesh
173
+
174
+ libp2p's relay default holds 15 reservations for two hours each and keeps one
175
+ after its holder disconnects; a hub on that default refuses every member with
132
176
  `RESERVATION_REFUSED` after fifteen distinct peers. `hubRelayService` sizes the
133
177
  store at `HUB_MAX_RESERVATIONS` (4096; `{ maxReservations }` overrides it),
134
178
  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
179
+ `releaseReservation(relay, peerId)` frees a revoked member's slot at once. The
180
+ per-circuit data limits stay at libp2p's defaults (128 KiB, 2 minutes): they
181
+ keep the hub a signalling channel. The store bounds how *many* members are
138
182
  reachable through the hub, not how *much* may cross it.
139
183
 
140
- ## `signerOf` — the bridge that stops a mesh naming nobody
184
+ `membershipGater` takes a thunk, not a value: the hub's node must exist before
185
+ the member store that answers "is this a member", and the thunk is read at
186
+ decision time.
141
187
 
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.
188
+ ### `signerOf` keeps the mesh name and the signing key the same
145
189
 
146
- ```ts
147
- const signer = signerOf(hubKey); // { mesh: peerIdOf(hubKey), seed: key.raw.slice(0, 32) }
148
- ```
190
+ A hub signs membership tokens with the key its node speaks with. Build the node
191
+ from one key and mint with another, and every token's `mesh` claim names a peer
192
+ nobody is talking to — which looks like a policy bug. `signerOf(key)` returns
193
+ `{ mesh: peerIdOf(key), seed }`. Its return type is declared structurally, so
194
+ this package does not depend on `httpeers-access`; a mismatch is a compile error
195
+ in the assembly that wires the two together.
196
+
197
+ ### Duplex is a second protocol for a different shape
149
198
 
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.
199
+ A fetch contract cannot express a WebSocket: both sides talking, neither input
200
+ closed. `serveDuplex` / `openDuplex` run one `Duplex` per libp2p stream on
201
+ `/httpeers-duplex/1.0.0`, addressed by path through their own mount table
202
+ (`createDuplexMounts`).
154
203
 
155
- ## The second altitude: duplex
204
+ ### Dependencies
156
205
 
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.
206
+ libp2p (`libp2p`, `@libp2p/*`, `@chainsafe/libp2p-noise`, `@chainsafe/libp2p-yamux`,
207
+ `@multiformats/multiaddr`), `@statewalker/httpeers-core`,
208
+ `@statewalker/httpeers-bridge` (HTTP over the link), and
209
+ `@statewalker/webrun-streams`, `@statewalker/webrun-streams-libp2p`,
210
+ `@statewalker/webrun-http-streams` (duplex streams over libp2p).
163
211
 
164
- ## Tests
212
+ Tests: `pnpm --filter @statewalker/httpeers-libp2p test`. Files run serially
213
+ because they start real libp2p nodes. `tests/serve-peer.test.ts` stands up two
214
+ nodes over a real Noise handshake and checks that what arrives carries the
215
+ identity the transport proved, not what the caller said.
165
216
 
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.
217
+ ## License
171
218
 
172
- The whole stack — relay, hub, members, tokens — is exercised together in
173
- `@statewalker/httpeers-conformance`.
219
+ MIT
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@statewalker/httpeers-libp2p",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "The transport: nodes, identity, and servePeer — the one place in the extraction that knows what libp2p is.",
6
+ "bugs": {
7
+ "url": "https://github.com/statewalker/httpeers/issues"
8
+ },
9
+ "homepage": "https://github.com/statewalker/httpeers/tree/main/packages/httpeers-libp2p#readme",
6
10
  "license": "MIT",
7
11
  "repository": {
8
12
  "type": "git",
9
- "url": "git+ssh://git@github.com/statewalker/httpeers.git",
13
+ "url": "git+https://github.com/statewalker/httpeers.git",
10
14
  "directory": "packages/httpeers-libp2p"
11
15
  },
12
16
  "main": "./dist/index.js",
@@ -50,15 +54,15 @@
50
54
  "@statewalker/webrun-streams": "^0.2.0",
51
55
  "@statewalker/webrun-streams-libp2p": "^0.1.6",
52
56
  "libp2p": "^3.3.11",
53
- "@statewalker/httpeers-bridge": "^0.1.3",
54
- "@statewalker/httpeers-core": "^0.1.2"
57
+ "@statewalker/httpeers-bridge": "^0.1.4",
58
+ "@statewalker/httpeers-core": "^0.1.3"
55
59
  },
56
60
  "devDependencies": {
57
61
  "@biomejs/biome": "^2.5.15",
58
62
  "@types/node": "^26.6.4",
59
63
  "typescript": "^7.0.2",
60
64
  "vitest": "^5.0.3",
61
- "@statewalker/httpeers-access": "^0.1.3"
65
+ "@statewalker/httpeers-access": "^0.1.4"
62
66
  },
63
67
  "publishConfig": {
64
68
  "access": "public"