@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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/browser.d.ts +33 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +77 -0
- package/dist/duplex.d.ts +86 -0
- package/dist/duplex.d.ts.map +1 -0
- package/dist/duplex.js +101 -0
- package/dist/hop-reserve.d.ts +105 -0
- package/dist/hop-reserve.d.ts.map +1 -0
- package/dist/hop-reserve.js +269 -0
- package/dist/hub-link.d.ts +131 -0
- package/dist/hub-link.d.ts.map +1 -0
- package/dist/hub-link.js +217 -0
- package/dist/hub-relay.d.ts +129 -0
- package/dist/hub-relay.d.ts.map +1 -0
- package/dist/hub-relay.js +154 -0
- package/dist/identity.d.ts +84 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +91 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/link.d.ts +49 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +88 -0
- package/dist/multiaddr-parts.d.ts +21 -0
- package/dist/multiaddr-parts.d.ts.map +1 -0
- package/dist/multiaddr-parts.js +27 -0
- package/dist/node.d.ts +33 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +60 -0
- package/dist/reservation.d.ts +245 -0
- package/dist/reservation.d.ts.map +1 -0
- package/dist/reservation.js +396 -0
- package/dist/serve-peer.d.ts +91 -0
- package/dist/serve-peer.d.ts.map +1 -0
- package/dist/serve-peer.js +68 -0
- package/dist/timers.d.ts +36 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +38 -0
- package/dist/transport.d.ts +314 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +410 -0
- package/package.json +73 -0
- package/src/browser.ts +118 -0
- package/src/duplex.ts +172 -0
- package/src/hop-reserve.ts +295 -0
- package/src/hub-link.ts +281 -0
- package/src/hub-relay.ts +188 -0
- package/src/identity.ts +155 -0
- package/src/index.ts +114 -0
- package/src/link.ts +143 -0
- package/src/multiaddr-parts.ts +29 -0
- package/src/node.ts +63 -0
- package/src/reservation.ts +564 -0
- package/src/serve-peer.ts +141 -0
- package/src/timers.ts +47 -0
- 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
|
+
}
|