peerbit 5.3.6 → 5.3.8

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.
@@ -0,0 +1,343 @@
1
+ import type { Multiaddr } from "@multiformats/multiaddr";
2
+
3
+ /**
4
+ * Automatic bootstrap recovery is opt-in so creating a Peerbit client keeps
5
+ * its existing no-network-side-effect behavior unless an application asks for
6
+ * recovery explicitly.
7
+ */
8
+ export type BootstrapRecoveryOptions = {
9
+ /** Set to false to leave recovery disabled. Defaults to true for an options object. */
10
+ enabled?: boolean;
11
+ /**
12
+ * Fixed bootstrap targets. When omitted, every recovery attempt resolves the
13
+ * current public bootstrap list, including its canonical fallback source.
14
+ */
15
+ addresses?: Array<string | Multiaddr>;
16
+ /**
17
+ * Delay after the first failed attempt. Defaults to 1 second; maximum is
18
+ * 2,147,483,647 ms.
19
+ */
20
+ initialDelayMs?: number;
21
+ /**
22
+ * Maximum retry delay, including jitter. Defaults to 60 seconds; maximum is
23
+ * 2,147,483,647 ms.
24
+ */
25
+ maxDelayMs?: number;
26
+ /** Exponential multiplier applied after each failed attempt. Defaults to 2. */
27
+ backoffFactor?: number;
28
+ /** Symmetric jitter ratio in the inclusive range 0..1. Defaults to 0.2. */
29
+ jitter?: number;
30
+ /**
31
+ * Minimum time between attempts and reconnects. Defaults to 1 second;
32
+ * maximum is 2,147,483,647 ms.
33
+ */
34
+ cooldownMs?: number;
35
+ };
36
+
37
+ type NormalizedBootstrapRecoveryOptions = {
38
+ initialDelayMs: number;
39
+ maxDelayMs: number;
40
+ backoffFactor: number;
41
+ jitter: number;
42
+ cooldownMs: number;
43
+ };
44
+
45
+ export type BootstrapRecoveryEventTarget = {
46
+ addEventListener(type: string, listener: EventListener): void;
47
+ removeEventListener(type: string, listener: EventListener): void;
48
+ };
49
+
50
+ export type BootstrapRecoveryRuntime = {
51
+ bootstrap(signal: AbortSignal): Promise<unknown>;
52
+ connectionEvents: BootstrapRecoveryEventTarget;
53
+ onlineEvents?: BootstrapRecoveryEventTarget;
54
+ isConnected(): boolean;
55
+ isOnline?: () => boolean;
56
+ now?: () => number;
57
+ random?: () => number;
58
+ onError?: (error: unknown) => void;
59
+ };
60
+
61
+ const DEFAULT_OPTIONS: NormalizedBootstrapRecoveryOptions = {
62
+ initialDelayMs: 1_000,
63
+ maxDelayMs: 60_000,
64
+ backoffFactor: 2,
65
+ jitter: 0.2,
66
+ cooldownMs: 1_000,
67
+ };
68
+
69
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
70
+
71
+ const finiteNumber = (name: string, value: number, minimum: number): number => {
72
+ if (!Number.isFinite(value) || value < minimum) {
73
+ throw new Error(`${name} must be a finite number >= ${minimum}`);
74
+ }
75
+ return value;
76
+ };
77
+
78
+ const timerDelay = (name: string, value: number, minimum: number): number => {
79
+ const delay = finiteNumber(name, value, minimum);
80
+ if (delay > MAX_TIMER_DELAY_MS) {
81
+ throw new Error(`${name} must be <= ${MAX_TIMER_DELAY_MS}`);
82
+ }
83
+ return delay;
84
+ };
85
+
86
+ const normalizeOptions = (
87
+ options: BootstrapRecoveryOptions,
88
+ ): NormalizedBootstrapRecoveryOptions => {
89
+ const initialDelayMs = timerDelay(
90
+ "bootstrapRecovery.initialDelayMs",
91
+ options.initialDelayMs ?? DEFAULT_OPTIONS.initialDelayMs,
92
+ 1,
93
+ );
94
+ const maxDelayMs = timerDelay(
95
+ "bootstrapRecovery.maxDelayMs",
96
+ options.maxDelayMs ?? DEFAULT_OPTIONS.maxDelayMs,
97
+ initialDelayMs,
98
+ );
99
+ const backoffFactor = finiteNumber(
100
+ "bootstrapRecovery.backoffFactor",
101
+ options.backoffFactor ?? DEFAULT_OPTIONS.backoffFactor,
102
+ 1,
103
+ );
104
+ const jitter = finiteNumber(
105
+ "bootstrapRecovery.jitter",
106
+ options.jitter ?? DEFAULT_OPTIONS.jitter,
107
+ 0,
108
+ );
109
+ if (jitter > 1) {
110
+ throw new Error("bootstrapRecovery.jitter must be <= 1");
111
+ }
112
+ const cooldownMs = timerDelay(
113
+ "bootstrapRecovery.cooldownMs",
114
+ options.cooldownMs ?? DEFAULT_OPTIONS.cooldownMs,
115
+ 0,
116
+ );
117
+ return {
118
+ initialDelayMs,
119
+ maxDelayMs,
120
+ backoffFactor,
121
+ jitter,
122
+ cooldownMs,
123
+ };
124
+ };
125
+
126
+ /** Validate policy configuration before an owning client acquires resources. */
127
+ export const validateBootstrapRecoveryOptions = (
128
+ options: BootstrapRecoveryOptions,
129
+ ): void => {
130
+ if (options.addresses?.length === 0) {
131
+ throw new Error("bootstrapRecovery.addresses must not be empty");
132
+ }
133
+ normalizeOptions(options);
134
+ };
135
+
136
+ /** Internal lifecycle controller, exported from its source module for tests. */
137
+ export class BootstrapRecoveryController {
138
+ private readonly options: NormalizedBootstrapRecoveryOptions;
139
+ private readonly now: () => number;
140
+ private readonly random: () => number;
141
+ private running = false;
142
+ private timer?: ReturnType<typeof setTimeout>;
143
+ private timerDueAt?: number;
144
+ private inFlight?: Promise<void>;
145
+ private attemptAbort?: AbortController;
146
+ private consecutiveFailures = 0;
147
+ private lastAttemptAt = Number.NEGATIVE_INFINITY;
148
+ private retryNotBefore = Number.NEGATIVE_INFINITY;
149
+
150
+ private readonly onConnectionOpen: EventListener = () => {
151
+ // A bootstrap attempt emits this event from inside its own successful dial.
152
+ // Let that attempt finish all Peerbit bootstrap side effects before treating
153
+ // the recovery as complete.
154
+ if (this.inFlight) return;
155
+ this.markConnected();
156
+ };
157
+
158
+ private readonly onConnectionClose: EventListener = () => {
159
+ this.requestRecovery(false);
160
+ };
161
+
162
+ private readonly onOnline: EventListener = () => {
163
+ this.requestRecovery(true);
164
+ };
165
+
166
+ constructor(
167
+ private readonly runtime: BootstrapRecoveryRuntime,
168
+ options: BootstrapRecoveryOptions = {},
169
+ ) {
170
+ this.options = normalizeOptions(options);
171
+ this.now = runtime.now ?? Date.now;
172
+ this.random = runtime.random ?? Math.random;
173
+ }
174
+
175
+ get started(): boolean {
176
+ return this.running;
177
+ }
178
+
179
+ start(): void {
180
+ if (this.running) return;
181
+ this.running = true;
182
+ this.runtime.connectionEvents.addEventListener(
183
+ "connection:open",
184
+ this.onConnectionOpen,
185
+ );
186
+ this.runtime.connectionEvents.addEventListener(
187
+ "connection:close",
188
+ this.onConnectionClose,
189
+ );
190
+ this.runtime.onlineEvents?.addEventListener("online", this.onOnline);
191
+
192
+ if (this.runtime.isConnected()) {
193
+ this.markConnected();
194
+ } else {
195
+ this.schedule(0, false);
196
+ }
197
+ }
198
+
199
+ stop(): Promise<void> {
200
+ if (this.running) {
201
+ this.running = false;
202
+ this.runtime.connectionEvents.removeEventListener(
203
+ "connection:open",
204
+ this.onConnectionOpen,
205
+ );
206
+ this.runtime.connectionEvents.removeEventListener(
207
+ "connection:close",
208
+ this.onConnectionClose,
209
+ );
210
+ this.runtime.onlineEvents?.removeEventListener("online", this.onOnline);
211
+ this.clearTimer();
212
+ this.attemptAbort?.abort(new Error("Bootstrap recovery stopped"));
213
+ this.attemptAbort = undefined;
214
+ }
215
+ return this.inFlight ?? Promise.resolve();
216
+ }
217
+
218
+ private markConnected(): void {
219
+ if (!this.running) return;
220
+ this.consecutiveFailures = 0;
221
+ this.retryNotBefore = Number.NEGATIVE_INFINITY;
222
+ this.lastAttemptAt = this.now();
223
+ this.clearTimer();
224
+ }
225
+
226
+ private requestRecovery(urgent: boolean): void {
227
+ if (!this.running || this.runtime.isConnected() || this.inFlight) return;
228
+ const now = this.now();
229
+ const cooldownRemaining = Math.max(
230
+ 0,
231
+ this.lastAttemptAt + this.options.cooldownMs - now,
232
+ );
233
+ const retryRemaining = Math.max(0, this.retryNotBefore - now);
234
+ this.schedule(Math.max(cooldownRemaining, retryRemaining), urgent);
235
+ }
236
+
237
+ private clearTimer(): void {
238
+ if (this.timer) {
239
+ clearTimeout(this.timer);
240
+ }
241
+ this.timer = undefined;
242
+ this.timerDueAt = undefined;
243
+ }
244
+
245
+ private schedule(delayMs: number, replaceIfEarlier: boolean): void {
246
+ if (!this.running) return;
247
+ const delay = Math.max(0, delayMs);
248
+ const dueAt = this.now() + delay;
249
+ if (this.timer) {
250
+ if (!replaceIfEarlier || (this.timerDueAt ?? dueAt) <= dueAt) {
251
+ return;
252
+ }
253
+ this.clearTimer();
254
+ }
255
+ this.timerDueAt = dueAt;
256
+ this.timer = setTimeout(() => {
257
+ this.timer = undefined;
258
+ this.timerDueAt = undefined;
259
+ this.runAttempt();
260
+ }, delay);
261
+ (
262
+ this.timer as ReturnType<typeof setTimeout> & { unref?: () => void }
263
+ ).unref?.();
264
+ }
265
+
266
+ private runAttempt(): void {
267
+ if (!this.running || this.runtime.isConnected()) {
268
+ if (this.runtime.isConnected()) this.markConnected();
269
+ return;
270
+ }
271
+ if (this.runtime.isOnline?.() === false) {
272
+ // Browser online events bring this forward immediately; the bounded timer
273
+ // remains as a safety net for missed or unreliable environment signals.
274
+ this.schedule(this.options.maxDelayMs, false);
275
+ return;
276
+ }
277
+ const cooldownRemaining = Math.max(
278
+ 0,
279
+ this.lastAttemptAt + this.options.cooldownMs - this.now(),
280
+ );
281
+ if (cooldownRemaining > 0) {
282
+ this.schedule(cooldownRemaining, false);
283
+ return;
284
+ }
285
+ if (this.inFlight) return;
286
+
287
+ this.lastAttemptAt = this.now();
288
+ const controller = new AbortController();
289
+ this.attemptAbort = controller;
290
+ const attempt = Promise.resolve().then(async () => {
291
+ await this.runtime.bootstrap(controller.signal);
292
+ });
293
+ const flight = attempt
294
+ .catch((error) => {
295
+ if (this.running && !controller.signal.aborted) {
296
+ this.runtime.onError?.(error);
297
+ }
298
+ })
299
+ .finally(() => {
300
+ if (this.inFlight !== flight) return;
301
+ this.inFlight = undefined;
302
+ if (this.attemptAbort === controller) {
303
+ this.attemptAbort = undefined;
304
+ }
305
+ if (!this.running) return;
306
+ if (this.runtime.isConnected()) {
307
+ this.markConnected();
308
+ return;
309
+ }
310
+ // A bootstrap call that returns after its connection has already closed is
311
+ // still a recovery failure and must remain on the bounded retry path.
312
+ this.scheduleRetry();
313
+ });
314
+ this.inFlight = flight;
315
+ }
316
+
317
+ private scheduleRetry(): void {
318
+ const exponent = Math.min(this.consecutiveFailures, 52);
319
+ const baseDelay = Math.min(
320
+ this.options.maxDelayMs,
321
+ this.options.initialDelayMs *
322
+ Math.pow(this.options.backoffFactor, exponent),
323
+ );
324
+ this.consecutiveFailures += 1;
325
+ const random = Math.max(0, Math.min(1, this.random()));
326
+ const jitterMultiplier = 1 + (random * 2 - 1) * this.options.jitter;
327
+ const retryDelay = Math.max(
328
+ 1,
329
+ Math.min(
330
+ this.options.maxDelayMs,
331
+ Math.round(baseDelay * jitterMultiplier),
332
+ ),
333
+ );
334
+ const now = this.now();
335
+ const cooldownRemaining = Math.max(
336
+ 0,
337
+ this.lastAttemptAt + this.options.cooldownMs - now,
338
+ );
339
+ const delay = Math.max(retryDelay, cooldownRemaining);
340
+ this.retryNotBefore = now + delay;
341
+ this.schedule(delay, false);
342
+ }
343
+ }
package/src/bootstrap.ts CHANGED
@@ -1,18 +1,287 @@
1
1
  import { type Multiaddr, multiaddr } from "@multiformats/multiaddr";
