@ckb-ccc/libp2p 0.0.2

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,411 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import type {
3
+ AbortOptions,
4
+ Connection,
5
+ PeerId,
6
+ Stream,
7
+ } from "@libp2p/interface";
8
+ import type { ConnectionManager, Registrar } from "@libp2p/interface-internal";
9
+ import { lpStream } from "@libp2p/utils";
10
+ import type { Multiaddr } from "@multiformats/multiaddr";
11
+
12
+ const MAX_PAIRING_MESSAGE_LENGTH = 1024;
13
+ const UNPAIR_TIMEOUT_MS = 10_000;
14
+
15
+ export type PairingServiceComponents = {
16
+ connectionManager: ConnectionManager;
17
+ registrar: Registrar;
18
+ };
19
+
20
+ export type PairingServiceConfig = {
21
+ /** Optional self-reported label shared with the paired peer. */
22
+ name?: string;
23
+ protocol: string;
24
+ pairedPeerTimeoutMs: number;
25
+ };
26
+
27
+ export type PairingTarget = {
28
+ addresses: Multiaddr[];
29
+ secret: string;
30
+ };
31
+
32
+ export type PairingGuard<
33
+ Components extends PairingServiceComponents = PairingServiceComponents,
34
+ > = (this: PairingService<Components>, peerId: PeerId) => boolean;
35
+
36
+ type PairingRequest =
37
+ { type: "pair"; secret: string; name?: string } | { type: "unpair" };
38
+
39
+ type PairingResponse =
40
+ { ok: true; name?: string } | { ok: false; error: string };
41
+
42
+ export abstract class PairingService<
43
+ Components extends PairingServiceComponents = PairingServiceComponents,
44
+ > {
45
+ private currentSecret = createSecret();
46
+ private readonly errorListeners = new Set<(error: Error) => void>();
47
+ private readonly pairedListeners = new Set<
48
+ (peerId: PeerId, name?: string) => void
49
+ >();
50
+ private readonly unpairedListeners = new Set<(peerId: PeerId) => void>();
51
+ private readonly secretChangedListeners = new Set<(secret: string) => void>();
52
+ private readonly pairingTimeouts = new Map<
53
+ string,
54
+ ReturnType<typeof setTimeout>
55
+ >();
56
+
57
+ constructor(
58
+ readonly components: Components,
59
+ readonly config: PairingServiceConfig,
60
+ ) {
61
+ if (config.pairedPeerTimeoutMs <= 0) {
62
+ throw new Error("Paired peer timeout must be greater than zero");
63
+ }
64
+ }
65
+
66
+ protected abstract canPair(peerId: PeerId): boolean;
67
+
68
+ /** The credential to include in the currently issued pairing endpoint. */
69
+ get secret() {
70
+ return this.currentSecret;
71
+ }
72
+
73
+ onError(listener: (error: Error) => void) {
74
+ this.errorListeners.add(listener);
75
+ return () => this.errorListeners.delete(listener);
76
+ }
77
+
78
+ onPaired(listener: (peerId: PeerId, name?: string) => void) {
79
+ this.pairedListeners.add(listener);
80
+ return () => this.pairedListeners.delete(listener);
81
+ }
82
+
83
+ onUnpaired(listener: (peerId: PeerId) => void) {
84
+ this.unpairedListeners.add(listener);
85
+ return () => this.unpairedListeners.delete(listener);
86
+ }
87
+
88
+ onSecretChanged(listener: (secret: string) => void) {
89
+ this.secretChangedListeners.add(listener);
90
+ return () => this.secretChangedListeners.delete(listener);
91
+ }
92
+
93
+ async start() {
94
+ await this.components.registrar.handle(
95
+ this.config.protocol,
96
+ this.handleProtocol.bind(this),
97
+ { runOnLimitedConnection: true },
98
+ );
99
+ }
100
+
101
+ async stop() {
102
+ await this.components.registrar.unhandle(this.config.protocol);
103
+
104
+ this.pairingTimeouts.forEach((timeout) => clearTimeout(timeout));
105
+ this.pairingTimeouts.clear();
106
+ }
107
+
108
+ isPaired(peerId: PeerId) {
109
+ return this.pairingTimeouts.has(peerId.toString());
110
+ }
111
+
112
+ refresh(peerId: PeerId) {
113
+ const key = peerId.toString();
114
+ const timeout = this.pairingTimeouts.get(key);
115
+ if (timeout === undefined) {
116
+ return;
117
+ }
118
+
119
+ clearTimeout(timeout);
120
+ this.pairingTimeouts.set(key, this.createPeerTimeout(peerId));
121
+ }
122
+
123
+ async pair(
124
+ target: PairingTarget,
125
+ options: AbortOptions = {},
126
+ ): Promise<PeerId> {
127
+ let stream: Stream | undefined;
128
+ let addedPeer: PeerId | undefined;
129
+
130
+ try {
131
+ const opened = await this.openStream(target.addresses, options);
132
+ stream = opened.stream;
133
+
134
+ if (!opened.connection) {
135
+ throw new Error("Could not determine remote peer");
136
+ }
137
+
138
+ if (this.addPairedPeer(opened.connection.remotePeer)) {
139
+ addedPeer = opened.connection.remotePeer;
140
+ }
141
+
142
+ const response = await exchange(
143
+ stream,
144
+ {
145
+ type: "pair",
146
+ secret: target.secret,
147
+ name: this.config.name,
148
+ },
149
+ options,
150
+ );
151
+ options.signal?.throwIfAborted();
152
+ stream = undefined;
153
+
154
+ if (!response.ok) {
155
+ throw new Error(response.error);
156
+ }
157
+
158
+ if (addedPeer) {
159
+ this.rotateSecret();
160
+ this.notifyPairedPeer(addedPeer, response.name);
161
+ }
162
+ return opened.connection.remotePeer;
163
+ } catch (cause) {
164
+ if (addedPeer) {
165
+ this.removePairedPeer(addedPeer, false);
166
+ }
167
+ throw this.handleError(cause, stream);
168
+ }
169
+ }
170
+
171
+ async unpair(peerId: PeerId) {
172
+ if (!this.removePairedPeer(peerId)) {
173
+ return;
174
+ }
175
+
176
+ const options = { signal: AbortSignal.timeout(UNPAIR_TIMEOUT_MS) };
177
+
178
+ let stream: Stream | undefined;
179
+ try {
180
+ const opened = await this.openStream(peerId, options);
181
+ stream = opened.stream;
182
+ const response = await exchange(
183
+ opened.stream,
184
+ { type: "unpair" },
185
+ options,
186
+ );
187
+ stream = undefined;
188
+ if (!response.ok) {
189
+ throw new Error(response.error);
190
+ }
191
+ } catch (cause) {
192
+ this.handleError(cause, stream);
193
+ }
194
+ }
195
+
196
+ private async handleProtocol(stream: Stream, connection: Connection) {
197
+ let addedPeer: PeerId | undefined;
198
+
199
+ try {
200
+ const pairingStream = lpStream(stream, {
201
+ maxDataLength: MAX_PAIRING_MESSAGE_LENGTH,
202
+ });
203
+ const request = parsePairingRequest(
204
+ (await pairingStream.read()).subarray(),
205
+ );
206
+
207
+ if (request.type === "unpair") {
208
+ this.removePairedPeer(connection.remotePeer);
209
+ await writeResponse(pairingStream, stream, { ok: true });
210
+ return;
211
+ }
212
+
213
+ if (
214
+ request.secret !== this.secret ||
215
+ !this.canPair(connection.remotePeer)
216
+ ) {
217
+ await writeResponse(pairingStream, stream, {
218
+ ok: false,
219
+ error: "Pairing rejected",
220
+ });
221
+ return;
222
+ }
223
+
224
+ // Consume the credential before the next asynchronous boundary so two
225
+ // concurrent requests cannot both pair with the same endpoint.
226
+ this.rotateSecret();
227
+
228
+ if (this.addPairedPeer(connection.remotePeer)) {
229
+ addedPeer = connection.remotePeer;
230
+ }
231
+ await writeResponse(pairingStream, stream, {
232
+ ok: true,
233
+ name: this.config.name,
234
+ });
235
+ if (addedPeer) {
236
+ this.notifyPairedPeer(addedPeer, request.name);
237
+ }
238
+ } catch (cause) {
239
+ if (addedPeer) {
240
+ this.removePairedPeer(addedPeer, false);
241
+ }
242
+ this.handleError(cause, stream);
243
+ }
244
+ }
245
+
246
+ private addPairedPeer(peerId: PeerId) {
247
+ const key = peerId.toString();
248
+ const timeout = this.pairingTimeouts.get(key);
249
+ if (timeout !== undefined) {
250
+ clearTimeout(timeout);
251
+ }
252
+
253
+ this.pairingTimeouts.set(key, this.createPeerTimeout(peerId));
254
+ return timeout === undefined;
255
+ }
256
+
257
+ private notifyPairedPeer(peerId: PeerId, name?: string) {
258
+ this.pairedListeners.forEach((listener) => listener(peerId, name));
259
+ }
260
+
261
+ private rotateSecret() {
262
+ this.currentSecret = createSecret();
263
+ this.secretChangedListeners.forEach((listener) =>
264
+ listener(this.currentSecret),
265
+ );
266
+ }
267
+
268
+ private removePairedPeer(peerId: PeerId, notify = true) {
269
+ const key = peerId.toString();
270
+ const timeout = this.pairingTimeouts.get(key);
271
+ if (timeout === undefined) {
272
+ return false;
273
+ }
274
+
275
+ clearTimeout(timeout);
276
+ this.pairingTimeouts.delete(key);
277
+
278
+ if (notify) {
279
+ this.unpairedListeners.forEach((listener) => listener(peerId));
280
+ }
281
+ return true;
282
+ }
283
+
284
+ private createPeerTimeout(peerId: PeerId) {
285
+ return setTimeout(() => {
286
+ this.removePairedPeer(peerId);
287
+ }, this.config.pairedPeerTimeoutMs);
288
+ }
289
+
290
+ private async openStream(
291
+ target: PeerId | Multiaddr | Multiaddr[],
292
+ options: AbortOptions,
293
+ ) {
294
+ let connection: Connection | undefined;
295
+ const stream = await this.components.connectionManager.openStream(
296
+ target,
297
+ this.config.protocol,
298
+ {
299
+ runOnLimitedConnection: true,
300
+ signal: options.signal,
301
+ onProgress(event) {
302
+ if (event.type === "connection:opened-stream") {
303
+ connection = event.detail.connection;
304
+ }
305
+ },
306
+ },
307
+ );
308
+
309
+ return { stream, connection };
310
+ }
311
+
312
+ private handleError(cause: unknown, stream?: Stream) {
313
+ const error = asError(cause, "Pairing operation failed");
314
+
315
+ stream?.abort(error);
316
+ this.errorListeners.forEach((listener) => listener(error));
317
+ return error;
318
+ }
319
+ }
320
+
321
+ export function pairingService<
322
+ Components extends PairingServiceComponents = PairingServiceComponents,
323
+ >(
324
+ config: PairingServiceConfig,
325
+ canPair: PairingGuard<Components>,
326
+ ): (components: Components) => PairingService<Components> {
327
+ return (components: Components) =>
328
+ new (class extends PairingService<Components> {
329
+ protected canPair(peerId: PeerId) {
330
+ return canPair.call(this, peerId);
331
+ }
332
+ })(components, config);
333
+ }
334
+
335
+ async function exchange(
336
+ stream: Stream,
337
+ request: PairingRequest,
338
+ options: AbortOptions,
339
+ ) {
340
+ const pairingStream = lpStream(stream, {
341
+ maxDataLength: MAX_PAIRING_MESSAGE_LENGTH,
342
+ });
343
+
344
+ await pairingStream.write(ccc.bytesFrom(JSON.stringify(request), "utf8"), {
345
+ signal: options.signal,
346
+ });
347
+ await stream.close(options);
348
+
349
+ const response = parsePairingResponse(
350
+ (await pairingStream.read(options)).subarray(),
351
+ );
352
+ await stream.closeRead(options);
353
+ return response;
354
+ }
355
+
356
+ async function writeResponse(
357
+ pairingStream: ReturnType<typeof lpStream>,
358
+ stream: Stream,
359
+ response: PairingResponse,
360
+ ) {
361
+ await pairingStream.write(ccc.bytesFrom(JSON.stringify(response), "utf8"));
362
+ await stream.close();
363
+ }
364
+
365
+ function parsePairingRequest(data: Uint8Array): PairingRequest {
366
+ const request = JSON.parse(
367
+ ccc.bytesTo(data, "utf8"),
368
+ ) as Partial<PairingRequest>;
369
+ if (
370
+ request.type === "pair" &&
371
+ typeof request.secret === "string" &&
372
+ request.secret
373
+ ) {
374
+ return {
375
+ type: "pair",
376
+ secret: request.secret,
377
+ name: typeof request.name === "string" ? request.name : undefined,
378
+ };
379
+ }
380
+
381
+ if (request.type === "unpair") {
382
+ return { type: "unpair" };
383
+ }
384
+
385
+ throw new Error("Invalid pairing request");
386
+ }
387
+
388
+ function parsePairingResponse(data: Uint8Array): PairingResponse {
389
+ const response = JSON.parse(
390
+ ccc.bytesTo(data, "utf8"),
391
+ ) as Partial<PairingResponse>;
392
+ if (response.ok === true) {
393
+ return {
394
+ ok: true,
395
+ name: typeof response.name === "string" ? response.name : undefined,
396
+ };
397
+ }
398
+ if (response.ok === false && typeof response.error === "string") {
399
+ return { ok: false, error: response.error };
400
+ }
401
+
402
+ throw new Error("Invalid pairing response");
403
+ }
404
+
405
+ function createSecret() {
406
+ return ccc.bytesTo(crypto.getRandomValues(new Uint8Array(16)), "base64url");
407
+ }
408
+
409
+ function asError(cause: unknown, fallback: string) {
410
+ return cause instanceof Error ? cause : new Error(fallback);
411
+ }
@@ -0,0 +1,41 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ const common = {
4
+ minify: true,
5
+ dts: true,
6
+ platform: "neutral" as const,
7
+ sourcemap: true,
8
+ exports: true,
9
+ };
10
+
11
+ const entry = {
12
+ index: "src/index.ts",
13
+ barrel: "src/barrel.ts",
14
+ } as const;
15
+
16
+ const bundleDeps: string[] = [];
17
+
18
+ export default defineConfig(
19
+ (
20
+ [
21
+ {
22
+ entry,
23
+ deps: {
24
+ onlyBundle: [] as string[],
25
+ },
26
+ format: "esm",
27
+ copy: "./misc/basedirs/dist/*",
28
+ },
29
+ {
30
+ entry,
31
+ deps: {
32
+ alwaysBundle: bundleDeps,
33
+ onlyBundle: bundleDeps,
34
+ },
35
+ format: "cjs",
36
+ outDir: "dist.commonjs",
37
+ copy: "./misc/basedirs/dist.commonjs/*",
38
+ },
39
+ ] as const
40
+ ).map((c) => ({ ...c, ...common })),
41
+ );
package/typedoc.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://typedoc.org/schema.json",
3
+ "entryPoints": ["./src/index.ts"],
4
+ "extends": ["../../typedoc.base.json"],
5
+ "name": "@ckb-ccc/libp2p"
6
+ }