@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,269 @@
1
+ /**
2
+ * Asking the RELAY whether this node is still reserved -- and renewing the
3
+ * reservation in the same breath.
4
+ *
5
+ * THE QUESTION THIS MODULE EXISTS TO ANSWER. `node.getMultiaddrs()` reports
6
+ * what the node's own transport reservation store believes, and in the
7
+ * 2026-09-19 incident that belief was wrong for hours: the relay held no
8
+ * reservation, the websocket was still up, every member got
9
+ * `NO_RESERVATION`, and the hub saw nothing to repair. The only authority on
10
+ * "is there a reservation" is the relay, so this asks it, over the wire.
11
+ *
12
+ * WHY NOT LIBP2P'S OWN CALL. `@libp2p/circuit-relay-v2`'s transport keeps a
13
+ * `ReservationStore` whose `addRelay(peer, type)` is the renewal entry point,
14
+ * and it is unusable as a probe for three separate reasons:
15
+ *
16
+ * 1. It SHORT-CIRCUITS. If it still holds an entry, the connection is open
17
+ * and the recorded expiry is more than ten minutes away, it returns the
18
+ * cached entry without sending a byte -- so a proactive call answers from
19
+ * the very belief we are trying to check.
20
+ * 2. It is NOT REACHABLE. The store is a private component of the transport;
21
+ * nothing on `Libp2p` exposes it.
22
+ * 3. Its failure paths are SURPRISING. A dial or protocol failure puts the
23
+ * relay in a "previously invalid" cuckoo filter (`relayFilter`) so later
24
+ * attempts are refused locally, and a discovered reservation can be
25
+ * refused with `HadEnoughRelaysError` when no pending slot is free.
26
+ *
27
+ * So we speak HOP ourselves. A `RESERVE` request on the relay's own protocol
28
+ * is two bytes, and the answer is the relay's, not ours.
29
+ *
30
+ * IT REUSES THE SLOT, IT DOES NOT LEAK A SECOND ONE. The relay's reservation
31
+ * store is keyed by peer id: `reserve()` finds the existing entry, resets its
32
+ * expiry signal in place and returns OK (see `server/reservation-store.js` in
33
+ * the installed 4.2.11). A peer cannot hold two reservations on one relay, so
34
+ * "renew" and "re-reserve" are the same request -- which is also why this one
35
+ * call both DETECTS a loss and REPAIRS it.
36
+ *
37
+ * IT DOES NOT DISTURB LIVE CIRCUITS. It opens one short-lived stream on a
38
+ * connection that already exists; already-established circuits are separate
39
+ * connections and are untouched, and the relay's refresh path does not close
40
+ * anything.
41
+ *
42
+ * THE CODEC IS HAND-WRITTEN, four fields wide, because it has to be:
43
+ * `@libp2p/circuit-relay-v2` publishes only `.` in its `exports` map, so its
44
+ * generated `pb/index.js` cannot be imported, and vendoring protons to
45
+ * regenerate a message we send ONE variant of would be more code than this.
46
+ * The wire format is pinned by `tests/hop-reserve.test.ts` against bytes taken
47
+ * from that generated encoder, and end to end by `tests/reservation-renewal.test.ts`
48
+ * against a real relay -- which is the check that actually matters, since a
49
+ * codec that agrees only with its own tests proves nothing.
50
+ */
51
+ import { RELAY_V2_HOP_CODEC } from "@libp2p/circuit-relay-v2";
52
+ import { peerIdFromString } from "@libp2p/peer-id";
53
+ import { pbStream } from "@libp2p/utils";
54
+ /** circuit-relay v2 `Status`, by number. The relay answers one of these. */
55
+ export const HOP_STATUS_NAMES = {
56
+ 0: "UNUSED",
57
+ 100: "OK",
58
+ 200: "RESERVATION_REFUSED",
59
+ 201: "RESOURCE_LIMIT_EXCEEDED",
60
+ 202: "PERMISSION_DENIED",
61
+ 203: "CONNECTION_FAILED",
62
+ 204: "NO_RESERVATION",
63
+ 400: "MALFORMED_MESSAGE",
64
+ 401: "UNEXPECTED_MESSAGE",
65
+ };
66
+ /** `Status.OK`. */
67
+ export const HOP_STATUS_OK = 100;
68
+ /**
69
+ * `HopMessage{ type: RESERVE }` on the wire: field 1, varint, value 0.
70
+ *
71
+ * protobuf3 would normally omit a zero-valued field; protons generates
72
+ * `if (obj.type != null) { w.uint32(8); ... }` for this `optional` field, so
73
+ * the tag IS written and a relay that requires it (go-libp2p does) sees it.
74
+ * Constant, because every request this module sends is this one.
75
+ */
76
+ const RESERVE_REQUEST = Uint8Array.from([0x08, 0x00]);
77
+ /** How long one RESERVE round trip may take, matching libp2p's own `DEFAULT_RESERVATION_COMPLETION_TIMEOUT`. */
78
+ export const HOP_RESERVE_TIMEOUT_MS = 5_000;
79
+ /** The relay answered, and the answer was not OK. `status` is its own `Status` number. */
80
+ export class RelayReservationRefusedError extends Error {
81
+ relayPeerId;
82
+ status;
83
+ constructor(relayPeerId, status) {
84
+ super(`hop: the relay (${relayPeerId}) refused a reservation with ` +
85
+ `${HOP_STATUS_NAMES[status] ?? `status ${status}`}`);
86
+ this.relayPeerId = relayPeerId;
87
+ this.status = status;
88
+ this.name = "RelayReservationRefusedError";
89
+ }
90
+ }
91
+ /** There is no connection this node could ask the relay over. Not a refusal: nothing was asked. */
92
+ export class RelayNotConnectedError extends Error {
93
+ relayPeerId;
94
+ constructor(relayPeerId) {
95
+ super(`hop: no open, unlimited connection to the relay (${relayPeerId}) to ask for a reservation over`);
96
+ this.relayPeerId = relayPeerId;
97
+ this.name = "RelayNotConnectedError";
98
+ }
99
+ }
100
+ /**
101
+ * Read the fields of a `HopMessage` this module needs, skipping the rest.
102
+ *
103
+ * Deliberately lenient about everything it does not use -- an unknown field,
104
+ * a voucher, a limit -- because a decoder that throws on a field a future
105
+ * relay adds would turn a healthy relay into a lost reservation.
106
+ */
107
+ export function decodeHopAnswer(data) {
108
+ const bytes = data instanceof Uint8Array ? data : data.subarray();
109
+ const answer = {};
110
+ const reader = new Reader(bytes);
111
+ while (reader.hasMore()) {
112
+ const tag = reader.varint();
113
+ const field = tag >>> 3;
114
+ const wire = tag & 7;
115
+ if (field === 1 && wire === 0)
116
+ answer.type = reader.varint();
117
+ else if (field === 5 && wire === 0)
118
+ answer.status = reader.varint();
119
+ else if (field === 3 && wire === 2) {
120
+ const reservation = new Reader(reader.bytes());
121
+ while (reservation.hasMore()) {
122
+ const innerTag = reservation.varint();
123
+ if (innerTag >>> 3 === 1 && (innerTag & 7) === 0)
124
+ answer.expire = reservation.varint();
125
+ else
126
+ reservation.skip(innerTag & 7);
127
+ }
128
+ }
129
+ else
130
+ reader.skip(wire);
131
+ }
132
+ return answer;
133
+ }
134
+ /**
135
+ * Request (or renew) this node's reservation on `relayPeerId`, and return what
136
+ * the relay granted. Throws `RelayReservationRefusedError` on any non-OK
137
+ * status, `RelayNotConnectedError` when there is nothing to ask over, and
138
+ * whatever the stream threw otherwise. Every one of those means "not reserved
139
+ * as far as the relay is concerned", which is the only judgement the caller
140
+ * needs.
141
+ */
142
+ export async function requestRelayReservation(node, relayPeerId, options = {}) {
143
+ const connection = openConnectionTo(node, relayPeerId);
144
+ if (connection == null)
145
+ throw new RelayNotConnectedError(relayPeerId);
146
+ const signal = options.signal ?? AbortSignal.timeout(HOP_RESERVE_TIMEOUT_MS);
147
+ const stream = await connection.newStream(RELAY_V2_HOP_CODEC, { signal });
148
+ let answer;
149
+ try {
150
+ const hop = pbStream(framed(stream)).pb({
151
+ encode: () => RESERVE_REQUEST,
152
+ decode: decodeHopAnswer,
153
+ });
154
+ await hop.write({}, { signal });
155
+ answer = await hop.read({ signal });
156
+ }
157
+ catch (error) {
158
+ stream.abort(error);
159
+ throw error;
160
+ }
161
+ // Closing the write end is enough and is not awaited past its own timeout:
162
+ // the answer is already in hand, and a relay that is slow to acknowledge the
163
+ // close must not turn a granted reservation into a reported loss.
164
+ await stream.close({ signal }).catch(() => stream.abort(new Error("hop: close failed")));
165
+ if (answer.status !== HOP_STATUS_OK) {
166
+ throw new RelayReservationRefusedError(relayPeerId, answer.status ?? 0);
167
+ }
168
+ const expiresAt = answer.expire != null ? answer.expire * 1_000 : null;
169
+ return { expiresAt, ttlMs: expiresAt != null ? expiresAt - Date.now() : null };
170
+ }
171
+ /**
172
+ * A ONE-VERSION TYPE GAP, NOT A RUNTIME ONE.
173
+ *
174
+ * This workspace pins `@libp2p/interface` at 3.2.5 everywhere, and
175
+ * `@libp2p/utils` requires `^3.3.0` — whose `MessageStream` grew a
176
+ * `readableEnded` property. `pbStream` therefore refuses a 3.2.5 `Stream` by
177
+ * type while accepting it perfectly at runtime: the property is implemented by
178
+ * `AbstractMessageStream`, which every real libp2p stream extends, and
179
+ * `stream-utils.js` never reads it in the first place (checked in the
180
+ * installed 7.4.1). The narrow cast is the honest statement of that; widening
181
+ * the pin across seven packages to satisfy one call site would be a much
182
+ * larger change for the same behaviour.
183
+ */
184
+ function framed(stream) {
185
+ return stream;
186
+ }
187
+ /**
188
+ * A connection to the relay a reservation may be requested over: open, and not
189
+ * itself relayed.
190
+ *
191
+ * NOT RELAYED, because a relay refuses to reserve over a circuit
192
+ * (libp2p calls it `DoubleRelayError` on its own side), and a limited
193
+ * connection would in any case be spent on the attempt. `limits != null` is
194
+ * how libp2p marks one.
195
+ */
196
+ function openConnectionTo(node, relayPeerId) {
197
+ let peerId;
198
+ try {
199
+ peerId = peerIdFromString(relayPeerId);
200
+ }
201
+ catch {
202
+ return undefined;
203
+ }
204
+ return node
205
+ .getConnections(peerId)
206
+ .find((connection) => connection.status === "open" && connection.limits == null);
207
+ }
208
+ /** A protobuf reader for the handful of field types `HopMessage` uses. */
209
+ class Reader {
210
+ buf;
211
+ pos = 0;
212
+ constructor(buf) {
213
+ this.buf = buf;
214
+ }
215
+ hasMore() {
216
+ return this.pos < this.buf.length;
217
+ }
218
+ /**
219
+ * An unsigned varint, as a `number`.
220
+ *
221
+ * Assembled with multiplication rather than `<<`, because `<<` is a 32-bit
222
+ * operation in JavaScript and `Reservation.expire` is a uint64 of Unix
223
+ * seconds -- a shift would wrap it into a negative number and turn a valid
224
+ * expiry into nonsense. Ten bytes is the maximum a uint64 can occupy.
225
+ */
226
+ varint() {
227
+ let value = 0;
228
+ let shift = 1;
229
+ for (let i = 0; i < 10; i++) {
230
+ if (!this.hasMore())
231
+ throw new Error("hop: truncated varint");
232
+ const byte = this.buf[this.pos++];
233
+ value += (byte & 0x7f) * shift;
234
+ if ((byte & 0x80) === 0)
235
+ return value;
236
+ shift *= 128;
237
+ }
238
+ throw new Error("hop: varint longer than 10 bytes");
239
+ }
240
+ /** A length-delimited field's payload. */
241
+ bytes() {
242
+ const length = this.varint();
243
+ const end = this.pos + length;
244
+ if (end > this.buf.length)
245
+ throw new Error("hop: truncated length-delimited field");
246
+ const slice = this.buf.subarray(this.pos, end);
247
+ this.pos = end;
248
+ return slice;
249
+ }
250
+ /** Step over a field of the given wire type. */
251
+ skip(wire) {
252
+ switch (wire) {
253
+ case 0:
254
+ this.varint();
255
+ return;
256
+ case 1:
257
+ this.pos += 8;
258
+ return;
259
+ case 2:
260
+ this.bytes();
261
+ return;
262
+ case 5:
263
+ this.pos += 4;
264
+ return;
265
+ default:
266
+ throw new Error(`hop: unsupported protobuf wire type ${wire}`);
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A member's link to its hub: reach it, reserve on it, and address other
3
+ * members through it. See `./hub-relay.ts` for the hub's side and why.
4
+ *
5
+ * TRANSPORT-NEUTRAL, like `./reservation.ts`: nothing here is browser-only,
6
+ * so the same code runs in a page and under the Node tests.
7
+ */
8
+ import type { Connection, Libp2p } from "@libp2p/interface";
9
+ import { type RelaySupervisor } from "./reservation.js";
10
+ /**
11
+ * The address a member dials another member at: through their hub, with a
12
+ * WebRTC upgrade. The `/webrtc` step is what turns the hub's circuit into a
13
+ * signalling channel -- dialled without it, the result is a limited
14
+ * connection through the hub, which carries nothing.
15
+ *
16
+ * COMPOSED, NEVER TAKEN FROM A PEER'S OWN ADDRESSES. A reservation on the
17
+ * hub makes libp2p report a double-circuit address
18
+ * (`<relay>/p2p-circuit/webrtc/p2p/<hub>/p2p-circuit/p2p/<member>`) that
19
+ * nothing can dial. This works because the circuit transport reuses the
20
+ * connection it already holds to the hub.
21
+ */
22
+ export declare function hubRoute(hubPeerId: string, peerId: string): string;
23
+ /**
24
+ * Reach the hub over WebRTC through the public relay, and keep ONLY the
25
+ * WebRTC connection.
26
+ *
27
+ * WHY THE SIGNALLING CIRCUIT IS CLOSED. Reaching the hub leaves two
28
+ * connections to it: the limited circuit through the public relay that
29
+ * carried the SDP exchange, and the WebRTC one. The circuit transport relays
30
+ * through `getConnections(relay)[0]` -- the FIRST connection to the relay,
31
+ * limited or not -- so while the limited one is open, every dial THROUGH the
32
+ * hub fails with `LimitedConnectionError`. Found by the `proto/hub-relay`
33
+ * prototype; the "connects two members directly" test fails without it.
34
+ */
35
+ export declare function reachHub(node: Libp2p, relayAddr: string, hubPeerId: string): Promise<void>;
36
+ /**
37
+ * Reach the hub through the public relay WITHOUT the WebRTC upgrade, and KEEP
38
+ * the circuit: the fallback for when `reachHub` cannot work (a hub in Docker on
39
+ * a bridge network has nothing a WebRTC dial can reach).
40
+ *
41
+ * The result is a LIMITED connection, and nothing crosses it unless both ends
42
+ * opt in: the hub serves with `serveOnLimitedConnection: true`, and the member
43
+ * calls with `callOnLimitedConnection` allowing the hub (`servePeer`,
44
+ * `createRemote`), which then opens its streams on this connection rather than
45
+ * dialling again.
46
+ *
47
+ * RETURNED AS LIBP2P RETURNS IT, and not always limited: if an unlimited
48
+ * connection to the hub already exists (a WebRTC upgrade that did come up),
49
+ * `dial` hands that back instead of opening a circuit. Check `limits` if it
50
+ * matters; the intended caller only gets here after `reachHub` failed, or
51
+ * after it succeeded but the hub refused a reservation -- and then hangs up
52
+ * the WebRTC connection first (`startMember`).
53
+ *
54
+ * NOT CLOSED, unlike `reachHub`'s circuit, because it is the data path. Two
55
+ * consequences for the caller: `reserveOnHub` has nothing to reserve over, so
56
+ * other members cannot reach this one; and `leaveRelay` would hang up the
57
+ * circuit itself.
58
+ */
59
+ export declare function reachHubRelayed(node: Libp2p, relayAddr: string, hubPeerId: string): Promise<Connection>;
60
+ /**
61
+ * Why a hub did not grant a reservation, as far as libp2p lets us tell.
62
+ *
63
+ * - `"store-full"` -- `RESERVATION_REFUSED`: the hub's relay already holds
64
+ * as many reservations as it grants (libp2p's `maxReservations`). A
65
+ * capacity condition on the hub; nothing about this peer.
66
+ * - `"resource-limit"` -- `RESOURCE_LIMIT_EXCEEDED`: the same kind of thing,
67
+ * from a relay that meters resources rather than counting reservations.
68
+ * - `"not-a-member"` -- `PERMISSION_DENIED`: the hub's `membershipGater`
69
+ * does not count this peer as a member. (libp2p also answers it to a
70
+ * reservation asked for over a relayed connection, which `reserveOnHub`
71
+ * never does: it runs over `reachHub`'s WebRTC connection.)
72
+ * - `"no-relay"` -- the hub does not speak the relay protocol at all
73
+ * (`UnsupportedProtocolError`): a hub deployed without `hubRelayService`.
74
+ * - `"no-answer"` -- no status came back: a timeout, a link that dropped
75
+ * mid-request. Nothing was refused; nothing was granted either.
76
+ * - `"other"` -- any other status, named verbatim in the message.
77
+ */
78
+ export type HubReservationRefusal = "store-full" | "resource-limit" | "not-a-member" | "no-relay" | "no-answer" | "other";
79
+ /** `reserveOnHub`'s failure: which refusal, the relay's own status when it sent one, and libp2p's error as `cause`. */
80
+ export declare class HubReservationError extends Error {
81
+ readonly hubPeerId: string;
82
+ /** The circuit-relay v2 status the hub answered (`"RESERVATION_REFUSED"`, ...), or `null` when it answered none. */
83
+ readonly status: string | null;
84
+ readonly refusal: HubReservationRefusal;
85
+ constructor(hubPeerId: string,
86
+ /** The circuit-relay v2 status the hub answered (`"RESERVATION_REFUSED"`, ...), or `null` when it answered none. */
87
+ status: string | null, refusal: HubReservationRefusal, options: {
88
+ cause: unknown;
89
+ });
90
+ }
91
+ /**
92
+ * Reserve on the hub, over the WebRTC connection `reachHub` left open.
93
+ * Returns the reserved address. Only a member is granted one -- call this
94
+ * after the hub has accepted this peer, not before.
95
+ *
96
+ * A REFUSAL IS A `HubReservationError` that names the relay's own status --
97
+ * see `HubReservationRefusal`. libp2p reports every refusal alike, as "Some
98
+ * configured addresses failed to be listened on"; a full store and a
99
+ * non-member used to read the same, and in production a full store was
100
+ * diagnosed as a membership problem.
101
+ *
102
+ * A CONFIGURED RELAY, NOT A DISCOVERED ONE: libp2p will not restore it on
103
+ * its own if the link to the hub drops, which is `superviseHubReservation`'s
104
+ * job, below. Each call adds a listener; after a lost link -- or a refused
105
+ * reservation -- the old one sits empty. That is one small object per
106
+ * attempt, accepted rather than reaching into libp2p's internals to reuse it.
107
+ */
108
+ export declare function reserveOnHub(node: Libp2p, hubPeerId: string): Promise<string>;
109
+ /**
110
+ * Close this member's connection to the public relay, once its hub link is
111
+ * up. A member holds no reservation there any more, and other members reach
112
+ * it through the hub, so the socket would only spend the relay's capacity.
113
+ * It is reopened on demand: `reachHub` dials through the relay whenever the
114
+ * hub has to be reached again.
115
+ */
116
+ export declare function leaveRelay(node: Libp2p, relayAddr: string): Promise<void>;
117
+ export interface SuperviseHubReservationInit {
118
+ node: Libp2p;
119
+ /** The public relay the hub is reached through -- `reachHub`'s. */
120
+ relayAddr: string;
121
+ hubPeerId: string;
122
+ minRetryDelayMs?: number;
123
+ maxRetryDelayMs?: number;
124
+ }
125
+ /**
126
+ * Keep this member's reservation on its hub: whenever it is lost, reach the
127
+ * hub again and re-reserve, with `superviseRelay`'s backoff and `poke()`.
128
+ * Call once the first `reserveOnHub` has succeeded.
129
+ */
130
+ export declare function superviseHubReservation(init: SuperviseHubReservationInit): RelaySupervisor;
131
+ //# sourceMappingURL=hub-link.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hub-link.d.ts","sourceRoot":"","sources":["../src/hub-link.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAI5D,OAAO,EAAE,KAAK,eAAe,EAAkB,MAAM,kBAAkB,CAAC;AAExE;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhG;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAErB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,qBAAqB,GAC7B,YAAY,GACZ,gBAAgB,GAChB,cAAc,GACd,UAAU,GACV,WAAW,GACX,OAAO,CAAC;AAEZ,uHAAuH;AACvH,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,oHAAoH;IACpH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAC9B,QAAQ,CAAC,OAAO,EAAE,qBAAqB;IAJzC,YACW,SAAS,EAAE,MAAM;IAC1B,oHAAoH;IAC3G,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,OAAO,EAAE,qBAAqB,EACvC,OAAO,EAAE;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,EAI5B;CACF;AA2ED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAenF;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG/E;AAED,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,2BAA2B,GAAG,eAAe,CAY1F"}
@@ -0,0 +1,217 @@
1
+ /**
2
+ * A member's link to its hub: reach it, reserve on it, and address other
3
+ * members through it. See `./hub-relay.ts` for the hub's side and why.
4
+ *
5
+ * TRANSPORT-NEUTRAL, like `./reservation.ts`: nothing here is browser-only,
6
+ * so the same code runs in a page and under the Node tests.
7
+ */
8
+ import { peerIdFromString } from "@libp2p/peer-id";
9
+ import { multiaddr } from "@multiformats/multiaddr";
10
+ import { lastPeerIdOf } from "./multiaddr-parts.js";
11
+ import { superviseRelay } from "./reservation.js";
12
+ /**
13
+ * The address a member dials another member at: through their hub, with a
14
+ * WebRTC upgrade. The `/webrtc` step is what turns the hub's circuit into a
15
+ * signalling channel -- dialled without it, the result is a limited
16
+ * connection through the hub, which carries nothing.
17
+ *
18
+ * COMPOSED, NEVER TAKEN FROM A PEER'S OWN ADDRESSES. A reservation on the
19
+ * hub makes libp2p report a double-circuit address
20
+ * (`<relay>/p2p-circuit/webrtc/p2p/<hub>/p2p-circuit/p2p/<member>`) that
21
+ * nothing can dial. This works because the circuit transport reuses the
22
+ * connection it already holds to the hub.
23
+ */
24
+ export function hubRoute(hubPeerId, peerId) {
25
+ return `/p2p/${hubPeerId}/p2p-circuit/webrtc/p2p/${peerId}`;
26
+ }
27
+ /**
28
+ * Reach the hub over WebRTC through the public relay, and keep ONLY the
29
+ * WebRTC connection.
30
+ *
31
+ * WHY THE SIGNALLING CIRCUIT IS CLOSED. Reaching the hub leaves two
32
+ * connections to it: the limited circuit through the public relay that
33
+ * carried the SDP exchange, and the WebRTC one. The circuit transport relays
34
+ * through `getConnections(relay)[0]` -- the FIRST connection to the relay,
35
+ * limited or not -- so while the limited one is open, every dial THROUGH the
36
+ * hub fails with `LimitedConnectionError`. Found by the `proto/hub-relay`
37
+ * prototype; the "connects two members directly" test fails without it.
38
+ */
39
+ export async function reachHub(node, relayAddr, hubPeerId) {
40
+ await node.dial(multiaddr(`${relayAddr}/p2p-circuit/webrtc/p2p/${hubPeerId}`));
41
+ const limited = node.getConnections(peerIdFromString(hubPeerId)).filter((c) => c.limits != null);
42
+ await Promise.all(limited.map((c) => c.close()));
43
+ }
44
+ /**
45
+ * Reach the hub through the public relay WITHOUT the WebRTC upgrade, and KEEP
46
+ * the circuit: the fallback for when `reachHub` cannot work (a hub in Docker on
47
+ * a bridge network has nothing a WebRTC dial can reach).
48
+ *
49
+ * The result is a LIMITED connection, and nothing crosses it unless both ends
50
+ * opt in: the hub serves with `serveOnLimitedConnection: true`, and the member
51
+ * calls with `callOnLimitedConnection` allowing the hub (`servePeer`,
52
+ * `createRemote`), which then opens its streams on this connection rather than
53
+ * dialling again.
54
+ *
55
+ * RETURNED AS LIBP2P RETURNS IT, and not always limited: if an unlimited
56
+ * connection to the hub already exists (a WebRTC upgrade that did come up),
57
+ * `dial` hands that back instead of opening a circuit. Check `limits` if it
58
+ * matters; the intended caller only gets here after `reachHub` failed, or
59
+ * after it succeeded but the hub refused a reservation -- and then hangs up
60
+ * the WebRTC connection first (`startMember`).
61
+ *
62
+ * NOT CLOSED, unlike `reachHub`'s circuit, because it is the data path. Two
63
+ * consequences for the caller: `reserveOnHub` has nothing to reserve over, so
64
+ * other members cannot reach this one; and `leaveRelay` would hang up the
65
+ * circuit itself.
66
+ */
67
+ export async function reachHubRelayed(node, relayAddr, hubPeerId) {
68
+ return node.dial(multiaddr(`${relayAddr}/p2p-circuit/p2p/${hubPeerId}`));
69
+ }
70
+ /** `reserveOnHub`'s failure: which refusal, the relay's own status when it sent one, and libp2p's error as `cause`. */
71
+ export class HubReservationError extends Error {
72
+ hubPeerId;
73
+ status;
74
+ refusal;
75
+ constructor(hubPeerId,
76
+ /** The circuit-relay v2 status the hub answered (`"RESERVATION_REFUSED"`, ...), or `null` when it answered none. */
77
+ status, refusal, options) {
78
+ super(describeRefusal(hubPeerId, status, refusal, options.cause), options);
79
+ this.hubPeerId = hubPeerId;
80
+ this.status = status;
81
+ this.refusal = refusal;
82
+ this.name = "HubReservationError";
83
+ }
84
+ }
85
+ /**
86
+ * Read the refusal out of libp2p's error.
87
+ *
88
+ * FROM THE TEXT, BECAUSE THAT IS ALL THERE IS. The transport manager wraps
89
+ * every failed listen in one `UnsupportedListenAddressesError` whose message
90
+ * embeds the inner error's stack -- `reservation failed with status <STATUS>`
91
+ * from the reservation store, or `UnsupportedProtocolError: ...` from protocol
92
+ * negotiation -- and keeps no reference to the inner error itself. The
93
+ * patterns are pinned against a real hub by the conformance suite's
94
+ * `member-reservation-fallback.test.ts`.
95
+ */
96
+ function classify(err) {
97
+ const text = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ""}` : String(err);
98
+ const status = /reservation failed with status (\w+)/.exec(text)?.[1] ?? null;
99
+ switch (status) {
100
+ case "RESERVATION_REFUSED":
101
+ return { status, refusal: "store-full" };
102
+ case "RESOURCE_LIMIT_EXCEEDED":
103
+ return { status, refusal: "resource-limit" };
104
+ case "PERMISSION_DENIED":
105
+ return { status, refusal: "not-a-member" };
106
+ case null:
107
+ return {
108
+ status,
109
+ refusal: /UnsupportedProtocolError|could not negotiate/.test(text)
110
+ ? "no-relay"
111
+ : "no-answer",
112
+ };
113
+ default:
114
+ return { status, refusal: "other" };
115
+ }
116
+ }
117
+ function describeRefusal(hubPeerId, status, refusal, cause) {
118
+ const hub = `hub-link: the hub (${hubPeerId})`;
119
+ switch (refusal) {
120
+ case "store-full":
121
+ return (`${hub} refused a reservation with RESERVATION_REFUSED: the hub's reservation store is ` +
122
+ "full (it grants a fixed number, each held until it expires). Nothing is wrong with this " +
123
+ "peer; the hub has no slot for it.");
124
+ case "resource-limit":
125
+ return `${hub} refused a reservation with RESOURCE_LIMIT_EXCEEDED: the hub's relay is at a resource limit.`;
126
+ case "not-a-member":
127
+ return (`${hub} refused a reservation with PERMISSION_DENIED: this peer is not a member as far ` +
128
+ "as the hub is concerned (a hub grants reservations to its members only).");
129
+ case "no-relay":
130
+ return `${hub} does not relay at all -- it has no circuit-relay service (UnsupportedProtocolError).`;
131
+ case "no-answer":
132
+ return `${hub} gave no answer to a reservation request. Cause: ${firstLine(cause)}`;
133
+ case "other":
134
+ return `${hub} refused a reservation with ${status}.`;
135
+ }
136
+ }
137
+ /** The inner error libp2p quotes, or the error itself -- without the stack. */
138
+ function firstLine(err) {
139
+ const text = err instanceof Error ? err.message : String(err);
140
+ const lines = text.split("\n").map((line) => line.trim());
141
+ // libp2p's wrapper: a header line, then `<addr>: <inner error>` per address.
142
+ const inner = lines.find((line) => line.startsWith("/") && line.includes(": "));
143
+ return inner != null ? inner.slice(inner.indexOf(": ") + 2) : (lines[0] ?? "");
144
+ }
145
+ /**
146
+ * Reserve on the hub, over the WebRTC connection `reachHub` left open.
147
+ * Returns the reserved address. Only a member is granted one -- call this
148
+ * after the hub has accepted this peer, not before.
149
+ *
150
+ * A REFUSAL IS A `HubReservationError` that names the relay's own status --
151
+ * see `HubReservationRefusal`. libp2p reports every refusal alike, as "Some
152
+ * configured addresses failed to be listened on"; a full store and a
153
+ * non-member used to read the same, and in production a full store was
154
+ * diagnosed as a membership problem.
155
+ *
156
+ * A CONFIGURED RELAY, NOT A DISCOVERED ONE: libp2p will not restore it on
157
+ * its own if the link to the hub drops, which is `superviseHubReservation`'s
158
+ * job, below. Each call adds a listener; after a lost link -- or a refused
159
+ * reservation -- the old one sits empty. That is one small object per
160
+ * attempt, accepted rather than reaching into libp2p's internals to reuse it.
161
+ */
162
+ export async function reserveOnHub(node, hubPeerId) {
163
+ try {
164
+ await transportManagerOf(node).listen([multiaddr(`/p2p/${hubPeerId}/p2p-circuit`)]);
165
+ }
166
+ catch (err) {
167
+ const { status, refusal } = classify(err);
168
+ throw new HubReservationError(hubPeerId, status, refusal, { cause: err });
169
+ }
170
+ const reserved = node
171
+ .getMultiaddrs()
172
+ .map((addr) => addr.toString())
173
+ .find((addr) => addr.includes(`/p2p/${hubPeerId}/p2p-circuit`));
174
+ if (reserved == null) {
175
+ throw new Error(`hub-link: listening on the hub (${hubPeerId}) produced no reserved address`);
176
+ }
177
+ return reserved;
178
+ }
179
+ /**
180
+ * Close this member's connection to the public relay, once its hub link is
181
+ * up. A member holds no reservation there any more, and other members reach
182
+ * it through the hub, so the socket would only spend the relay's capacity.
183
+ * It is reopened on demand: `reachHub` dials through the relay whenever the
184
+ * hub has to be reached again.
185
+ */
186
+ export async function leaveRelay(node, relayAddr) {
187
+ const relayPeerId = lastPeerIdOf(multiaddr(relayAddr));
188
+ if (relayPeerId != null)
189
+ await node.hangUp(peerIdFromString(relayPeerId));
190
+ }
191
+ /**
192
+ * Keep this member's reservation on its hub: whenever it is lost, reach the
193
+ * hub again and re-reserve, with `superviseRelay`'s backoff and `poke()`.
194
+ * Call once the first `reserveOnHub` has succeeded.
195
+ */
196
+ export function superviseHubReservation(init) {
197
+ const { node, relayAddr, hubPeerId } = init;
198
+ return superviseRelay({
199
+ node,
200
+ relayAddr: `/p2p/${hubPeerId}`,
201
+ minRetryDelayMs: init.minRetryDelayMs,
202
+ maxRetryDelayMs: init.maxRetryDelayMs,
203
+ restore: async () => {
204
+ await reachHub(node, relayAddr, hubPeerId);
205
+ await reserveOnHub(node, hubPeerId);
206
+ },
207
+ });
208
+ }
209
+ /**
210
+ * The node's transport manager -- the only way to add a listen address after
211
+ * start. `components` is a public field of the libp2p 3.x node class but not
212
+ * part of the `Libp2p` interface, hence the narrow cast; pinned by
213
+ * `tests/e2e/hub-relay.test.ts`, which fails if a libp2p upgrade moves it.
214
+ */
215
+ function transportManagerOf(node) {
216
+ return node.components.transportManager;
217
+ }