@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,564 @@
1
+ /**
2
+ * Dialing the relay, and waiting for the reservation it grants afterwards.
3
+ *
4
+ * TRANSPORT-NEUTRAL ON PURPOSE, AND THAT IS WHY IT LIVES HERE. Both halves
5
+ * of this module touch nothing but `Libp2p.dial` and `Libp2p.getMultiaddrs`,
6
+ * so they are identical for a browser node (`browser/node-profile.ts`), a
7
+ * Node hub (`hub/node-profile.ts`) and a test peer (`tests/e2e/harness.ts`).
8
+ * They used to live in `browser/node-profile.ts`, whose top-level imports
9
+ * include `@libp2p/webrtc` -- which is why `tests/e2e/harness.ts` grew a
10
+ * second, hand-copied poll loop rather than import them (Task 14 reported
11
+ * that duplication as a finding). `@libp2p/webrtc` now loads under Node
12
+ * (its `node-datachannel` binary is installed, Task 20 Step 1), so the
13
+ * import would work today; the copy is still gone because ONE poll loop
14
+ * with one timing contract is the point, not because importing was
15
+ * impossible.
16
+ *
17
+ * READINESS IS POLLED, NOT AWAITED (design note 17 §4). `node.dial(relay)`
18
+ * resolving means the connection to the relay opened -- it says nothing
19
+ * about whether the relay has finished granting a reservation. That
20
+ * reservation lands asynchronously afterwards, so `waitForCircuitReservation`
21
+ * polls `getMultiaddrs()` until a `p2p-circuit` address actually appears, on
22
+ * the same schedule (250 ms x 40 attempts = 10 s ceiling) the validated
23
+ * relay test used (`prototypes/16-.../src/relay.test.ts`). Treating `dial`'s
24
+ * resolution as "ready" is exactly the race that note documents this project
25
+ * already got bitten by once.
26
+ */
27
+
28
+ import type { Libp2p } from "@libp2p/interface";
29
+ import { multiaddr } from "@multiformats/multiaddr";
30
+ import { type RelayReservationGrant, requestRelayReservation } from "./hop-reserve.js";
31
+ import { lastPeerIdOf } from "./multiaddr-parts.js";
32
+ import { globalTimers, type TimerHandle, type Timers } from "./timers.js";
33
+
34
+ /** Dial the relay named by `relayAddr` (`httpeers.json`'s `relayAddrs[0]`). Resolving means the link is up -- NOT that a circuit reservation exists yet; see `waitForCircuitReservation`. */
35
+ export async function dialRelay(node: Libp2p, relayAddr: string): Promise<void> {
36
+ await node.dial(multiaddr(relayAddr));
37
+ }
38
+
39
+ /** How often `waitForCircuitReservation` re-checks `getMultiaddrs()`. */
40
+ export const RESERVATION_POLL_INTERVAL_MS = 250;
41
+ /** How many times it checks before giving up -- 40 x 250 ms = 10 s, the ceiling the validated relay test (`relay.test.ts`) used. */
42
+ export const RESERVATION_POLL_ATTEMPTS = 40;
43
+
44
+ export interface WaitForCircuitReservationInit {
45
+ intervalMs?: number;
46
+ attempts?: number;
47
+ }
48
+
49
+ /**
50
+ * Poll `node.getMultiaddrs()` until a `p2p-circuit` address appears,
51
+ * returning it as a string. See this module's own "READINESS IS POLLED,
52
+ * NOT AWAITED" note for why this exists instead of trusting `dialRelay`'s
53
+ * resolution: the reservation is granted by the relay asynchronously,
54
+ * after the dial itself has already resolved.
55
+ */
56
+ export async function waitForCircuitReservation(
57
+ node: Libp2p,
58
+ init: WaitForCircuitReservationInit = {},
59
+ ): Promise<string> {
60
+ const intervalMs = init.intervalMs ?? RESERVATION_POLL_INTERVAL_MS;
61
+ const attempts = init.attempts ?? RESERVATION_POLL_ATTEMPTS;
62
+
63
+ for (let attempt = 0; attempt < attempts; attempt++) {
64
+ const found = node.getMultiaddrs().find((addr) => addr.toString().includes("p2p-circuit"));
65
+ if (found != null) return found.toString();
66
+ await new Promise<void>((resolve) => setTimeout(resolve, intervalMs));
67
+ }
68
+
69
+ throw new Error(
70
+ `reservation: no p2p-circuit reservation appeared within ${attempts * intervalMs}ms of dialing the relay -- ` +
71
+ "the relay may be unreachable, or its reservation limits already exhausted.",
72
+ );
73
+ }
74
+
75
+ /**
76
+ * WHAT `superviseRelay` NOW DOES, AND WHY IT IS NOT WHAT IT USED TO DO.
77
+ *
78
+ * The supervisor below used to answer "am I reserved?" from
79
+ * `node.getMultiaddrs()` -- the node's OWN belief, held by libp2p's transport
80
+ * reservation store. On 2026-09-19 that belief was wrong for hours: the relay
81
+ * held no reservation for the hub, the websocket to it was still ESTABLISHED,
82
+ * every member got `NO_RESERVATION`, and the supervisor saw a perfectly good
83
+ * `/p2p-circuit` address and did nothing. Only a container restart fixed it,
84
+ * and the root cause on the relay's side was never found -- which is the
85
+ * argument for repairing the CLASS of fault rather than that one cause.
86
+ *
87
+ * So the relay decides now. On a schedule well inside the reservation's TTL
88
+ * the supervisor asks the relay for a reservation over the connection it
89
+ * already holds (`./hop-reserve.ts`, which explains the wire-level choice).
90
+ * That single request is both the question and the repair: a relay's
91
+ * reservation store is keyed by peer, so the answer is OK whether it renewed
92
+ * an entry or created a missing one. The incident therefore heals on the next
93
+ * tick without anything having to notice it first -- and if the relay instead
94
+ * REFUSES, or cannot be asked at all, that is a loss, with the backoff and
95
+ * `restore` path below.
96
+ *
97
+ * THE LOCAL ADDRESS LIST STILL EARNS ITS KEEP, in one direction only: an
98
+ * address that has GONE is proof of a loss (libp2p removes it when the relay
99
+ * connection closes), and reacting to it turns a dropped link into a restore
100
+ * in milliseconds rather than at the next renewal. An address that is PRESENT
101
+ * proves nothing and can no longer put the supervisor back into `reserved` --
102
+ * `tests/reservation-supervisor.test.ts` pins exactly that asymmetry.
103
+ *
104
+ * NOTHING IS SWALLOWED. Every transition logs one line naming the relay and
105
+ * the reason. The old implementation had a bare `catch {}` around the whole
106
+ * restore, which is why the incident left no trace in the hub's own output.
107
+ */
108
+
109
+ /** Where a supervised reservation stands. `stopped` is terminal. */
110
+ export type RelayReservationStatus = "reserved" | "lost" | "stopped";
111
+
112
+ /** Every transition the supervisor logs. There is no "gave up": it retries forever, with a capped backoff, and the health signal is what escalates. */
113
+ export type RelayReservationEvent =
114
+ | "reserved"
115
+ | "renewed"
116
+ | "renewal-failed"
117
+ | "lost"
118
+ | "restoring"
119
+ | "restored"
120
+ | "restore-failed"
121
+ | "stopped";
122
+
123
+ /** A supervisor's current view, as `GET /hub/api/relay` reports it. */
124
+ export interface RelayReservationState {
125
+ status: RelayReservationStatus;
126
+ relayAddr: string;
127
+ /** The relay's peer id, or `null` when `relayAddr` names none. */
128
+ relayPeerId: string | null;
129
+ /** When the RELAY last confirmed the reservation (ms since the epoch); `null` until it first has. */
130
+ verifiedAt: number | null;
131
+ /** When the relay says the reservation expires (ms since the epoch); `null` when it did not say. */
132
+ expiresAt: number | null;
133
+ /** When the loss was first noticed (ms since the epoch); `null` while reserved. */
134
+ lostSince: number | null;
135
+ /** Renewals or restores that have failed in a row. Zero the moment the relay says OK. */
136
+ consecutiveFailures: number;
137
+ /** Relay-confirmed renewals since this supervisor started. */
138
+ renewals: number;
139
+ /** Recoveries completed since this supervisor started. */
140
+ restores: number;
141
+ /** The most recent failure, verbatim; kept after recovery so an operator can see what happened. */
142
+ lastError: string | null;
143
+ }
144
+
145
+ /** One line, plus the structured form of the same thing. */
146
+ export type RelayLog = (
147
+ message: string,
148
+ detail: { event: RelayReservationEvent; state: RelayReservationState },
149
+ ) => void;
150
+
151
+ export interface SuperviseRelayInit {
152
+ node: Libp2p;
153
+ /**
154
+ * The same string `dialRelay` was given. Its last `/p2p/<id>` names the
155
+ * relay whose reservation counts; with a custom `restore`, `/p2p/<id>`
156
+ * alone is enough. Without any `/p2p/<id>` there is nobody to ask, and
157
+ * proactive renewal is off.
158
+ */
159
+ relayAddr: string;
160
+ /**
161
+ * How to get the reservation back. Defaults to dialling `relayAddr` and
162
+ * waiting for libp2p to reserve on it, which is right for a relay libp2p
163
+ * DISCOVERED (a `/p2p-circuit` listen address). A CONFIGURED relay -- a
164
+ * member's reservation on its hub -- is never re-reserved by libp2p, so its
165
+ * restore has to ask again itself; see `./hub-link.ts`.
166
+ */
167
+ restore?: () => Promise<void>;
168
+ /** The first retry after a failed attempt. */
169
+ minRetryDelayMs?: number;
170
+ /** The ceiling the backoff grows to. */
171
+ maxRetryDelayMs?: number;
172
+ /**
173
+ * Ask the relay itself. Defaults to a circuit-relay v2 HOP `RESERVE` over
174
+ * the connection this node already holds (`requestRelayReservation`).
175
+ * `false` turns proactive renewal off entirely, which leaves only the
176
+ * address-disappeared detector -- i.e. the behaviour that missed the
177
+ * incident. It exists for tests, not for production.
178
+ */
179
+ verify?: ((options: { signal?: AbortSignal }) => Promise<RelayReservationGrant>) | false;
180
+ /**
181
+ * How long between renewals. Defaults to a quarter of the TTL the relay
182
+ * itself reported in its last grant -- see `renewalIntervalMs`.
183
+ */
184
+ renewalIntervalMs?: number;
185
+ /** The backstop check for a loss no event reported. */
186
+ checkIntervalMs?: number;
187
+ /** Defaults to the global timers; a browser tab should pass `worker-timers` (see `./timers.ts`). */
188
+ timers?: Timers;
189
+ /** Defaults to one `console.log` per transition. A silent supervisor is what hid the incident. */
190
+ log?: RelayLog;
191
+ now?: () => number;
192
+ random?: () => number;
193
+ }
194
+
195
+ export interface RelaySupervisor {
196
+ /** Re-check now, and if the reservation is gone try at once rather than wait out the backoff. */
197
+ poke(): void;
198
+ /** What the relay last said, and when. Safe to call at any time; a copy, not the live object. */
199
+ state(): RelayReservationState;
200
+ stop(): void;
201
+ }
202
+
203
+ /** How long one restore attempt waits for the reservation after its dial resolved -- 20 x 250 ms = 5 s. */
204
+ const RESTORE_POLL_ATTEMPTS = 20;
205
+
206
+ /** The backstop check, for a loss no event reported. */
207
+ const SUPERVISOR_CHECK_INTERVAL_MS = 10_000;
208
+
209
+ /** How soon after starting the supervisor first asks the relay. See `scheduleRenewal`. */
210
+ const INITIAL_VERIFY_DELAY_MS = 1_000;
211
+
212
+ /**
213
+ * The floor and ceiling on the renewal cadence, and the cadence used when the
214
+ * relay has not told us a TTL yet.
215
+ *
216
+ * A QUARTER OF THE TTL is the rule (`renewalIntervalMs`). Against the relay
217
+ * this project runs -- `apps/relay/src/limits.ts`, TTL two hours, restated
218
+ * there precisely so a library change is visible -- that is thirty minutes,
219
+ * and it means three consecutive renewals can fail before the reservation
220
+ * would lapse on its own. A half would leave room for one, which is no margin
221
+ * at all; an eighth would double the traffic for a margin nobody needs.
222
+ *
223
+ * The CEILING is the same thirty minutes, so a relay that one day advertises a
224
+ * longer TTL does not stretch the cadence with it: the incident was not a
225
+ * reservation that expired, it was one that vanished, and how fast that is
226
+ * repaired is bounded by this number, not by the TTL. The FLOOR keeps a relay
227
+ * with an implausibly short TTL from being asked every few seconds.
228
+ */
229
+ export const MIN_RENEWAL_INTERVAL_MS = 60_000;
230
+ export const MAX_RENEWAL_INTERVAL_MS = 30 * 60_000;
231
+ export const DEFAULT_RENEWAL_INTERVAL_MS = 20 * 60_000;
232
+
233
+ /** The renewal cadence for a reservation the relay says lasts `ttlMs`. See the constants above. */
234
+ export function renewalIntervalMs(ttlMs: number | null | undefined): number {
235
+ if (ttlMs == null || !(ttlMs > 0)) return DEFAULT_RENEWAL_INTERVAL_MS;
236
+ return Math.min(Math.max(ttlMs / 4, MIN_RENEWAL_INTERVAL_MS), MAX_RENEWAL_INTERVAL_MS);
237
+ }
238
+
239
+ /**
240
+ * The actual wait before the next renewal: `intervalMs` jittered DOWN into its
241
+ * upper quarter, never up.
242
+ *
243
+ * Down, because the interval is already the deadline -- stretching it past a
244
+ * quarter of the TTL would spend the margin the quarter exists to buy. The
245
+ * spread is what keeps a fleet of hubs that started together, or came back
246
+ * together after a relay restart, from asking the relay in the same instant
247
+ * for ever after.
248
+ */
249
+ export function renewalDelayMs(intervalMs: number, random: () => number = Math.random): number {
250
+ return intervalMs * 0.75 + intervalMs * 0.25 * random();
251
+ }
252
+
253
+ /**
254
+ * How long a lost reservation may go unrepaired before the hub should call
255
+ * itself unhealthy.
256
+ *
257
+ * TWO MINUTES. A restore normally completes in seconds: the loss is noticed
258
+ * either at once (the address disappears) or at the next renewal, the restore
259
+ * dials and waits at most five seconds for the reservation, and the retry
260
+ * backoff caps at thirty. Two minutes is therefore at least four attempts
261
+ * including capped retries -- long enough that a relay restart or a dropped
262
+ * link never shows up as an unhealthy appliance, short enough to be a small
263
+ * fraction of the two-hour TTL, so a hub that truly cannot get back is
264
+ * reported long before members would notice on their own.
265
+ */
266
+ export const RESERVATION_LOSS_GRACE_MS = 120_000;
267
+
268
+ /** Is this state healthy -- reserved, or lost for less than `graceMs`? See `RESERVATION_LOSS_GRACE_MS`. */
269
+ export function reservationHealthy(
270
+ state: Pick<RelayReservationState, "status" | "lostSince">,
271
+ graceMs: number = RESERVATION_LOSS_GRACE_MS,
272
+ now: number = Date.now(),
273
+ ): boolean {
274
+ if (state.status === "reserved") return true;
275
+ if (state.status === "stopped") return false;
276
+ return state.lostSince != null && now - state.lostSince < graceMs;
277
+ }
278
+
279
+ /**
280
+ * Keep this node's reservation on the relay at `relayAddr`: renew it against
281
+ * the relay on a schedule, and re-dial whenever it is lost. Call once the
282
+ * first reservation has landed.
283
+ */
284
+ export function superviseRelay(init: SuperviseRelayInit): RelaySupervisor {
285
+ const { node, relayAddr } = init;
286
+ const minDelayMs = init.minRetryDelayMs ?? 1_000;
287
+ const maxDelayMs = init.maxRetryDelayMs ?? 30_000;
288
+ const relayPeerId = lastPeerIdOf(multiaddr(relayAddr)) ?? null;
289
+ const timers = init.timers ?? globalTimers;
290
+ const now = init.now ?? Date.now;
291
+ const random = init.random ?? Math.random;
292
+ const log = init.log ?? defaultRelayLog;
293
+ const verify =
294
+ init.verify === false
295
+ ? undefined
296
+ : (init.verify ??
297
+ (relayPeerId != null
298
+ ? (options: { signal?: AbortSignal }) =>
299
+ requestRelayReservation(node, relayPeerId, options)
300
+ : undefined));
301
+
302
+ const state: RelayReservationState = {
303
+ status: "reserved",
304
+ relayAddr,
305
+ relayPeerId,
306
+ verifiedAt: null,
307
+ expiresAt: null,
308
+ lostSince: null,
309
+ consecutiveFailures: 0,
310
+ renewals: 0,
311
+ restores: 0,
312
+ lastError: null,
313
+ };
314
+
315
+ let retryTimer: TimerHandle;
316
+ let renewalTimer: TimerHandle;
317
+ let restoring = false;
318
+ let renewing = false;
319
+ let stopped = false;
320
+
321
+ const snapshot = (): RelayReservationState => ({ ...state });
322
+
323
+ const report = (event: RelayReservationEvent, reason?: string): void => {
324
+ const where = relayPeerId ?? relayAddr;
325
+ log(
326
+ `relay: ${event} on ${where}${reason != null ? ` -- ${reason}` : ""}` +
327
+ ` (failures=${state.consecutiveFailures}, renewals=${state.renewals}, restores=${state.restores})`,
328
+ { event, state: snapshot() },
329
+ );
330
+ };
331
+
332
+ /**
333
+ * The address check, used ONLY to prove a loss. `true` here means nothing:
334
+ * it is precisely the belief that was wrong during the incident.
335
+ */
336
+ const addressGone = (): boolean =>
337
+ !node.getMultiaddrs().some((addr) => {
338
+ const s = addr.toString();
339
+ return relayPeerId == null
340
+ ? s.includes("/p2p-circuit")
341
+ : s.includes(`/p2p/${relayPeerId}/p2p-circuit`);
342
+ });
343
+
344
+ const markReserved = (grant: RelayReservationGrant, event: RelayReservationEvent): void => {
345
+ state.status = "reserved";
346
+ state.verifiedAt = now();
347
+ state.expiresAt = grant.expiresAt;
348
+ state.lostSince = null;
349
+ state.consecutiveFailures = 0;
350
+ if (event === "renewed") state.renewals++;
351
+ if (event === "restored") state.restores++;
352
+ report(event);
353
+ };
354
+
355
+ const markLost = (reason: string): void => {
356
+ state.lastError = reason;
357
+ if (state.status === "lost") return;
358
+ state.status = "lost";
359
+ state.lostSince = now();
360
+ report("lost", reason);
361
+ };
362
+
363
+ const scheduleRetry = (delayMs: number): void => {
364
+ timers.clearTimeout(retryTimer);
365
+ retryTimer = timers.setTimeout(() => {
366
+ retryTimer = undefined;
367
+ void restore();
368
+ }, delayMs);
369
+ };
370
+
371
+ /**
372
+ * THE FIRST RENEWAL IS PROMPT, the rest are on the TTL's cadence.
373
+ *
374
+ * A supervisor is started the moment a reservation lands, so one request a
375
+ * second later costs a two-byte round trip and buys two things the caller
376
+ * cannot get otherwise: the relay's own expiry -- which is what every later
377
+ * interval is derived from, instead of the blind `DEFAULT_RENEWAL_INTERVAL_MS`
378
+ * -- and a `verifiedAt` an operator can read straight after a restart rather
379
+ * than twenty minutes into one.
380
+ */
381
+ const scheduleRenewal = (first = false): void => {
382
+ if (stopped || verify == null) return;
383
+ timers.clearTimeout(renewalTimer);
384
+ const interval = init.renewalIntervalMs ?? renewalIntervalMs(ttlOf(state, now()));
385
+ const delayMs =
386
+ first && init.renewalIntervalMs == null
387
+ ? INITIAL_VERIFY_DELAY_MS
388
+ : renewalDelayMs(interval, random);
389
+ renewalTimer = timers.setTimeout(() => {
390
+ renewalTimer = undefined;
391
+ void renew();
392
+ }, delayMs);
393
+ };
394
+
395
+ /**
396
+ * One relay-verified renewal. This is the whole point of the module: the
397
+ * request renews a reservation that is there and re-creates one that is not,
398
+ * so the ordinary case never reaches `restore` at all.
399
+ */
400
+ async function renew(): Promise<void> {
401
+ if (stopped || verify == null || renewing || restoring) {
402
+ scheduleRenewal();
403
+ return;
404
+ }
405
+ renewing = true;
406
+ try {
407
+ const grant = await verify({});
408
+ if (stopped) return;
409
+ markReserved(grant, state.status === "reserved" ? "renewed" : "restored");
410
+ } catch (error) {
411
+ if (stopped) return;
412
+ state.consecutiveFailures++;
413
+ const reason = messageOf(error);
414
+ state.lastError = reason;
415
+ report("renewal-failed", reason);
416
+ markLost(reason);
417
+ scheduleRetry(0);
418
+ } finally {
419
+ renewing = false;
420
+ scheduleRenewal();
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Get the reservation back the hard way: re-dial (or the caller's `restore`),
426
+ * then ASK THE RELAY. A restore that only re-listens has proved nothing --
427
+ * that is the same local belief that failed before -- so when a `verify` is
428
+ * available, its answer is what ends the outage.
429
+ */
430
+ async function restore(): Promise<void> {
431
+ if (stopped || restoring) return;
432
+ restoring = true;
433
+ report("restoring", state.lastError ?? undefined);
434
+ try {
435
+ if (init.restore != null) {
436
+ await init.restore();
437
+ } else {
438
+ await dialRelay(node, relayAddr);
439
+ await waitForCircuitReservation(node, { attempts: RESTORE_POLL_ATTEMPTS });
440
+ }
441
+ if (stopped) return;
442
+ if (verify != null) {
443
+ markReserved(await verify({}), "restored");
444
+ } else {
445
+ if (addressGone()) throw new Error("restore resolved without a reservation");
446
+ markReserved({ expiresAt: null, ttlMs: null }, "restored");
447
+ }
448
+ } catch (error) {
449
+ if (stopped) return;
450
+ state.consecutiveFailures++;
451
+ const reason = messageOf(error);
452
+ state.lastError = reason;
453
+ markLost(reason);
454
+ report("restore-failed", reason);
455
+ scheduleRetry(retryDelayMs(state.consecutiveFailures - 1, minDelayMs, maxDelayMs, random));
456
+ } finally {
457
+ restoring = false;
458
+ }
459
+ }
460
+
461
+ /** The fast loss detector: an address that has gone is proof; one that is there is not. */
462
+ const check = (): void => {
463
+ if (stopped || restoring || renewing || retryTimer != null) return;
464
+ if (!addressGone()) return;
465
+ markLost("the node's own circuit address is gone (the relay connection closed)");
466
+ scheduleRetry(0);
467
+ };
468
+
469
+ node.addEventListener("connection:close", check);
470
+ node.addEventListener("self:peer:update", check);
471
+ const interval = timers.setInterval(check, init.checkIntervalMs ?? SUPERVISOR_CHECK_INTERVAL_MS);
472
+ report("reserved");
473
+ scheduleRenewal(true);
474
+
475
+ return {
476
+ poke() {
477
+ if (stopped) return;
478
+ timers.clearTimeout(retryTimer);
479
+ retryTimer = undefined;
480
+ state.consecutiveFailures = 0;
481
+ if (verify != null) void renew();
482
+ else check();
483
+ },
484
+ state: snapshot,
485
+ stop() {
486
+ if (stopped) return;
487
+ stopped = true;
488
+ state.status = "stopped";
489
+ timers.clearTimeout(retryTimer);
490
+ timers.clearTimeout(renewalTimer);
491
+ retryTimer = undefined;
492
+ renewalTimer = undefined;
493
+ timers.clearInterval(interval);
494
+ node.removeEventListener("connection:close", check);
495
+ node.removeEventListener("self:peer:update", check);
496
+ report("stopped");
497
+ },
498
+ };
499
+ }
500
+
501
+ /** What is left of the reservation the relay last granted, for the next renewal's cadence. */
502
+ function ttlOf(state: RelayReservationState, nowMs: number): number | null {
503
+ return state.expiresAt != null ? state.expiresAt - nowMs : null;
504
+ }
505
+
506
+ function messageOf(error: unknown): string {
507
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
508
+ }
509
+
510
+ /** One line per transition on stdout. Replaceable, but never absent — see the module comment. */
511
+ const defaultRelayLog: RelayLog = (message) => {
512
+ console.log(message);
513
+ };
514
+
515
+ /**
516
+ * The wait before retry number `failures` (0-based): doubling from
517
+ * `minDelayMs`, capped at `maxDelayMs`, then jittered into its upper half
518
+ * so a relay restart does not bring every peer back in the same instant.
519
+ */
520
+ export function retryDelayMs(
521
+ failures: number,
522
+ minDelayMs: number,
523
+ maxDelayMs: number,
524
+ random: () => number = Math.random,
525
+ ): number {
526
+ const base = Math.min(maxDelayMs, minDelayMs * 2 ** failures);
527
+ return base / 2 + (random() * base) / 2;
528
+ }
529
+
530
+ /** The circuit addresses a node currently holds, split by kind. */
531
+ export interface CircuitAddrs {
532
+ /** `/…/p2p-circuit/p2p/<self>` — reachable through the relay, no WebRTC. */
533
+ bare?: string;
534
+ /** `/…/p2p-circuit/webrtc/p2p/<self>` — the relay carries only the handshake. */
535
+ webrtc?: string;
536
+ }
537
+
538
+ /**
539
+ * Which of a node's addresses a peer should actually be told.
540
+ *
541
+ * After reserving, a node carries several multiaddrs at once and they are NOT
542
+ * interchangeable: its own transport address is not reachable through a relay
543
+ * at all, the bare circuit relays every byte, and the `/webrtc` circuit uses
544
+ * the relay for the handshake only and then talks directly. Handing out the
545
+ * wrong one is a connection that fails for a reason nobody can read from the
546
+ * error.
547
+ *
548
+ * Each assembly in the prototype picked one inline, and differently — the kind
549
+ * of triplication the extraction exists to end.
550
+ */
551
+ export function circuitAddrs(node: Libp2p): CircuitAddrs {
552
+ const found: CircuitAddrs = {};
553
+ for (const addr of node.getMultiaddrs()) {
554
+ const value = addr.toString();
555
+ // Not a circuit: the node's own transport address. A dialler reaching it
556
+ // needs no relay, so it does not belong in either field.
557
+ if (!value.includes("/p2p-circuit")) continue;
558
+ // Order matters: every webrtc circuit also contains `/p2p-circuit`, so the
559
+ // narrower test comes first or every address lands in `bare`.
560
+ if (value.includes("/webrtc")) found.webrtc ??= value;
561
+ else found.bare ??= value;
562
+ }
563
+ return found;
564
+ }