2
+ import {
3
+ Circuit,
4
+ DNSADDR,
5
+ TCP,
6
+ WebRTC,
7
+ WebSockets,
8
+ WebSocketsSecure,
9
+ } from "@multiformats/multiaddr-matcher";
10
+
11
+ const BOOTSTRAP_LIST_FETCH_TIMEOUT_MS = 10_000;
12
+ const MAX_BOOTSTRAP_LIST_BYTES = 64 * 1024;
13
+ const MAX_BOOTSTRAP_LIST_LINES = 512;
14
+ const MAX_BOOTSTRAP_ADDRESS_COUNT = 256;
15
+
16
+ const hasRemoteEndpoint = (address: Multiaddr): boolean =>
17
+ !address
18
+ .getComponents()
19
+ .some(
20
+ (component) =>
21
+ (component.name === "tcp" && component.value === "0") ||
22
+ (component.name === "ip4" && component.value === "0.0.0.0") ||
23
+ (component.name === "ip6" && component.value === "::"),
24
+ );
25
+
26
+ const hasDatagramTransport = (address: Multiaddr): boolean =>
27
+ address
28
+ .getComponents()
29
+ .some((component) =>
30
+ ["udp", "quic", "quic-v1", "webtransport", "webrtc-direct"].includes(
31
+ component.name,
32
+ ),
33
+ );
34
+
35
+ const isWebSocketTarget = (address: Multiaddr): boolean =>
36
+ !hasDatagramTransport(address) && WebSockets.exactMatch(address);
37
+
38
+ const isSecureWebSocketTarget = (address: Multiaddr): boolean =>
39
+ !hasDatagramTransport(address) && WebSocketsSecure.exactMatch(address);
40
+
41
+ const hasDefaultDirectPrefix = (address: Multiaddr): boolean =>
42
+ !hasDatagramTransport(address) &&
43
+ (DNSADDR.matches(address) ||
44
+ TCP.matches(address) ||
45
+ WebSockets.matches(address) ||
46
+ WebSocketsSecure.matches(address));
47
+
48
+ const hasBrowserSafeDirectPrefix = (address: Multiaddr): boolean =>
49
+ !hasDatagramTransport(address) &&
50
+ (DNSADDR.matches(address) || WebSocketsSecure.matches(address));
51
+
52
+ const hasCircuitPeerIds = (address: Multiaddr): boolean => {
53
+ const components = address.getComponents();
54
+ const circuitIndex = components.findIndex(
55
+ (component) => component.name === "p2p-circuit",
56
+ );
57
+ return (
58
+ circuitIndex >= 0 &&
59
+ components
60
+ .slice(0, circuitIndex)
61
+ .some((component) => component.name === "p2p") &&
62
+ components
63
+ .slice(circuitIndex + 1)
64
+ .some((component) => component.name === "p2p")
65
+ );
66
+ };
67
+
68
+ const classifyBootstrapAddress = (
69
+ address: Multiaddr,
70
+ ): { supported: boolean; crossRuntime: boolean } => {
71
+ if (!hasRemoteEndpoint(address)) {
72
+ return { supported: false, crossRuntime: false };
73
+ }
74
+
75
+ const direct =
76
+ DNSADDR.exactMatch(address) ||
77
+ TCP.exactMatch(address) ||
78
+ isWebSocketTarget(address) ||
79
+ isSecureWebSocketTarget(address);
80
+ const circuit =
81
+ hasCircuitPeerIds(address) &&
82
+ Circuit.exactMatch(address) &&
83
+ hasDefaultDirectPrefix(address);
84
+ const webRTC =
85
+ hasCircuitPeerIds(address) &&
86
+ WebRTC.exactMatch(address) &&
87
+ hasBrowserSafeDirectPrefix(address);
88
+ const crossRuntime =
89
+ DNSADDR.exactMatch(address) ||
90
+ isSecureWebSocketTarget(address) ||
91
+ (circuit && hasBrowserSafeDirectPrefix(address));
92
+
93
+ return { supported: direct || circuit || webRTC, crossRuntime };
94
+ };
95
+
96
+ const getBootstrapListSources = (v: string): string[] => {
97
+ const file = `bootstrap${v ? "-" + encodeURIComponent(v) : ""}.env`;
98
+ return [
99
+ `https://bootstrap.peerbit.org/${file}`,
100
+ `https://raw.githubusercontent.com/dao-xyz/peerbit-bootstrap/master/${file}`,
101
+ ];
102
+ };
103
+
104
+ const parseBootstrapAddresses = (value: string): string[] => {
105
+ const lines = value.split(/\r?\n/);
106
+ if (lines.length > MAX_BOOTSTRAP_LIST_LINES) {
107
+ throw new Error(
108
+ `Bootstrap list has too many lines (${lines.length} > ${MAX_BOOTSTRAP_LIST_LINES})`,
109
+ );
110
+ }
111
+
112
+ const addresses = lines
113
+ .map((line) => line.trim())
114
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
115
+
116
+ if (addresses.length === 0) {
117
+ throw new Error("Bootstrap list is empty");
118
+ }
119
+ if (addresses.length > MAX_BOOTSTRAP_ADDRESS_COUNT) {
120
+ throw new Error(
121
+ `Bootstrap list has too many addresses (${addresses.length} > ${MAX_BOOTSTRAP_ADDRESS_COUNT})`,
122
+ );
123
+ }
124
+
125
+ const canonicalAddresses: string[] = [];
126
+ let hasCrossRuntimeAddress = false;
127
+ for (const address of addresses) {
128
+ let parsed: Multiaddr;
129
+ try {
130
+ parsed = multiaddr(address);
131
+ } catch {
132
+ continue;
133
+ }
134
+ const classification = classifyBootstrapAddress(parsed);
135
+ if (!classification.supported) {
136
+ continue;
137
+ }
138
+ hasCrossRuntimeAddress ||= classification.crossRuntime;
139
+ canonicalAddresses.push(parsed.toString());
140
+ }
141
+ if (canonicalAddresses.length === 0) {
142
+ throw new Error("Bootstrap list has no supported dial targets");
143
+ }
144
+ if (!hasCrossRuntimeAddress) {
145
+ throw new Error(
146
+ "Bootstrap list has no browser-safe cross-runtime dial target",
147
+ );
148
+ }
149
+
150
+ return [...new Set(canonicalAddresses)];
151
+ };
152
+
153
+ const readBootstrapList = async (response: Response): Promise<string> => {
154
+ const declaredLength = response.headers.get("content-length");
155
+ if (declaredLength != null) {
156
+ if (!/^\d+$/.test(declaredLength)) {
157
+ throw new Error(
158
+ `Invalid bootstrap list Content-Length: ${declaredLength}`,
159
+ );
160
+ }
161
+ const length = Number(declaredLength);
162
+ if (!Number.isSafeInteger(length) || length > MAX_BOOTSTRAP_LIST_BYTES) {
163
+ throw new Error(
164
+ `Bootstrap list is too large (${declaredLength} > ${MAX_BOOTSTRAP_LIST_BYTES} bytes)`,
165
+ );
166
+ }
167
+ }
168
+
169
+ if (!response.body) {
170
+ return "";
171
+ }
172
+
173
+ const reader = response.body.getReader();
174
+ const decoder = new TextDecoder("utf-8", { fatal: true });
175
+ let bytesRead = 0;
176
+ let value = "";
177
+ let completed = false;
178
+ try {
179
+ while (true) {
180
+ const chunk = await reader.read();
181
+ if (chunk.done) {
182
+ break;
183
+ }
184
+ bytesRead += chunk.value.byteLength;
185
+ if (bytesRead > MAX_BOOTSTRAP_LIST_BYTES) {
186
+ throw new Error(
187
+ `Bootstrap list is too large (${bytesRead} > ${MAX_BOOTSTRAP_LIST_BYTES} bytes)`,
188
+ );
189
+ }
190
+ value += decoder.decode(chunk.value, { stream: true });
191
+ }
192
+ value += decoder.decode();
193
+ completed = true;
194
+ return value;
195
+ } finally {
196
+ if (!completed) {
197
+ await reader
198
+ .cancel("Bootstrap list read did not complete")
199
+ .catch(() => {});
200
+ }
201
+ reader.releaseLock();
202
+ }
203
+ };
204
+
205
+ const abortReason = (signal: AbortSignal): unknown =>
206
+ signal.reason ?? new Error("Bootstrap address resolution aborted");
207
+
208
+ const fetchBootstrapAddresses = async (
209
+ source: string,
210
+ externalSignal?: AbortSignal,
211
+ ): Promise<string[]> => {
212
+ const controller = new AbortController();
213
+ const onExternalAbort = () => controller.abort(abortReason(externalSignal!));
214
+ if (externalSignal?.aborted) {
215
+ onExternalAbort();
216
+ } else {
217
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
218
+ }
219
+ const timeout = setTimeout(() => {
220
+ controller.abort(
221
+ new Error(
222
+ `Timed out fetching bootstrap list after ${BOOTSTRAP_LIST_FETCH_TIMEOUT_MS} ms`,
223
+ ),
224
+ );
225
+ }, BOOTSTRAP_LIST_FETCH_TIMEOUT_MS);
226
+
227
+ let response: Response | undefined;
228
+ try {
229
+ response = await fetch(source, { signal: controller.signal });
230
+ if (!response.ok) {
231
+ throw new Error(
232
+ `Bootstrap list returned HTTP ${response.status}${
233
+ response.statusText ? ` ${response.statusText}` : ""
234
+ }`,
235
+ );
236
+ }
237
+ return parseBootstrapAddresses(await readBootstrapList(response));
238
+ } catch (error) {
239
+ if (response?.body && !response.body.locked) {
240
+ await response.body.cancel(error).catch(() => {});
241
+ }
242
+ if (!controller.signal.aborted) {
243
+ controller.abort(error);
244
+ }
245
+ throw error;
246
+ } finally {
247
+ clearTimeout(timeout);
248
+ externalSignal?.removeEventListener("abort", onExternalAbort);
249
+ }
250
+ };
251
+
252
+ export type ResolveBootstrapAddressesOptions = {
253
+ signal?: AbortSignal;
254
+ };
2
255
 
