@le-space/aleph-bootstrap 0.3.0
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/README.md +26 -0
- package/index.d.ts +90 -0
- package/index.js +248 -0
- package/le-space-aleph-bootstrap-0.3.0.tgz +0 -0
- package/package.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @le-space/aleph-bootstrap
|
|
2
|
+
|
|
3
|
+
`@le-space/aleph-bootstrap` is the shared package for publishing and
|
|
4
|
+
discovering relay bootstrap multiaddrs through Aleph POST messages.
|
|
5
|
+
|
|
6
|
+
It is designed for two complementary jobs:
|
|
7
|
+
|
|
8
|
+
- relay operators publish their current public multiaddrs to Aleph
|
|
9
|
+
- apps load fresh bootstrap multiaddrs before creating a libp2p node
|
|
10
|
+
|
|
11
|
+
## Public API
|
|
12
|
+
|
|
13
|
+
- `discoverAlephBootstrapMultiaddrs(options)`
|
|
14
|
+
- `createLibp2pAlephBootstrap(options)`
|
|
15
|
+
- `filterPublicMultiaddrs(addrs, options?)`
|
|
16
|
+
- `createRelayBootstrapPost(options)`
|
|
17
|
+
|
|
18
|
+
## Default Aleph convention
|
|
19
|
+
|
|
20
|
+
The package defaults to the shared relay-bootstrap namespace:
|
|
21
|
+
|
|
22
|
+
- channel: `simple-todo`
|
|
23
|
+
- ref: `simple-todo-bootstrap`
|
|
24
|
+
- post type: `relay-bootstrap`
|
|
25
|
+
|
|
26
|
+
All values are overrideable per app or environment.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
declare const DEFAULT_ALEPH_API_HOST = "https://api2.aleph.im";
|
|
2
|
+
declare const DEFAULT_ALEPH_BOOTSTRAP_CHANNEL = "simple-todo";
|
|
3
|
+
declare const DEFAULT_ALEPH_BOOTSTRAP_REF = "simple-todo-bootstrap";
|
|
4
|
+
declare const DEFAULT_ALEPH_BOOTSTRAP_POST_TYPE = "relay-bootstrap";
|
|
5
|
+
declare const DEFAULT_BOOTSTRAP_MAX_AGE_MS: number;
|
|
6
|
+
declare const DEFAULT_BOOTSTRAP_PAGINATION = 50;
|
|
7
|
+
interface RelayBootstrapContent {
|
|
8
|
+
peerId: string;
|
|
9
|
+
multiaddrs: string[];
|
|
10
|
+
browserMultiaddrs?: string[];
|
|
11
|
+
profile?: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
updatedAt: number;
|
|
14
|
+
}
|
|
15
|
+
interface RelayBootstrapPostContent {
|
|
16
|
+
type: string;
|
|
17
|
+
address: string;
|
|
18
|
+
ref?: string;
|
|
19
|
+
content: RelayBootstrapContent;
|
|
20
|
+
time: number;
|
|
21
|
+
}
|
|
22
|
+
interface RelayBootstrapPostRecord {
|
|
23
|
+
hash: string | null;
|
|
24
|
+
itemHash: string | null;
|
|
25
|
+
address: string | null;
|
|
26
|
+
ref: string | null;
|
|
27
|
+
type: string | null;
|
|
28
|
+
time: number | null;
|
|
29
|
+
content: RelayBootstrapContent | null;
|
|
30
|
+
}
|
|
31
|
+
interface DiscoverAlephBootstrapOptions {
|
|
32
|
+
apiHost?: string;
|
|
33
|
+
channel?: string;
|
|
34
|
+
ref?: string;
|
|
35
|
+
postType?: string;
|
|
36
|
+
page?: number;
|
|
37
|
+
pagination?: number;
|
|
38
|
+
maxAgeMs?: number;
|
|
39
|
+
browserDialableOnly?: boolean;
|
|
40
|
+
fetch?: typeof fetch;
|
|
41
|
+
}
|
|
42
|
+
interface FilterPublicMultiaddrsOptions {
|
|
43
|
+
browserDialableOnly?: boolean;
|
|
44
|
+
requirePeerId?: boolean;
|
|
45
|
+
}
|
|
46
|
+
interface CreateRelayBootstrapPostOptions {
|
|
47
|
+
sender: string;
|
|
48
|
+
peerId: string;
|
|
49
|
+
multiaddrs: string[];
|
|
50
|
+
browserMultiaddrs?: string[];
|
|
51
|
+
ref?: string;
|
|
52
|
+
channel?: string;
|
|
53
|
+
postType?: string;
|
|
54
|
+
profile?: string;
|
|
55
|
+
version?: string;
|
|
56
|
+
now?: number;
|
|
57
|
+
hasher: (payload: string) => Promise<string> | string;
|
|
58
|
+
}
|
|
59
|
+
declare function dedupeMultiaddrs(addrs: readonly string[]): string[];
|
|
60
|
+
declare function isPublicMultiaddr(addr: string): boolean;
|
|
61
|
+
declare function filterPublicMultiaddrs(addrs: readonly string[], options?: FilterPublicMultiaddrsOptions): string[];
|
|
62
|
+
declare function buildRelayBootstrapPostContent(args: {
|
|
63
|
+
sender: string;
|
|
64
|
+
peerId: string;
|
|
65
|
+
multiaddrs: string[];
|
|
66
|
+
browserMultiaddrs?: string[];
|
|
67
|
+
ref?: string;
|
|
68
|
+
postType?: string;
|
|
69
|
+
profile?: string;
|
|
70
|
+
version?: string;
|
|
71
|
+
now?: number;
|
|
72
|
+
}): RelayBootstrapPostContent;
|
|
73
|
+
declare function createRelayBootstrapPost(args: CreateRelayBootstrapPostOptions): Promise<{
|
|
74
|
+
channel: string;
|
|
75
|
+
sender: string;
|
|
76
|
+
chain: "ETH";
|
|
77
|
+
type: "POST";
|
|
78
|
+
time: number;
|
|
79
|
+
item_type: "inline";
|
|
80
|
+
item_content: string;
|
|
81
|
+
item_hash: string;
|
|
82
|
+
}>;
|
|
83
|
+
declare function fetchAlephBootstrapPosts(options?: DiscoverAlephBootstrapOptions): Promise<RelayBootstrapPostRecord[]>;
|
|
84
|
+
declare function discoverAlephBootstrapMultiaddrs(options?: DiscoverAlephBootstrapOptions): Promise<string[]>;
|
|
85
|
+
declare function createLibp2pAlephBootstrap(options?: DiscoverAlephBootstrapOptions & {
|
|
86
|
+
timeout?: number;
|
|
87
|
+
tagName?: string;
|
|
88
|
+
}): Promise<unknown>;
|
|
89
|
+
|
|
90
|
+
export { type CreateRelayBootstrapPostOptions, DEFAULT_ALEPH_API_HOST, DEFAULT_ALEPH_BOOTSTRAP_CHANNEL, DEFAULT_ALEPH_BOOTSTRAP_POST_TYPE, DEFAULT_ALEPH_BOOTSTRAP_REF, DEFAULT_BOOTSTRAP_MAX_AGE_MS, DEFAULT_BOOTSTRAP_PAGINATION, type DiscoverAlephBootstrapOptions, type FilterPublicMultiaddrsOptions, type RelayBootstrapContent, type RelayBootstrapPostContent, type RelayBootstrapPostRecord, buildRelayBootstrapPostContent, createLibp2pAlephBootstrap, createRelayBootstrapPost, dedupeMultiaddrs, discoverAlephBootstrapMultiaddrs, fetchAlephBootstrapPosts, filterPublicMultiaddrs, isPublicMultiaddr };
|
package/index.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { bootstrap } from "@libp2p/bootstrap";
|
|
3
|
+
var DEFAULT_ALEPH_API_HOST = "https://api2.aleph.im";
|
|
4
|
+
var DEFAULT_ALEPH_BOOTSTRAP_CHANNEL = "simple-todo";
|
|
5
|
+
var DEFAULT_ALEPH_BOOTSTRAP_REF = "simple-todo-bootstrap";
|
|
6
|
+
var DEFAULT_ALEPH_BOOTSTRAP_POST_TYPE = "relay-bootstrap";
|
|
7
|
+
var DEFAULT_BOOTSTRAP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
8
|
+
var DEFAULT_BOOTSTRAP_PAGINATION = 50;
|
|
9
|
+
function asTrimmedString(value) {
|
|
10
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
11
|
+
}
|
|
12
|
+
function asNumber(value) {
|
|
13
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
14
|
+
if (typeof value === "string" && value.trim()) {
|
|
15
|
+
const parsed = Number(value);
|
|
16
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function normalizeHost(host) {
|
|
21
|
+
return host.trim().toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
function splitMultiaddr(addr) {
|
|
24
|
+
return addr.split("/").filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
function isPrivateIpv4(ip) {
|
|
27
|
+
const parts = ip.split(".").map((part) => Number(part));
|
|
28
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (parts[0] === 10) return true;
|
|
32
|
+
if (parts[0] === 127) return true;
|
|
33
|
+
if (parts[0] === 0) return true;
|
|
34
|
+
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
35
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
36
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
37
|
+
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function isPrivateIpv6(ip) {
|
|
41
|
+
const normalized = ip.trim().toLowerCase();
|
|
42
|
+
return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
|
43
|
+
}
|
|
44
|
+
function isLocalHostname(host) {
|
|
45
|
+
const normalized = normalizeHost(host);
|
|
46
|
+
return normalized === "localhost" || normalized.endsWith(".localhost") || normalized.endsWith(".local");
|
|
47
|
+
}
|
|
48
|
+
function hasPeerId(addr) {
|
|
49
|
+
return splitMultiaddr(addr).includes("p2p");
|
|
50
|
+
}
|
|
51
|
+
function isBrowserDialableMultiaddr(addr) {
|
|
52
|
+
const normalized = addr.toLowerCase();
|
|
53
|
+
return normalized.includes("/ws") || normalized.includes("/wss") || normalized.includes("/webtransport") || normalized.includes("/webrtc-direct");
|
|
54
|
+
}
|
|
55
|
+
function dedupeMultiaddrs(addrs) {
|
|
56
|
+
const seen = /* @__PURE__ */ new Set();
|
|
57
|
+
const result = [];
|
|
58
|
+
for (const value of addrs) {
|
|
59
|
+
const trimmed = value.trim();
|
|
60
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
61
|
+
seen.add(trimmed);
|
|
62
|
+
result.push(trimmed);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function isPublicMultiaddr(addr) {
|
|
67
|
+
const parts = splitMultiaddr(addr);
|
|
68
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
69
|
+
const protocol = parts[index];
|
|
70
|
+
const value = parts[index + 1];
|
|
71
|
+
if (protocol === "ip4" && value) {
|
|
72
|
+
return !isPrivateIpv4(value);
|
|
73
|
+
}
|
|
74
|
+
if (protocol === "ip6" && value) {
|
|
75
|
+
return !isPrivateIpv6(value);
|
|
76
|
+
}
|
|
77
|
+
if ((protocol === "dns4" || protocol === "dns6" || protocol === "dnsaddr") && value) {
|
|
78
|
+
return !isLocalHostname(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
function filterPublicMultiaddrs(addrs, options = {}) {
|
|
84
|
+
return dedupeMultiaddrs(addrs).filter((addr) => {
|
|
85
|
+
if (!isPublicMultiaddr(addr)) return false;
|
|
86
|
+
if (options.requirePeerId !== false && !hasPeerId(addr)) return false;
|
|
87
|
+
if (options.browserDialableOnly && !isBrowserDialableMultiaddr(addr)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function normalizeRelayBootstrapContent(value) {
|
|
94
|
+
if (!value || typeof value !== "object") return null;
|
|
95
|
+
const content = value;
|
|
96
|
+
const peerId = asTrimmedString(content.peerId);
|
|
97
|
+
const updatedAt = asNumber(content.updatedAt);
|
|
98
|
+
if (!peerId || updatedAt == null) return null;
|
|
99
|
+
const multiaddrs = Array.isArray(content.multiaddrs) ? content.multiaddrs.filter((entry) => typeof entry === "string") : [];
|
|
100
|
+
const browserMultiaddrs = Array.isArray(content.browserMultiaddrs) ? content.browserMultiaddrs.filter((entry) => typeof entry === "string") : void 0;
|
|
101
|
+
return {
|
|
102
|
+
peerId,
|
|
103
|
+
multiaddrs: dedupeMultiaddrs(multiaddrs),
|
|
104
|
+
browserMultiaddrs: browserMultiaddrs ? dedupeMultiaddrs(browserMultiaddrs) : void 0,
|
|
105
|
+
profile: asTrimmedString(content.profile) ?? void 0,
|
|
106
|
+
version: asTrimmedString(content.version) ?? void 0,
|
|
107
|
+
updatedAt
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function normalizeRelayBootstrapPostRecord(value) {
|
|
111
|
+
if (!value || typeof value !== "object") return null;
|
|
112
|
+
const entry = value;
|
|
113
|
+
return {
|
|
114
|
+
hash: asTrimmedString(entry.hash),
|
|
115
|
+
itemHash: asTrimmedString(entry.item_hash),
|
|
116
|
+
address: asTrimmedString(entry.address),
|
|
117
|
+
ref: asTrimmedString(entry.ref),
|
|
118
|
+
type: asTrimmedString(entry.type),
|
|
119
|
+
time: asNumber(entry.time),
|
|
120
|
+
content: normalizeRelayBootstrapContent(entry.content)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function buildRelayBootstrapPostContent(args) {
|
|
124
|
+
const now = args.now ?? Date.now() / 1e3;
|
|
125
|
+
const updatedAt = args.now ?? Date.now();
|
|
126
|
+
return {
|
|
127
|
+
type: args.postType ?? DEFAULT_ALEPH_BOOTSTRAP_POST_TYPE,
|
|
128
|
+
address: args.sender,
|
|
129
|
+
...args.ref ? { ref: args.ref } : {},
|
|
130
|
+
content: {
|
|
131
|
+
peerId: args.peerId,
|
|
132
|
+
multiaddrs: filterPublicMultiaddrs(args.multiaddrs),
|
|
133
|
+
browserMultiaddrs: args.browserMultiaddrs ? filterPublicMultiaddrs(args.browserMultiaddrs, {
|
|
134
|
+
browserDialableOnly: true
|
|
135
|
+
}) : void 0,
|
|
136
|
+
profile: args.profile,
|
|
137
|
+
version: args.version,
|
|
138
|
+
updatedAt: Math.round(updatedAt)
|
|
139
|
+
},
|
|
140
|
+
time: now
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function createRelayBootstrapPost(args) {
|
|
144
|
+
const nowMillis = args.now ?? Date.now();
|
|
145
|
+
const nowSeconds = nowMillis / 1e3;
|
|
146
|
+
const itemContent = buildRelayBootstrapPostContent({
|
|
147
|
+
sender: args.sender,
|
|
148
|
+
peerId: args.peerId,
|
|
149
|
+
multiaddrs: args.multiaddrs,
|
|
150
|
+
browserMultiaddrs: args.browserMultiaddrs,
|
|
151
|
+
ref: args.ref ?? DEFAULT_ALEPH_BOOTSTRAP_REF,
|
|
152
|
+
postType: args.postType ?? DEFAULT_ALEPH_BOOTSTRAP_POST_TYPE,
|
|
153
|
+
profile: args.profile,
|
|
154
|
+
version: args.version,
|
|
155
|
+
now: nowMillis
|
|
156
|
+
});
|
|
157
|
+
itemContent.time = nowSeconds;
|
|
158
|
+
const serialized = JSON.stringify(itemContent);
|
|
159
|
+
const itemHash = await args.hasher(serialized);
|
|
160
|
+
return {
|
|
161
|
+
channel: args.channel ?? DEFAULT_ALEPH_BOOTSTRAP_CHANNEL,
|
|
162
|
+
sender: args.sender,
|
|
163
|
+
chain: "ETH",
|
|
164
|
+
type: "POST",
|
|
165
|
+
time: nowSeconds,
|
|
166
|
+
item_type: "inline",
|
|
167
|
+
item_content: serialized,
|
|
168
|
+
item_hash: itemHash
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async function fetchAlephBootstrapPosts(options = {}) {
|
|
172
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
173
|
+
if (typeof fetchImpl !== "function") {
|
|
174
|
+
throw new Error(
|
|
175
|
+
"A fetch implementation is required to query Aleph bootstrap posts."
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const url = new URL(
|
|
179
|
+
"/api/v0/posts.json",
|
|
180
|
+
options.apiHost ?? DEFAULT_ALEPH_API_HOST
|
|
181
|
+
);
|
|
182
|
+
url.searchParams.set(
|
|
183
|
+
"channels",
|
|
184
|
+
options.channel ?? DEFAULT_ALEPH_BOOTSTRAP_CHANNEL
|
|
185
|
+
);
|
|
186
|
+
url.searchParams.set("refs", options.ref ?? DEFAULT_ALEPH_BOOTSTRAP_REF);
|
|
187
|
+
url.searchParams.set(
|
|
188
|
+
"types",
|
|
189
|
+
options.postType ?? DEFAULT_ALEPH_BOOTSTRAP_POST_TYPE
|
|
190
|
+
);
|
|
191
|
+
url.searchParams.set(
|
|
192
|
+
"pagination",
|
|
193
|
+
String(options.pagination ?? DEFAULT_BOOTSTRAP_PAGINATION)
|
|
194
|
+
);
|
|
195
|
+
url.searchParams.set("page", String(options.page ?? 1));
|
|
196
|
+
const response = await fetchImpl(url.toString(), {
|
|
197
|
+
method: "GET",
|
|
198
|
+
headers: { accept: "application/json" }
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error(`Aleph bootstrap post lookup failed: ${response.status}`);
|
|
202
|
+
}
|
|
203
|
+
const payload = await response.json();
|
|
204
|
+
return (payload.posts ?? []).map((entry) => normalizeRelayBootstrapPostRecord(entry)).filter((entry) => entry != null);
|
|
205
|
+
}
|
|
206
|
+
async function discoverAlephBootstrapMultiaddrs(options = {}) {
|
|
207
|
+
const posts = await fetchAlephBootstrapPosts(options);
|
|
208
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_BOOTSTRAP_MAX_AGE_MS;
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const browserDialableOnly = options.browserDialableOnly ?? true;
|
|
211
|
+
const addrs = [];
|
|
212
|
+
for (const post of posts) {
|
|
213
|
+
const content = post.content;
|
|
214
|
+
if (!content) continue;
|
|
215
|
+
if (now - content.updatedAt > maxAgeMs) continue;
|
|
216
|
+
const candidates = browserDialableOnly && Array.isArray(content.browserMultiaddrs) && content.browserMultiaddrs.length > 0 ? content.browserMultiaddrs : content.multiaddrs;
|
|
217
|
+
addrs.push(
|
|
218
|
+
...filterPublicMultiaddrs(candidates, {
|
|
219
|
+
browserDialableOnly
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return dedupeMultiaddrs(addrs);
|
|
224
|
+
}
|
|
225
|
+
async function createLibp2pAlephBootstrap(options = {}) {
|
|
226
|
+
const list = await discoverAlephBootstrapMultiaddrs(options);
|
|
227
|
+
return bootstrap({
|
|
228
|
+
list,
|
|
229
|
+
timeout: options.timeout,
|
|
230
|
+
tagName: options.tagName
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
export {
|
|
234
|
+
DEFAULT_ALEPH_API_HOST,
|
|
235
|
+
DEFAULT_ALEPH_BOOTSTRAP_CHANNEL,
|
|
236
|
+
DEFAULT_ALEPH_BOOTSTRAP_POST_TYPE,
|
|
237
|
+
DEFAULT_ALEPH_BOOTSTRAP_REF,
|
|
238
|
+
DEFAULT_BOOTSTRAP_MAX_AGE_MS,
|
|
239
|
+
DEFAULT_BOOTSTRAP_PAGINATION,
|
|
240
|
+
buildRelayBootstrapPostContent,
|
|
241
|
+
createLibp2pAlephBootstrap,
|
|
242
|
+
createRelayBootstrapPost,
|
|
243
|
+
dedupeMultiaddrs,
|
|
244
|
+
discoverAlephBootstrapMultiaddrs,
|
|
245
|
+
fetchAlephBootstrapPosts,
|
|
246
|
+
filterPublicMultiaddrs,
|
|
247
|
+
isPublicMultiaddr
|
|
248
|
+
};
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@le-space/aleph-bootstrap",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Aleph-backed libp2p bootstrap discovery and relay registration helpers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./index.js",
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"import": "./index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@libp2p/bootstrap": "^11.0.46"
|
|
20
|
+
}
|
|
21
|
+
}
|