@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,538 @@
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 type { Ed25519PrivateKey, Libp2p } from "@libp2p/interface";
29
+
30
+ // Re-exported so `peer.ts` (and any other consumer) can type an already-
31
+ // constructed node, or a retained signing key, without itself importing
32
+ // `@libp2p/interface` — this file stays the only one that does.
33
+ export type { Ed25519PrivateKey, Libp2p } from "@libp2p/interface";
34
+
35
+ import { createRemoteOverLink, serveFetchOverLink } from "@statewalker/httpeers-bridge";
36
+ import type { FetchHandler, PeerIdStr, Remote } from "@statewalker/httpeers-core";
37
+ import {
38
+ PeerCallError,
39
+ PeerLimitedConnectionError,
40
+ PeerProtocolUnsupportedError,
41
+ PeerRelayLimitExceededError,
42
+ PeerRequestTimeoutError,
43
+ PeerStreamResetError,
44
+ PeerUnreachableError,
45
+ UnknownPeerCallError,
46
+ } from "@statewalker/httpeers-core";
47
+ // Only the parse error is still read here: `mapPeerCallError` classifies it.
48
+ // The fetch-over-duplex calls themselves moved to `httpeers-bridge`.
49
+ import { HttpParseError } from "@statewalker/webrun-http-streams";
50
+ import { createLibp2p } from "libp2p";
51
+ import { type CallOnLimitedConnection, libp2pLink } from "./link.js";
52
+
53
+ /**
54
+ * A namespaced protocol id, passed explicitly rather than left at
55
+ * `webrun-streams-libp2p`'s package default (`/webrun-streams/1.0.0`). The
56
+ * protocol id is the demux key libp2p uses to route an inbound stream to a
57
+ * handler on the node: sharing the generic default would collide with any
58
+ * other `webrun-streams` consumer sharing the same libp2p node.
59
+ */
60
+ export const PROTOCOL = "/httpeers/1.0.0";
61
+
62
+ /**
63
+ * Per-connection concurrent stream cap, applied to both directions — THE
64
+ * SERVER-SIDE CONTRACT (ledger note 18 §6, T-3/Task 18 step 3): libp2p's own
65
+ * default is 32 inbound / 64 outbound streams per protocol per connection;
66
+ * past the inbound number, a new stream is RESET, not queued, and the caller
67
+ * observes that reset as `PeerStreamResetError` (`kind: "stream-reset"`,
68
+ * `errors.ts` row 3b, `mapPeerCallError`'s `TooManyInboundProtocolStreamsError`
69
+ * branch) — never a raw libp2p exception, but still a rejected call with no
70
+ * retry or backoff of its own. 512 is the recorded precedent from the
71
+ * stream-limits task (6a) and its regression tests in
72
+ * `webrun-streams-libp2p`. Raising it moves this cliff outward; it does not
73
+ * remove it, which is why `DEFAULT_MAX_CONCURRENT_OUTBOUND` below exists —
74
+ * see that constant's doc comment for the client-side half of this contract.
75
+ *
76
+ * THE EXPOSURE WINDOW this cap forms together with `DEFAULT_DRAIN_TIMEOUT_MS`
77
+ * (the OTHER half of the same contract, Task 18 step 3): a peer that opens
78
+ * every one of its 512 allowed streams and then stops reading pins
79
+ * `DEFAULT_MAX_STREAMS * DEFAULT_DRAIN_TIMEOUT_MS` = 512 * 15s ≈ 2.1
80
+ * stream-hours of this node's buffer before the last one is dropped. See
81
+ * `DEFAULT_DRAIN_TIMEOUT_MS`'s own doc comment for why 15s (not
82
+ * `webrun-streams-libp2p`'s 5-minute default) was chosen once this cap was
83
+ * raised past libp2p's own default. Both numbers are stated here as ONE
84
+ * contract, not two independently-tunable knobs: changing either without
85
+ * re-deriving this product reopens a cliff of a different shape.
86
+ */
87
+ export const DEFAULT_MAX_STREAMS = 512;
88
+
89
+ /**
90
+ * How long a served stream waits for a backpressured peer to drain before
91
+ * being dropped. This is the ONLY bound on a peer that requests something
92
+ * and then stops reading without closing — the serving side has no
93
+ * `.return`/abort escape the way `connect`'s caller side does.
94
+ *
95
+ * It multiplies with `DEFAULT_MAX_STREAMS`: a peer that opens the full
96
+ * stream cap and never reads any of them pins `DEFAULT_MAX_STREAMS *
97
+ * drainTimeoutMs` stream-milliseconds of server-side buffer before every one
98
+ * of them is finally dropped. `webrun-streams-libp2p`'s own default (5
99
+ * minutes) times a 512-stream cap is ~42.7 stream-hours of exposure — too
100
+ * generous to leave in force once the cap itself has been raised well past
101
+ * libp2p's default. 15 seconds is chosen instead: long enough that a
102
+ * legitimate reader on a slow or lossy link still has room to drain a
103
+ * response chunk (multiple seconds of headroom even at very low bandwidth),
104
+ * short enough that the worst case — every one of 512 streams held open by a
105
+ * peer that never reads — caps out at 512 * 15s ≈ 2.1 stream-hours and clears
106
+ * within one operational window rather than requiring intervention.
107
+ */
108
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 15_000;
109
+
110
+ /**
111
+ * T-2's request timeout contract: how long `Peer.call()` / `Remote` wait —
112
+ * across queueing for a local concurrency slot (T-3/Task 18, see
113
+ * `DEFAULT_MAX_CONCURRENT_OUTBOUND` below), dialing, protocol negotiation,
114
+ * and the response itself — before giving up and rejecting with
115
+ * `PeerRequestTimeoutError`. This is a CONTRACT, not a tuning knob picked in
116
+ * isolation: no caller of this package may assume an unbounded wait for a
117
+ * response, ever, and this is the number that bounds it by default.
118
+ *
119
+ * T-3 DELIBERATELY DID NOT ADD A SEPARATE QUEUE TIMEOUT. When Task 18 added
120
+ * the outbound semaphore, the queue wait it introduces was placed INSIDE
121
+ * this same race rather than behind its own timer — see
122
+ * `DEFAULT_MAX_CONCURRENT_OUTBOUND`'s doc comment for the reasoning and the
123
+ * worst-case arithmetic that decision avoids.
124
+ *
125
+ * CHOSEN BELOW `DEFAULT_DRAIN_TIMEOUT_MS` (15s) AND `PROTOCOL_NEGOTIATION_TIMEOUT`
126
+ * (10s, libp2p's own default for how long protocol negotiation may take —
127
+ * `libp2p/dist/src/connection-manager/constants.defaults.js`), deliberately:
128
+ * 8s < 10s < 15s. That ordering means THIS timeout is always the first bound
129
+ * to trip. A caller therefore always observes a typed
130
+ * `PeerRequestTimeoutError` for any hang, never a raw libp2p `TimeoutError`
131
+ * leaking through from the negotiation phase, and the drain timeout exists
132
+ * purely as a transport-internal safety net most requests will never reach.
133
+ * The reverse ordering — a request timeout longer than the drain timeout —
134
+ * would mean the transport gives up on a stalled response before the
135
+ * caller's own timeout ever fires, which is a coherent choice only when
136
+ * deliberate; it is not the choice made here.
137
+ *
138
+ * 8 seconds: generous enough that an ordinary handler (token verification,
139
+ * a store lookup, one hop through a relay) never trips it under normal
140
+ * load, short enough that a caller waiting on a mesh peer notices "this
141
+ * peer is not answering" within under two heartbeat intervals (the
142
+ * heartbeat cadence this design assumes is 5s — see `DEFAULT_MINT_TTL_MS`'s
143
+ * doc comment in `peer.ts`) rather than being left hanging indefinitely.
144
+ * Override via `requestTimeoutMs` when a deployment knows better.
145
+ */
146
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 8_000;
147
+
148
+ /**
149
+ * T-3 (Task 18): the CLIENT-SIDE half of the concurrency contract
150
+ * `DEFAULT_MAX_STREAMS` states the server-side half of. Ledger note 18 §6 is
151
+ * explicit that raising `maxInboundStreams`/`maxOutboundStreams` to 512
152
+ * (Task 6a) MOVED the cliff rather than removing it: past the cap, libp2p
153
+ * RESETS the stream instead of queueing it, so a caller sees a rejected
154
+ * promise instead of latency, and nothing retries or backs off. This
155
+ * constant bounds `createRemote`'s own concurrency instead, so a well-behaved
156
+ * `Remote` never has to find that cliff at all: `createRemote` runs every
157
+ * outbound call through a private, in-process semaphore of this width before
158
+ * it ever calls `connect()` (which is what triggers libp2p's own
159
+ * `maxOutboundStreams` check, `TooManyOutboundProtocolStreamsError` --
160
+ * `errors.ts` row 3a). Excess calls QUEUE, in FIFO order, rather than racing
161
+ * each other into that cap.
162
+ *
163
+ * DEFAULTED TO `DEFAULT_MAX_STREAMS`, NOT SOME OTHER NUMBER, DELIBERATELY:
164
+ * this is a per-`Remote` (i.e. per-`Peer`) budget, not per-target-peer, so a
165
+ * `Peer` that only ever calls ONE target sees IDENTICAL admitted concurrency
166
+ * to before -- it will now queue instead of reset at the same threshold,
167
+ * never tighter -- while a `Peer` fanning calls out to MANY targets at once
168
+ * (a relay/hub forwarding on behalf of several callers) gets an additional,
169
+ * new global ceiling on its own total outbound fan-out that did not exist
170
+ * before this task. That second effect is intentional: `DEFAULT_MAX_STREAMS`
171
+ * is a PER-CONNECTION cap, so nothing previously bounded how many
172
+ * connections' worth of streams one `Remote` could have open at once: a
173
+ * `Peer` calling 20 different targets at 512 streams apiece could reach
174
+ * 10,240 concurrent outbound streams without ever touching any single
175
+ * connection's cap. That is exactly the kind of local resource exhaustion a
176
+ * DoS-adjacent control (see `DEFAULT_MAX_STREAMS`'s own doc comment) should
177
+ * not leave open just because it happens to fan out across many peers
178
+ * instead of one.
179
+ *
180
+ * THE AT-LIMIT CONTRACT (the decision ledger note 18 §6 left open --
181
+ * "queue, reject fast, or apply backpressure to the caller"): QUEUE, with a
182
+ * BOUNDED wait, then REJECT with a typed error from Task 17's taxonomy --
183
+ * never a silent reset (that would just be `DEFAULT_MAX_STREAMS`'s cliff
184
+ * again, moved one layer up), and never an unbounded queue (an unfailing
185
+ * queue under sustained overload is a slow-motion version of the same
186
+ * failure the timeout policy already rules out for every other kind of
187
+ * hang). Concretely: the bound is `DEFAULT_REQUEST_TIMEOUT_MS`, not a
188
+ * second, independent timer. `createRemote`'s `withRequestTimeout` race
189
+ * already covers "dial, negotiate, respond"; queueing for a `Semaphore`
190
+ * permit is placed INSIDE that same race (before `connect()` is even
191
+ * called), not layered outside it, specifically so a queued call that never
192
+ * gets a permit rejects with the SAME `PeerRequestTimeoutError` an
193
+ * unresponsive peer would produce, and within the SAME bound. The
194
+ * alternative -- a queue timeout on top of the existing request timeout --
195
+ * was considered and rejected: a caller that waits `requestTimeoutMs` in the
196
+ * queue and then gets a fresh `requestTimeoutMs` for the call itself sees up
197
+ * to 2 * `DEFAULT_REQUEST_TIMEOUT_MS` (16s) worst case, silently doubling a
198
+ * contract Task 17 wrote down as an 8-second promise. Sharing one budget
199
+ * keeps that promise: `Peer.call()` / `Remote` never wait longer than
200
+ * `requestTimeoutMs` for ANY reason, queueing included. Whichever reason
201
+ * wins the race, `mapPeerCallError`'s `if (err instanceof PeerCallError)
202
+ * return err` passes it through unchanged, so this never becomes a new
203
+ * taxonomy row -- `PeerRequestTimeoutError`'s trigger set simply grows to
204
+ * include "gave up waiting for a local concurrency slot," alongside its
205
+ * existing "gave up waiting for a response."
206
+ *
207
+ * A held permit is released in a `finally` (see `createRemote`) regardless
208
+ * of how the call ends -- success, a mapped `PeerCallError`, or the timeout
209
+ * race itself -- so one failing call can never permanently strand a slot.
210
+ */
211
+ export const DEFAULT_MAX_CONCURRENT_OUTBOUND = DEFAULT_MAX_STREAMS;
212
+
213
+ /**
214
+ * Node.js `net`'s own connection-establishment errno codes -- structured,
215
+ * documented properties (`err.code`), never a message string. Needed
216
+ * because of a finding this file's own tests turned up (see the Task 17
217
+ * report): libp2p's dial queue (`dial-queue.js`'s `dialPeer`) wraps
218
+ * PER-ADDRESS failures in `AggregateError` only when MORE THAN ONE address
219
+ * was tried (`errors.push(err)`, then `new AggregateError(errors, ...)`).
220
+ * With exactly one address -- the common case for an httpeers peer, which
221
+ * usually has one listen address -- it does `if (errors.length === 1) throw
222
+ * errors[0]`: the RAW error from `@libp2p/tcp`'s `net.connect` escapes
223
+ * unwrapped, with no distinguishing class or `.name` at all (`.name` is
224
+ * plain `"Error"`). `@libp2p/tcp` (`tcp.js`'s `_connect`) only prefixes that
225
+ * error's `.message`; it does not touch `.code`, so Node's own errno is
226
+ * still there to check instead of parsing text.
227
+ */
228
+ const CONNECTION_ESTABLISHMENT_ERRNO = new Set([
229
+ "ECONNREFUSED",
230
+ "ECONNRESET",
231
+ "EHOSTUNREACH",
232
+ "ENETUNREACH",
233
+ "ETIMEDOUT",
234
+ ]);
235
+
236
+ /**
237
+ * Map whatever a failed dial/call/timeout actually threw onto this
238
+ * package's own taxonomy (`errors.ts`), so `Peer.call()` / `Remote` keep
239
+ * the contract "you get a `PeerCallError`, never a raw transport
240
+ * exception". Exported so a future transport adapter (or a test) can see
241
+ * exactly which condition maps to which class; see each class's own doc
242
+ * comment in `errors.ts` for the library source each case is grounded in.
243
+ *
244
+ * Matched by `.name` string, not `instanceof`, for three of the cases
245
+ * below (`NoValidAddressesError`, `DialError`/`DialDeniedError`,
246
+ * `TransferLimitError`): none is part of `@libp2p/interface`'s (or any
247
+ * dependency this package actually has) public export surface, so there is
248
+ * no class to import and compare against. `.name` is reliably set on the
249
+ * *instance*, not only the `static name`, in every libp2p error class this
250
+ * was checked against (`@libp2p/interface/dist/src/errors.js`,
251
+ * `libp2p/dist/src/errors.js`), which is what makes the string check sound
252
+ * rather than a guess.
253
+ */
254
+ export function mapPeerCallError(err: unknown, peerId: PeerIdStr): PeerCallError {
255
+ if (err instanceof PeerCallError) return err; // already mapped (e.g. re-thrown by a timeout race)
256
+
257
+ const name = err instanceof Error ? err.name : undefined;
258
+ const code = (err as { code?: unknown } | undefined)?.code;
259
+ const cause = { cause: err };
260
+
261
+ // "All multiaddr dials failed" -- thrown only when 2+ addresses were
262
+ // tried and every one failed; see `CONNECTION_ESTABLISHMENT_ERRNO`'s doc
263
+ // comment above for the single-address case, which does NOT go through
264
+ // this class. `AggregateError` is a JS built-in, so this is the one case
265
+ // checked by `instanceof` rather than `.name`.
266
+ if (err instanceof AggregateError) return new PeerUnreachableError(peerId, cause);
267
+
268
+ // The single-address unwrap: a raw Node.js connection error with no
269
+ // libp2p class or name of its own, identified by its own errno.
270
+ if (typeof code === "string" && CONNECTION_ESTABLISHMENT_ERRNO.has(code)) {
271
+ return new PeerUnreachableError(peerId, cause);
272
+ }
273
+
274
+ // A `HttpParseError` (`@statewalker/webrun-http-streams`) specifically
275
+ // for "no bytes arrived at all" is this package's own transport-and-codec
276
+ // stack's OTHER real signature of a stream reset, discovered by running
277
+ // this file's own tests (Task 17 report has the trace): when the
278
+ // server-side stream is aborted (a concurrency-cap reset, or any other
279
+ // abort) before a single response byte was written, the client-side
280
+ // codec's format sniffer (`sniff.ts`) sees end-of-stream on its very
281
+ // first read and throws this, rather than any stream-level error class
282
+ // ever reaching this far up the stack. `duplexOverStream`
283
+ // (`webrun-streams-libp2p`) does not distinguish a reset from a clean
284
+ // early close at its own layer, so this message is the only signal left
285
+ // by the time it gets here -- narrowed to this exact `sniff.ts` message
286
+ // (not every `HttpParseError`) because a genuinely malformed response
287
+ // (a foreign codec, corrupted bytes) is a different, real condition this
288
+ // must not misclassify as a reset.
289
+ if (
290
+ err instanceof HttpParseError &&
291
+ err.message.includes("stream ended before any bytes arrived")
292
+ ) {
293
+ return new PeerStreamResetError(peerId, cause);
294
+ }
295
+
296
+ switch (name) {
297
+ // No dialable address is known for this peerId at all (never connected,
298
+ // no peer routing configured), or our own dial queue/gater refused to
299
+ // try -- libp2p/dist/src/connection-manager/dial-queue.js.
300
+ case "NoValidAddressesError":
301
+ case "DialError":
302
+ case "DialDeniedError":
303
+ return new PeerUnreachableError(peerId, cause);
304
+ // multistream-select could not agree on a protocol -- @libp2p/interface,
305
+ // thrown from @libp2p/multistream-select's select().
306
+ case "UnsupportedProtocolError":
307
+ return new PeerProtocolUnsupportedError(peerId, PROTOCOL, cause);
308
+ // A remote reset, a local abort, or our own outbound stream cap tripped
309
+ // inside libp2p's Connection#newStream, rejecting before the stream is
310
+ // handed back rather than queueing it -- @libp2p/interface. CORRECTED
311
+ // ON REVIEW (Task 18): this is an async rejection (the throw happens
312
+ // after `await mss.select(...)` inside `newStream`'s own async body,
313
+ // `connection.js:118` in `libp2p@3.3.8`), not a synchronous throw
314
+ // escaping outside a promise chain -- see `PeerStreamResetError`'s doc
315
+ // comment in `errors.ts` (row 3a) for why that distinction matters here
316
+ // specifically.
317
+ case "StreamResetError":
318
+ case "StreamAbortedError":
319
+ case "TooManyOutboundProtocolStreamsError":
320
+ case "TooManyInboundProtocolStreamsError":
321
+ return new PeerStreamResetError(peerId, cause);
322
+ // @libp2p/circuit-relay-v2's relay-side byte-limit enforcement. See
323
+ // `PeerRelayLimitExceededError`'s doc comment: grounded, but unwired
324
+ // and untested in this stack -- this package has no relay transport.
325
+ case "TransferLimitError":
326
+ return new PeerRelayLimitExceededError(peerId, cause);
327
+ // The only connection to the peer is a relay circuit and this call did not
328
+ // pass `runOnLimitedConnection` -- `Connection#newStream` (and the inbound
329
+ // handler check beside it) in `libp2p@3.3.8`. Exported by `@libp2p/interface`, but matched by name
330
+ // like its neighbours: this package does not import that module at runtime.
331
+ case "LimitedConnectionError":
332
+ return new PeerLimitedConnectionError(peerId, cause);
333
+ // libp2p's own internal timeouts (protocol negotiation, connection
334
+ // close) -- @libp2p/interface. Reached only if this package's own
335
+ // (shorter) DEFAULT_REQUEST_TIMEOUT_MS race somehow did not win, which
336
+ // should not happen given the ordering documented on that constant, but
337
+ // mapping it here rather than falling through to UnknownPeerCallError
338
+ // keeps the contract honest either way.
339
+ case "TimeoutError":
340
+ return new PeerRequestTimeoutError(peerId, DEFAULT_REQUEST_TIMEOUT_MS, cause);
341
+ default:
342
+ return new UnknownPeerCallError(peerId, cause);
343
+ }
344
+ }
345
+
346
+ export interface CreateNodeInit {
347
+ /**
348
+ * Multiaddrs to listen on. Omit (or pass an empty array) for a dial-only
349
+ * node — one that can call out but accepts no inbound connections, which
350
+ * is a legitimate deployment shape (a client-only edge peer).
351
+ */
352
+ listen?: string[];
353
+ /**
354
+ * The node's own signing key. Omit to let libp2p generate one internally
355
+ * — fine for a peer that never needs to prove that key again once the
356
+ * node exists. `createPeer` generates and RETAINS this key itself (never
357
+ * here) when it needs to mint tokens later, because `createLibp2p` never
358
+ * hands a generated key back out; if the caller doesn't keep the
359
+ * reference before calling this function, it is gone for good.
360
+ */
361
+ privateKey?: Ed25519PrivateKey;
362
+ /**
363
+ * The libp2p transports this node speaks. **Required, and deliberately so.**
364
+ *
365
+ * The prototype hard-coded `tcp()` here, which made importing this module at
366
+ * all pull a Node-only transport into a browser bundle — in a package whose
367
+ * root export is supposed to run on both. There is no sensible default: a
368
+ * server wants `tcp()`, a page wants `webRTC()` and `webSockets()`, and
369
+ * guessing produces a node that cannot dial anything on the platform it
370
+ * actually ran on.
371
+ *
372
+ * `./node` and `./browser` supply the profiles; this stays platform-free.
373
+ */
374
+ transports: TransportFactory[];
375
+ /**
376
+ * Extra libp2p services — `circuitRelayServer()` for a hub, say. Merged
377
+ * over the `identify` this always registers.
378
+ */
379
+ services?: Record<string, unknown>;
380
+ /** A connection gater, e.g. membership. */
381
+ connectionGater?: unknown;
382
+ }
383
+
384
+ /**
385
+ * A libp2p transport factory — `tcp()`, `webSockets()`, `webRTC()`.
386
+ *
387
+ * Typed loosely on purpose: naming libp2p's `Transport` here would drag the
388
+ * whole interface package into the signature for no gain, and every caller
389
+ * passes the result of a factory it already imported.
390
+ */
391
+ export type TransportFactory = (components: never) => unknown;
392
+
393
+ /**
394
+ * Build a libp2p node with the transport stack this package's tests and
395
+ * `createPeer`'s default construction path both rely on: TCP, Noise
396
+ * (connection encryption — this is what makes `ConnectionContext.remotePeer`
397
+ * trustworthy), Yamux (stream muxing) and identify (address exchange, so a
398
+ * peer dialled once by full multiaddr can be dialled again by bare peerId —
399
+ * see `createRemote`). Not the only file allowed to build a node makes this
400
+ * package's swappable-transport claim false, so this stays inside
401
+ * `transport-duplex.ts`; `peer.ts` calls it, never `createLibp2p` directly.
402
+ */
403
+ export async function createNode(init: CreateNodeInit): Promise<Libp2p> {
404
+ const { listen = [], privateKey, transports, services, connectionGater } = init;
405
+ if (transports.length === 0) {
406
+ throw new TypeError(
407
+ "createNode: at least one transport is required. Use `nodeTransports()` from " +
408
+ "`@statewalker/httpeers-libp2p/node` or `browserTransports()` from `./browser`.",
409
+ );
410
+ }
411
+ return createLibp2p({
412
+ ...(privateKey != null ? { privateKey } : {}),
413
+ ...(connectionGater != null ? { connectionGater } : {}),
414
+ addresses: { listen },
415
+ // biome-ignore lint/suspicious/noExplicitAny: libp2p's own factory types are not expressible without importing its whole component graph.
416
+ transports: transports as any,
417
+ connectionEncrypters: [noise()],
418
+ streamMuxers: [yamux()],
419
+ services: { identify: identify(), ...(services ?? {}) } as never,
420
+ });
421
+ }
422
+
423
+ export interface ServeTransportInit {
424
+ node: Libp2p;
425
+ /** The composed peer router — everything downstream of identity. */
426
+ dispatch: FetchHandler;
427
+ /** Defaults to `PROTOCOL`. */
428
+ protocol?: string;
429
+ /** Defaults to `DEFAULT_DRAIN_TIMEOUT_MS`. See that constant's doc comment. */
430
+ drainTimeoutMs?: number;
431
+ /** Defaults to `DEFAULT_MAX_STREAMS`. See that constant's doc comment. */
432
+ maxInboundStreams?: number;
433
+ /** Defaults to `DEFAULT_MAX_STREAMS`. See that constant's doc comment. */
434
+ maxOutboundStreams?: number;
435
+ /** Accept streams on limited connections too -- serving only. See `Libp2pLinkInit.serveOnLimitedConnection`. */
436
+ runOnLimitedConnection?: boolean;
437
+ }
438
+
439
+ /**
440
+ * Register the httpeers protocol handler on `node`. Every inbound stream
441
+ * gets its own `Duplex`, closing over that stream's proven `remotePeer` —
442
+ * `registerPeer` runs before `dispatch` is ever called, so every handler
443
+ * downstream (the binding middleware first) sees the identity the transport
444
+ * itself proved, never anything the peer merely claims.
445
+ *
446
+ * Returns the teardown function `serveConnections` itself returns
447
+ * (`node.unhandle` for this protocol).
448
+ */
449
+ export async function serveTransport(init: ServeTransportInit): Promise<() => Promise<void>> {
450
+ // DELEGATES. The identity binding and the fetch-over-duplex plumbing moved
451
+ // to `@statewalker/httpeers-bridge`, which knows nothing about libp2p; what
452
+ // is left here is `libp2pLink`, the answer to "how do I accept a duplex".
453
+ // Keeping a second copy of the serving logic beside the generic one is
454
+ // exactly the duplication this extraction exists to remove.
455
+ return serveFetchOverLink({
456
+ link: libp2pLink({
457
+ node: init.node,
458
+ protocol: init.protocol,
459
+ drainTimeoutMs: init.drainTimeoutMs,
460
+ maxInboundStreams: init.maxInboundStreams,
461
+ maxOutboundStreams: init.maxOutboundStreams,
462
+ serveOnLimitedConnection: init.runOnLimitedConnection,
463
+ }),
464
+ dispatch: init.dispatch,
465
+ });
466
+ }
467
+
468
+ export interface CreateRemoteInit {
469
+ node: Libp2p;
470
+ /** Defaults to `PROTOCOL`. */
471
+ protocol?: string;
472
+ /** Defaults to `DEFAULT_DRAIN_TIMEOUT_MS`. See that constant's doc comment. */
473
+ drainTimeoutMs?: number;
474
+ /**
475
+ * Defaults to `DEFAULT_MAX_STREAMS`. Set here too, not only on the serving
476
+ * side: a peer both serves and dials (a relay forwards by dialing out), so
477
+ * the outbound cap needs the same headroom as the inbound one.
478
+ */
479
+ maxOutboundStreams?: number;
480
+ /**
481
+ * Defaults to `DEFAULT_REQUEST_TIMEOUT_MS`. See that constant's doc
482
+ * comment for the value, the contract it states, and its relationship to
483
+ * `drainTimeoutMs`.
484
+ */
485
+ requestTimeoutMs?: number;
486
+ /**
487
+ * T-3 (Task 18): the width of this `Remote`'s outbound admission
488
+ * semaphore. Defaults to `DEFAULT_MAX_CONCURRENT_OUTBOUND`. See that
489
+ * constant's doc comment for the full contract -- what "width" bounds
490
+ * (this `Remote`'s total in-flight calls, across every target peer, not
491
+ * per-connection), and why queueing past it shares `requestTimeoutMs`
492
+ * rather than getting its own timer.
493
+ */
494
+ maxConcurrentOutbound?: number;
495
+ /** Open streams on limited connections to the peers this allows. See `CallOnLimitedConnection`. */
496
+ callOnLimitedConnection?: CallOnLimitedConnection;
497
+ }
498
+
499
+ /**
500
+ * Build the `Remote` seam `createPeerRouter` forwards through: dial `target`
501
+ * over libp2p and run the request over the resulting `Duplex`. Outbound is
502
+ * definitionally identity-free — nothing here registers a peer binding,
503
+ * because the *caller's* proven identity has no meaning on a connection *we*
504
+ * initiated.
505
+ *
506
+ * Every rejection from the returned function is a `PeerCallError` (see
507
+ * `errors.ts` and `mapPeerCallError` above) — never a raw libp2p exception,
508
+ * and never an unbounded wait: the whole queue-dial-negotiate-respond
509
+ * sequence (T-3/Task 18 added the queueing step) is raced against
510
+ * `requestTimeoutMs`.
511
+ *
512
+ * ONE `Semaphore` PER `Remote`, NOT PER CALL: `createRemote` is called once
513
+ * per `Peer` (see `peer.ts`'s `createPeer`), so the semaphore built here is
514
+ * that peer's single, shared outbound admission gate for as long as the
515
+ * `Peer` lives -- every call through this `Remote`, to every target,
516
+ * competes for the same `maxConcurrentOutbound` permits. See
517
+ * `DEFAULT_MAX_CONCURRENT_OUTBOUND`'s doc comment for why that scope (global
518
+ * to this `Remote`, not per-target-peer) was chosen.
519
+ */
520
+ export function createRemote(init: CreateRemoteInit): Remote {
521
+ // DELEGATES, for the same reason as `serveTransport`. The permit, the
522
+ // per-call timeout and the cleanup are the bridge's; `mapPeerCallError` stays
523
+ // here because the strings it matches ("All multiaddr dials failed" and
524
+ // friends) are libp2p's own, and a generic bridge that pattern-matched one
525
+ // transport's wording would mis-classify every other one.
526
+ return createRemoteOverLink({
527
+ link: libp2pLink({
528
+ node: init.node,
529
+ protocol: init.protocol,
530
+ drainTimeoutMs: init.drainTimeoutMs,
531
+ maxOutboundStreams: init.maxOutboundStreams,
532
+ callOnLimitedConnection: init.callOnLimitedConnection,
533
+ }),
534
+ requestTimeoutMs: init.requestTimeoutMs,
535
+ maxConcurrentOutbound: init.maxConcurrentOutbound,
536
+ mapError: (error, target) => mapPeerCallError(error, target),
537
+ });
538
+ }