3
256
  export const resolveBootstrapAddresses = async (
4
257
  v: string = "5",
258
+ options: ResolveBootstrapAddressesOptions = {},
5
259
  ): Promise<string[]> => {
6
- // Bootstrap addresses for network
7
- return (
8
- await (
9
- await fetch(
10
- `https://bootstrap.peerbit.org/bootstrap${v ? "-" + v : ""}.env`,
11
- )
12
- ).text()
13
- )
14
- .split(/\r?\n/)
15
- .filter((x) => x.length > 0);
260
+ const failures: Error[] = [];
261
+ for (const source of getBootstrapListSources(v)) {
262
+ if (options.signal?.aborted) {
263
+ throw abortReason(options.signal);
264
+ }
265
+ try {
266
+ return await fetchBootstrapAddresses(source, options.signal);
267
+ } catch (error) {
268
+ if (options.signal?.aborted) {
269
+ throw abortReason(options.signal);
270
+ }
271
+ failures.push(
272
+ new Error(
273
+ `Failed to load bootstrap addresses from ${source}: ${
274
+ error instanceof Error ? error.message : String(error)
275
+ }`,
276
+ ),
277
+ );
278
+ }
279
+ }
280
+
281
+ throw new AggregateError(
282
+ failures,
283
+ `Failed to resolve bootstrap addresses for network version ${v || "default"}`,
284
+ );
16
285
  };
17
286
 
18
287
  export const getBootstrapPeerId = (
@@ -20,8 +289,13 @@ export const getBootstrapPeerId = (
20
289
  ): string | undefined => {
21
290
  try {
22
291
  const parsed = typeof address === "string" ? multiaddr(address) : address;
23
- return parsed.getComponents().find((component) => component.name === "p2p")
24
- ?.value;
292
+ const components = parsed.getComponents();
293
+ for (let index = components.length - 1; index >= 0; index--) {
294
+ if (components[index].name === "p2p") {
295
+ return components[index].value;
296
+ }
297
+ }
298
+ return undefined;
25
299
  } catch {
26
300
  return undefined;
27
301
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./peer.js";
2
2
  export * from "./bootstrap.js";
3
+ export type { BootstrapRecoveryOptions } from "./bootstrap-recovery.js";
3
4
  export {
4
5
  createLibp2pExtended,
5
6
  type Libp2pExtendServices,