@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
@@ -0,0 +1,410 @@
1
+ /**
2
+ * The transport adapter. THE ONLY FILE IN THIS PACKAGE THAT IMPORTS libp2p —
3
+ * verify with `grep -rlE "^import.*libp2p" src/` from the package root; it
4
+ * must list only this file. That constraint is what keeps the transport
5
+ * swappable: everything else in `httpeers.core` talks `FetchHandler`
6
+ * (`Request -> Promise<Response>`) and never sees a `Stream` or a
7
+ * `Connection`.
8
+ *
9
+ * IDENTITY BY CLOSURE (note 24). `Duplex = (input) => AsyncGenerator` is one
10
+ * parameter, deliberately, with no slot for identity — hiding the transport
11
+ * is what makes `Duplex` swappable. So the serving-side `Duplex` is built PER
12
+ * INBOUND STREAM, with the proven peer captured in that build's closure, and
13
+ * `registerPeer` runs from inside it before `dispatch` ever sees the
14
+ * `Request`. `serveConnections` (from `@statewalker/webrun-streams-libp2p`)
15
+ * exists for exactly this: it calls back once per stream with a
16
+ * `ConnectionContext` carrying `remotePeer` — the peer id libp2p's Noise
17
+ * handshake proved for that connection, the one identity claim on the
18
+ * serving side nothing in the request payload can forge.
19
+ *
20
+ * `serveConnections` is used here rather than registering the protocol by
21
+ * hand (`node.handle`), because it already threads `ConnectionContext`
22
+ * through and owns the serving-side input queue / backpressure handling
23
+ * (`duplexOverStream`) a hand port would have to reimplement.
24
+ */
25
+ import { noise } from "@chainsafe/libp2p-noise";
26
+ import { yamux } from "@chainsafe/libp2p-yamux";
27
+ import { identify } from "@libp2p/identify";
28
+ import { createRemoteOverLink, serveFetchOverLink } from "@statewalker/httpeers-bridge";
29
+ import { PeerCallError, PeerLimitedConnectionError, PeerProtocolUnsupportedError, PeerRelayLimitExceededError, PeerRequestTimeoutError, PeerStreamResetError, PeerUnreachableError, UnknownPeerCallError, } from "@statewalker/httpeers-core";
30
+ // Only the parse error is still read here: `mapPeerCallError` classifies it.
31
+ // The fetch-over-duplex calls themselves moved to `httpeers-bridge`.
32
+ import { HttpParseError } from "@statewalker/webrun-http-streams";
33
+ import { createLibp2p } from "libp2p";
34
+ import { libp2pLink } from "./link.js";
35
+ /**
36
+ * A namespaced protocol id, passed explicitly rather than left at
37
+ * `webrun-streams-libp2p`'s package default (`/webrun-streams/1.0.0`). The
38
+ * protocol id is the demux key libp2p uses to route an inbound stream to a
39
+ * handler on the node: sharing the generic default would collide with any
40
+ * other `webrun-streams` consumer sharing the same libp2p node.
41
+ */
42
+ export const PROTOCOL = "/httpeers/1.0.0";
43
+ /**
44
+ * Per-connection concurrent stream cap, applied to both directions — THE
45
+ * SERVER-SIDE CONTRACT (ledger note 18 §6, T-3/Task 18 step 3): libp2p's own
46
+ * default is 32 inbound / 64 outbound streams per protocol per connection;
47
+ * past the inbound number, a new stream is RESET, not queued, and the caller
48
+ * observes that reset as `PeerStreamResetError` (`kind: "stream-reset"`,
49
+ * `errors.ts` row 3b, `mapPeerCallError`'s `TooManyInboundProtocolStreamsError`
50
+ * branch) — never a raw libp2p exception, but still a rejected call with no
51
+ * retry or backoff of its own. 512 is the recorded precedent from the
52
+ * stream-limits task (6a) and its regression tests in
53
+ * `webrun-streams-libp2p`. Raising it moves this cliff outward; it does not
54
+ * remove it, which is why `DEFAULT_MAX_CONCURRENT_OUTBOUND` below exists —
55
+ * see that constant's doc comment for the client-side half of this contract.
56
+ *
57
+ * THE EXPOSURE WINDOW this cap forms together with `DEFAULT_DRAIN_TIMEOUT_MS`
58
+ * (the OTHER half of the same contract, Task 18 step 3): a peer that opens
59
+ * every one of its 512 allowed streams and then stops reading pins
60
+ * `DEFAULT_MAX_STREAMS * DEFAULT_DRAIN_TIMEOUT_MS` = 512 * 15s ≈ 2.1
61
+ * stream-hours of this node's buffer before the last one is dropped. See
62
+ * `DEFAULT_DRAIN_TIMEOUT_MS`'s own doc comment for why 15s (not
63
+ * `webrun-streams-libp2p`'s 5-minute default) was chosen once this cap was
64
+ * raised past libp2p's own default. Both numbers are stated here as ONE
65
+ * contract, not two independently-tunable knobs: changing either without
66
+ * re-deriving this product reopens a cliff of a different shape.
67
+ */
68
+ export const DEFAULT_MAX_STREAMS = 512;
69
+ /**
70
+ * How long a served stream waits for a backpressured peer to drain before
71
+ * being dropped. This is the ONLY bound on a peer that requests something
72
+ * and then stops reading without closing — the serving side has no
73
+ * `.return`/abort escape the way `connect`'s caller side does.
74
+ *
75
+ * It multiplies with `DEFAULT_MAX_STREAMS`: a peer that opens the full
76
+ * stream cap and never reads any of them pins `DEFAULT_MAX_STREAMS *
77
+ * drainTimeoutMs` stream-milliseconds of server-side buffer before every one
78
+ * of them is finally dropped. `webrun-streams-libp2p`'s own default (5
79
+ * minutes) times a 512-stream cap is ~42.7 stream-hours of exposure — too
80
+ * generous to leave in force once the cap itself has been raised well past
81
+ * libp2p's default. 15 seconds is chosen instead: long enough that a
82
+ * legitimate reader on a slow or lossy link still has room to drain a
83
+ * response chunk (multiple seconds of headroom even at very low bandwidth),
84
+ * short enough that the worst case — every one of 512 streams held open by a
85
+ * peer that never reads — caps out at 512 * 15s ≈ 2.1 stream-hours and clears
86
+ * within one operational window rather than requiring intervention.
87
+ */
88
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 15_000;
89
+ /**
90
+ * T-2's request timeout contract: how long `Peer.call()` / `Remote` wait —
91
+ * across queueing for a local concurrency slot (T-3/Task 18, see
92
+ * `DEFAULT_MAX_CONCURRENT_OUTBOUND` below), dialing, protocol negotiation,
93
+ * and the response itself — before giving up and rejecting with
94
+ * `PeerRequestTimeoutError`. This is a CONTRACT, not a tuning knob picked in
95
+ * isolation: no caller of this package may assume an unbounded wait for a
96
+ * response, ever, and this is the number that bounds it by default.
97
+ *
98
+ * T-3 DELIBERATELY DID NOT ADD A SEPARATE QUEUE TIMEOUT. When Task 18 added
99
+ * the outbound semaphore, the queue wait it introduces was placed INSIDE
100
+ * this same race rather than behind its own timer — see
101
+ * `DEFAULT_MAX_CONCURRENT_OUTBOUND`'s doc comment for the reasoning and the
102
+ * worst-case arithmetic that decision avoids.
103
+ *
104
+ * CHOSEN BELOW `DEFAULT_DRAIN_TIMEOUT_MS` (15s) AND `PROTOCOL_NEGOTIATION_TIMEOUT`
105
+ * (10s, libp2p's own default for how long protocol negotiation may take —
106
+ * `libp2p/dist/src/connection-manager/constants.defaults.js`), deliberately:
107
+ * 8s < 10s < 15s. That ordering means THIS timeout is always the first bound
108
+ * to trip. A caller therefore always observes a typed
109
+ * `PeerRequestTimeoutError` for any hang, never a raw libp2p `TimeoutError`
110
+ * leaking through from the negotiation phase, and the drain timeout exists
111
+ * purely as a transport-internal safety net most requests will never reach.
112
+ * The reverse ordering — a request timeout longer than the drain timeout —
113
+ * would mean the transport gives up on a stalled response before the
114
+ * caller's own timeout ever fires, which is a coherent choice only when
115
+ * deliberate; it is not the choice made here.
116
+ *
117
+ * 8 seconds: generous enough that an ordinary handler (token verification,
118
+ * a store lookup, one hop through a relay) never trips it under normal
119
+ * load, short enough that a caller waiting on a mesh peer notices "this
120
+ * peer is not answering" within under two heartbeat intervals (the
121
+ * heartbeat cadence this design assumes is 5s — see `DEFAULT_MINT_TTL_MS`'s
122
+ * doc comment in `peer.ts`) rather than being left hanging indefinitely.
123
+ * Override via `requestTimeoutMs` when a deployment knows better.
124
+ */
125
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 8_000;
126
+ /**
127
+ * T-3 (Task 18): the CLIENT-SIDE half of the concurrency contract
128
+ * `DEFAULT_MAX_STREAMS` states the server-side half of. Ledger note 18 §6 is
129
+ * explicit that raising `maxInboundStreams`/`maxOutboundStreams` to 512
130
+ * (Task 6a) MOVED the cliff rather than removing it: past the cap, libp2p
131
+ * RESETS the stream instead of queueing it, so a caller sees a rejected
132
+ * promise instead of latency, and nothing retries or backs off. This
133
+ * constant bounds `createRemote`'s own concurrency instead, so a well-behaved
134
+ * `Remote` never has to find that cliff at all: `createRemote` runs every
135
+ * outbound call through a private, in-process semaphore of this width before
136
+ * it ever calls `connect()` (which is what triggers libp2p's own
137
+ * `maxOutboundStreams` check, `TooManyOutboundProtocolStreamsError` --
138
+ * `errors.ts` row 3a). Excess calls QUEUE, in FIFO order, rather than racing
139
+ * each other into that cap.
140
+ *
141
+ * DEFAULTED TO `DEFAULT_MAX_STREAMS`, NOT SOME OTHER NUMBER, DELIBERATELY:
142
+ * this is a per-`Remote` (i.e. per-`Peer`) budget, not per-target-peer, so a
143
+ * `Peer` that only ever calls ONE target sees IDENTICAL admitted concurrency
144
+ * to before -- it will now queue instead of reset at the same threshold,
145
+ * never tighter -- while a `Peer` fanning calls out to MANY targets at once
146
+ * (a relay/hub forwarding on behalf of several callers) gets an additional,
147
+ * new global ceiling on its own total outbound fan-out that did not exist
148
+ * before this task. That second effect is intentional: `DEFAULT_MAX_STREAMS`
149
+ * is a PER-CONNECTION cap, so nothing previously bounded how many
150
+ * connections' worth of streams one `Remote` could have open at once: a
151
+ * `Peer` calling 20 different targets at 512 streams apiece could reach
152
+ * 10,240 concurrent outbound streams without ever touching any single
153
+ * connection's cap. That is exactly the kind of local resource exhaustion a
154
+ * DoS-adjacent control (see `DEFAULT_MAX_STREAMS`'s own doc comment) should
155
+ * not leave open just because it happens to fan out across many peers
156
+ * instead of one.
157
+ *
158
+ * THE AT-LIMIT CONTRACT (the decision ledger note 18 §6 left open --
159
+ * "queue, reject fast, or apply backpressure to the caller"): QUEUE, with a
160
+ * BOUNDED wait, then REJECT with a typed error from Task 17's taxonomy --
161
+ * never a silent reset (that would just be `DEFAULT_MAX_STREAMS`'s cliff
162
+ * again, moved one layer up), and never an unbounded queue (an unfailing
163
+ * queue under sustained overload is a slow-motion version of the same
164
+ * failure the timeout policy already rules out for every other kind of
165
+ * hang). Concretely: the bound is `DEFAULT_REQUEST_TIMEOUT_MS`, not a
166
+ * second, independent timer. `createRemote`'s `withRequestTimeout` race
167
+ * already covers "dial, negotiate, respond"; queueing for a `Semaphore`
168
+ * permit is placed INSIDE that same race (before `connect()` is even
169
+ * called), not layered outside it, specifically so a queued call that never
170
+ * gets a permit rejects with the SAME `PeerRequestTimeoutError` an
171
+ * unresponsive peer would produce, and within the SAME bound. The
172
+ * alternative -- a queue timeout on top of the existing request timeout --
173
+ * was considered and rejected: a caller that waits `requestTimeoutMs` in the
174
+ * queue and then gets a fresh `requestTimeoutMs` for the call itself sees up
175
+ * to 2 * `DEFAULT_REQUEST_TIMEOUT_MS` (16s) worst case, silently doubling a
176
+ * contract Task 17 wrote down as an 8-second promise. Sharing one budget
177
+ * keeps that promise: `Peer.call()` / `Remote` never wait longer than
178
+ * `requestTimeoutMs` for ANY reason, queueing included. Whichever reason
179
+ * wins the race, `mapPeerCallError`'s `if (err instanceof PeerCallError)
180
+ * return err` passes it through unchanged, so this never becomes a new
181
+ * taxonomy row -- `PeerRequestTimeoutError`'s trigger set simply grows to
182
+ * include "gave up waiting for a local concurrency slot," alongside its
183
+ * existing "gave up waiting for a response."
184
+ *
185
+ * A held permit is released in a `finally` (see `createRemote`) regardless
186
+ * of how the call ends -- success, a mapped `PeerCallError`, or the timeout
187
+ * race itself -- so one failing call can never permanently strand a slot.
188
+ */
189
+ export const DEFAULT_MAX_CONCURRENT_OUTBOUND = DEFAULT_MAX_STREAMS;
190
+ /**
191
+ * Node.js `net`'s own connection-establishment errno codes -- structured,
192
+ * documented properties (`err.code`), never a message string. Needed
193
+ * because of a finding this file's own tests turned up (see the Task 17
194
+ * report): libp2p's dial queue (`dial-queue.js`'s `dialPeer`) wraps
195
+ * PER-ADDRESS failures in `AggregateError` only when MORE THAN ONE address
196
+ * was tried (`errors.push(err)`, then `new AggregateError(errors, ...)`).
197
+ * With exactly one address -- the common case for an httpeers peer, which
198
+ * usually has one listen address -- it does `if (errors.length === 1) throw
199
+ * errors[0]`: the RAW error from `@libp2p/tcp`'s `net.connect` escapes
200
+ * unwrapped, with no distinguishing class or `.name` at all (`.name` is
201
+ * plain `"Error"`). `@libp2p/tcp` (`tcp.js`'s `_connect`) only prefixes that
202
+ * error's `.message`; it does not touch `.code`, so Node's own errno is
203
+ * still there to check instead of parsing text.
204
+ */
205
+ const CONNECTION_ESTABLISHMENT_ERRNO = new Set([
206
+ "ECONNREFUSED",
207
+ "ECONNRESET",
208
+ "EHOSTUNREACH",
209
+ "ENETUNREACH",
210
+ "ETIMEDOUT",
211
+ ]);
212
+ /**
213
+ * Map whatever a failed dial/call/timeout actually threw onto this
214
+ * package's own taxonomy (`errors.ts`), so `Peer.call()` / `Remote` keep
215
+ * the contract "you get a `PeerCallError`, never a raw transport
216
+ * exception". Exported so a future transport adapter (or a test) can see
217
+ * exactly which condition maps to which class; see each class's own doc
218
+ * comment in `errors.ts` for the library source each case is grounded in.
219
+ *
220
+ * Matched by `.name` string, not `instanceof`, for three of the cases
221
+ * below (`NoValidAddressesError`, `DialError`/`DialDeniedError`,
222
+ * `TransferLimitError`): none is part of `@libp2p/interface`'s (or any
223
+ * dependency this package actually has) public export surface, so there is
224
+ * no class to import and compare against. `.name` is reliably set on the
225
+ * *instance*, not only the `static name`, in every libp2p error class this
226
+ * was checked against (`@libp2p/interface/dist/src/errors.js`,
227
+ * `libp2p/dist/src/errors.js`), which is what makes the string check sound
228
+ * rather than a guess.
229
+ */
230
+ export function mapPeerCallError(err, peerId) {
231
+ if (err instanceof PeerCallError)
232
+ return err; // already mapped (e.g. re-thrown by a timeout race)
233
+ const name = err instanceof Error ? err.name : undefined;
234
+ const code = err?.code;
235
+ const cause = { cause: err };
236
+ // "All multiaddr dials failed" -- thrown only when 2+ addresses were
237
+ // tried and every one failed; see `CONNECTION_ESTABLISHMENT_ERRNO`'s doc
238
+ // comment above for the single-address case, which does NOT go through
239
+ // this class. `AggregateError` is a JS built-in, so this is the one case
240
+ // checked by `instanceof` rather than `.name`.
241
+ if (err instanceof AggregateError)
242
+ return new PeerUnreachableError(peerId, cause);
243
+ // The single-address unwrap: a raw Node.js connection error with no
244
+ // libp2p class or name of its own, identified by its own errno.
245
+ if (typeof code === "string" && CONNECTION_ESTABLISHMENT_ERRNO.has(code)) {
246
+ return new PeerUnreachableError(peerId, cause);
247
+ }
248
+ // A `HttpParseError` (`@statewalker/webrun-http-streams`) specifically
249
+ // for "no bytes arrived at all" is this package's own transport-and-codec
250
+ // stack's OTHER real signature of a stream reset, discovered by running
251
+ // this file's own tests (Task 17 report has the trace): when the
252
+ // server-side stream is aborted (a concurrency-cap reset, or any other
253
+ // abort) before a single response byte was written, the client-side
254
+ // codec's format sniffer (`sniff.ts`) sees end-of-stream on its very
255
+ // first read and throws this, rather than any stream-level error class
256
+ // ever reaching this far up the stack. `duplexOverStream`
257
+ // (`webrun-streams-libp2p`) does not distinguish a reset from a clean
258
+ // early close at its own layer, so this message is the only signal left
259
+ // by the time it gets here -- narrowed to this exact `sniff.ts` message
260
+ // (not every `HttpParseError`) because a genuinely malformed response
261
+ // (a foreign codec, corrupted bytes) is a different, real condition this
262
+ // must not misclassify as a reset.
263
+ if (err instanceof HttpParseError &&
264
+ err.message.includes("stream ended before any bytes arrived")) {
265
+ return new PeerStreamResetError(peerId, cause);
266
+ }
267
+ switch (name) {
268
+ // No dialable address is known for this peerId at all (never connected,
269
+ // no peer routing configured), or our own dial queue/gater refused to
270
+ // try -- libp2p/dist/src/connection-manager/dial-queue.js.
271
+ case "NoValidAddressesError":
272
+ case "DialError":
273
+ case "DialDeniedError":
274
+ return new PeerUnreachableError(peerId, cause);
275
+ // multistream-select could not agree on a protocol -- @libp2p/interface,
276
+ // thrown from @libp2p/multistream-select's select().
277
+ case "UnsupportedProtocolError":
278
+ return new PeerProtocolUnsupportedError(peerId, PROTOCOL, cause);
279
+ // A remote reset, a local abort, or our own outbound stream cap tripped
280
+ // inside libp2p's Connection#newStream, rejecting before the stream is
281
+ // handed back rather than queueing it -- @libp2p/interface. CORRECTED
282
+ // ON REVIEW (Task 18): this is an async rejection (the throw happens
283
+ // after `await mss.select(...)` inside `newStream`'s own async body,
284
+ // `connection.js:118` in `libp2p@3.3.8`), not a synchronous throw
285
+ // escaping outside a promise chain -- see `PeerStreamResetError`'s doc
286
+ // comment in `errors.ts` (row 3a) for why that distinction matters here
287
+ // specifically.
288
+ case "StreamResetError":
289
+ case "StreamAbortedError":
290
+ case "TooManyOutboundProtocolStreamsError":
291
+ case "TooManyInboundProtocolStreamsError":
292
+ return new PeerStreamResetError(peerId, cause);
293
+ // @libp2p/circuit-relay-v2's relay-side byte-limit enforcement. See
294
+ // `PeerRelayLimitExceededError`'s doc comment: grounded, but unwired
295
+ // and untested in this stack -- this package has no relay transport.
296
+ case "TransferLimitError":
297
+ return new PeerRelayLimitExceededError(peerId, cause);
298
+ // The only connection to the peer is a relay circuit and this call did not
299
+ // pass `runOnLimitedConnection` -- `Connection#newStream` (and the inbound
300
+ // handler check beside it) in `libp2p@3.3.8`. Exported by `@libp2p/interface`, but matched by name
301
+ // like its neighbours: this package does not import that module at runtime.
302
+ case "LimitedConnectionError":
303
+ return new PeerLimitedConnectionError(peerId, cause);
304
+ // libp2p's own internal timeouts (protocol negotiation, connection
305
+ // close) -- @libp2p/interface. Reached only if this package's own
306
+ // (shorter) DEFAULT_REQUEST_TIMEOUT_MS race somehow did not win, which
307
+ // should not happen given the ordering documented on that constant, but
308
+ // mapping it here rather than falling through to UnknownPeerCallError
309
+ // keeps the contract honest either way.
310
+ case "TimeoutError":
311
+ return new PeerRequestTimeoutError(peerId, DEFAULT_REQUEST_TIMEOUT_MS, cause);
312
+ default:
313
+ return new UnknownPeerCallError(peerId, cause);
314
+ }
315
+ }
316
+ /**
317
+ * Build a libp2p node with the transport stack this package's tests and
318
+ * `createPeer`'s default construction path both rely on: TCP, Noise
319
+ * (connection encryption — this is what makes `ConnectionContext.remotePeer`
320
+ * trustworthy), Yamux (stream muxing) and identify (address exchange, so a
321
+ * peer dialled once by full multiaddr can be dialled again by bare peerId —
322
+ * see `createRemote`). Not the only file allowed to build a node makes this
323
+ * package's swappable-transport claim false, so this stays inside
324
+ * `transport-duplex.ts`; `peer.ts` calls it, never `createLibp2p` directly.
325
+ */
326
+ export async function createNode(init) {
327
+ const { listen = [], privateKey, transports, services, connectionGater } = init;
328
+ if (transports.length === 0) {
329
+ throw new TypeError("createNode: at least one transport is required. Use `nodeTransports()` from " +
330
+ "`@statewalker/httpeers-libp2p/node` or `browserTransports()` from `./browser`.");
331
+ }
332
+ return createLibp2p({
333
+ ...(privateKey != null ? { privateKey } : {}),
334
+ ...(connectionGater != null ? { connectionGater } : {}),
335
+ addresses: { listen },
336
+ // biome-ignore lint/suspicious/noExplicitAny: libp2p's own factory types are not expressible without importing its whole component graph.
337
+ transports: transports,
338
+ connectionEncrypters: [noise()],
339
+ streamMuxers: [yamux()],
340
+ services: { identify: identify(), ...(services ?? {}) },
341
+ });
342
+ }
343
+ /**
344
+ * Register the httpeers protocol handler on `node`. Every inbound stream
345
+ * gets its own `Duplex`, closing over that stream's proven `remotePeer` —
346
+ * `registerPeer` runs before `dispatch` is ever called, so every handler
347
+ * downstream (the binding middleware first) sees the identity the transport
348
+ * itself proved, never anything the peer merely claims.
349
+ *
350
+ * Returns the teardown function `serveConnections` itself returns
351
+ * (`node.unhandle` for this protocol).
352
+ */
353
+ export async function serveTransport(init) {
354
+ // DELEGATES. The identity binding and the fetch-over-duplex plumbing moved
355
+ // to `@statewalker/httpeers-bridge`, which knows nothing about libp2p; what
356
+ // is left here is `libp2pLink`, the answer to "how do I accept a duplex".
357
+ // Keeping a second copy of the serving logic beside the generic one is
358
+ // exactly the duplication this extraction exists to remove.
359
+ return serveFetchOverLink({
360
+ link: libp2pLink({
361
+ node: init.node,
362
+ protocol: init.protocol,
363
+ drainTimeoutMs: init.drainTimeoutMs,
364
+ maxInboundStreams: init.maxInboundStreams,
365
+ maxOutboundStreams: init.maxOutboundStreams,
366
+ serveOnLimitedConnection: init.runOnLimitedConnection,
367
+ }),
368
+ dispatch: init.dispatch,
369
+ });
370
+ }
371
+ /**
372
+ * Build the `Remote` seam `createPeerRouter` forwards through: dial `target`
373
+ * over libp2p and run the request over the resulting `Duplex`. Outbound is
374
+ * definitionally identity-free — nothing here registers a peer binding,
375
+ * because the *caller's* proven identity has no meaning on a connection *we*
376
+ * initiated.
377
+ *
378
+ * Every rejection from the returned function is a `PeerCallError` (see
379
+ * `errors.ts` and `mapPeerCallError` above) — never a raw libp2p exception,
380
+ * and never an unbounded wait: the whole queue-dial-negotiate-respond
381
+ * sequence (T-3/Task 18 added the queueing step) is raced against
382
+ * `requestTimeoutMs`.
383
+ *
384
+ * ONE `Semaphore` PER `Remote`, NOT PER CALL: `createRemote` is called once
385
+ * per `Peer` (see `peer.ts`'s `createPeer`), so the semaphore built here is
386
+ * that peer's single, shared outbound admission gate for as long as the
387
+ * `Peer` lives -- every call through this `Remote`, to every target,
388
+ * competes for the same `maxConcurrentOutbound` permits. See
389
+ * `DEFAULT_MAX_CONCURRENT_OUTBOUND`'s doc comment for why that scope (global
390
+ * to this `Remote`, not per-target-peer) was chosen.
391
+ */
392
+ export function createRemote(init) {
393
+ // DELEGATES, for the same reason as `serveTransport`. The permit, the
394
+ // per-call timeout and the cleanup are the bridge's; `mapPeerCallError` stays
395
+ // here because the strings it matches ("All multiaddr dials failed" and
396
+ // friends) are libp2p's own, and a generic bridge that pattern-matched one
397
+ // transport's wording would mis-classify every other one.
398
+ return createRemoteOverLink({
399
+ link: libp2pLink({
400
+ node: init.node,
401
+ protocol: init.protocol,
402
+ drainTimeoutMs: init.drainTimeoutMs,
403
+ maxOutboundStreams: init.maxOutboundStreams,
404
+ callOnLimitedConnection: init.callOnLimitedConnection,
405
+ }),
406
+ requestTimeoutMs: init.requestTimeoutMs,
407
+ maxConcurrentOutbound: init.maxConcurrentOutbound,
408
+ mapError: (error, target) => mapPeerCallError(error, target),
409
+ });
410
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@statewalker/httpeers-libp2p",
3
+ "version": "0.1.3",
4
+ "type": "module",
5
+ "description": "The transport: nodes, identity, and servePeer — the one place in the extraction that knows what libp2p is.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+ssh://git@github.com/statewalker/httpeers.git",
10
+ "directory": "packages/httpeers-libp2p"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "source": "./src/index.ts",
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./node": {
21
+ "source": "./src/node.ts",
22
+ "types": "./dist/node.d.ts",
23
+ "import": "./dist/node.js"
24
+ },
25
+ "./browser": {
26
+ "source": "./src/browser.ts",
27
+ "types": "./dist/browser.d.ts",
28
+ "import": "./dist/browser.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src"
34
+ ],
35
+ "sideEffects": false,
36
+ "dependencies": {
37
+ "@chainsafe/libp2p-noise": "^17.0.0",
38
+ "@chainsafe/libp2p-yamux": "^8.0.1",
39
+ "@libp2p/circuit-relay-v2": "^4.2.13",
40
+ "@libp2p/crypto": "^5.1.23",
41
+ "@libp2p/identify": "^4.1.14",
42
+ "@libp2p/interface": "^3.3.0",
43
+ "@libp2p/peer-id": "^6.0.15",
44
+ "@libp2p/tcp": "^11.0.28",
45
+ "@libp2p/utils": "^7.4.1",
46
+ "@libp2p/webrtc": "^6.0.33",
47
+ "@libp2p/websockets": "^10.1.21",
48
+ "@multiformats/multiaddr": "^13.0.3",
49
+ "@statewalker/webrun-http-streams": "^0.2.2",
50
+ "@statewalker/webrun-streams": "^0.2.0",
51
+ "@statewalker/webrun-streams-libp2p": "^0.1.2",
52
+ "libp2p": "^3.3.11",
53
+ "@statewalker/httpeers-bridge": "^0.1.3",
54
+ "@statewalker/httpeers-core": "^0.1.2"
55
+ },
56
+ "devDependencies": {
57
+ "@biomejs/biome": "^2.5.15",
58
+ "@types/node": "^26.6.4",
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^5.0.3",
61
+ "@statewalker/httpeers-access": "^0.1.3"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.build.json",
68
+ "test": "pnpm run build && pnpm run typecheck && vitest run --no-file-parallelism",
69
+ "typecheck": "tsc --noEmit",
70
+ "lint": "biome check src tests",
71
+ "lint:check": "biome check src tests"
72
+ }
73
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,118 @@
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
+
18
+ import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
19
+ import { webRTC } from "@libp2p/webrtc";
20
+ import { webSockets } from "@libp2p/websockets";
21
+ import type { BytesStore } from "./identity.js";
22
+ import type { TransportFactory } from "./transport.js";
23
+
24
+ /**
25
+ * The slice of IndexedDB this file uses, declared MODULE-LOCALLY.
26
+ *
27
+ * The DOM lib is deliberately absent from this package — including it is how a
28
+ * DOM-only global gets blessed by the compiler and is not noticed until the
29
+ * code reaches a worker — and declaring these globally would collide with the
30
+ * real ones for every consumer who does have them. Only what is called is
31
+ * described; a fuller copy would be a second source of truth to drift.
32
+ */
33
+ type IdbRequest<T> = {
34
+ result: T;
35
+ error: unknown;
36
+ onsuccess: (() => void) | null;
37
+ onerror: (() => void) | null;
38
+ onupgradeneeded?: (() => void) | null;
39
+ };
40
+ type IdbStore = {
41
+ get(key: string): IdbRequest<ArrayBuffer | Uint8Array | undefined>;
42
+ put(value: Uint8Array, key: string): IdbRequest<unknown>;
43
+ delete(key: string): IdbRequest<unknown>;
44
+ };
45
+ type IdbDatabase = {
46
+ objectStoreNames: { contains(name: string): boolean };
47
+ createObjectStore(name: string): unknown;
48
+ transaction(
49
+ name: string,
50
+ mode: "readonly" | "readwrite",
51
+ ): { objectStore(name: string): IdbStore };
52
+ close(): void;
53
+ };
54
+ declare const indexedDB: { open(name: string, version: number): IdbRequest<IdbDatabase> };
55
+
56
+ /** Transports for a page. Fresh instances per call — libp2p's are not reusable. */
57
+ export function browserTransports(): TransportFactory[] {
58
+ return [
59
+ webSockets() as unknown as TransportFactory,
60
+ circuitRelayTransport() as unknown as TransportFactory,
61
+ webRTC() as unknown as TransportFactory,
62
+ ];
63
+ }
64
+
65
+ /**
66
+ * A `BytesStore` in IndexedDB — where a page keeps its identity.
67
+ *
68
+ * NOT `localStorage`, and the difference matters: a key is bytes, and
69
+ * `localStorage` stores strings, so every read and write would go through a
70
+ * base64 round trip that can silently truncate. IndexedDB stores a
71
+ * `Uint8Array` as itself.
72
+ *
73
+ * Written against the raw IndexedDB API rather than a wrapper so this package
74
+ * keeps its single dependency. It is thirty lines and it never changes.
75
+ */
76
+ export function idbBytesStore(name = "httpeers", store = "identity"): BytesStore {
77
+ const open = (): Promise<IdbDatabase> =>
78
+ new Promise((resolve, reject) => {
79
+ const request = indexedDB.open(name, 1);
80
+ request.onupgradeneeded = () => {
81
+ if (!request.result.objectStoreNames.contains(store)) {
82
+ request.result.createObjectStore(store);
83
+ }
84
+ };
85
+ request.onsuccess = () => resolve(request.result);
86
+ request.onerror = () => reject(request.error);
87
+ });
88
+
89
+ const run = async <T>(
90
+ mode: "readonly" | "readwrite",
91
+ act: (s: IdbStore) => IdbRequest<unknown>,
92
+ ): Promise<T> => {
93
+ const db = await open();
94
+ try {
95
+ return await new Promise<T>((resolve, reject) => {
96
+ const request = act(db.transaction(store, mode).objectStore(store));
97
+ request.onsuccess = () => resolve(request.result as T);
98
+ request.onerror = () => reject(request.error);
99
+ });
100
+ } finally {
101
+ db.close();
102
+ }
103
+ };
104
+
105
+ return {
106
+ async get(key) {
107
+ const value = await run<ArrayBuffer | Uint8Array | undefined>("readonly", (s) => s.get(key));
108
+ if (value === undefined) return undefined;
109
+ return value instanceof Uint8Array ? value : new Uint8Array(value);
110
+ },
111
+ async set(key, value) {
112
+ await run("readwrite", (s) => s.put(value, key));
113
+ },
114
+ async delete(key) {
115
+ await run("readwrite", (s) => s.delete(key));
116
+ },
117
+ };
118
+ }