@ckb-ccc/libp2p 0.2.1 → 0.3.1-next.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.
@@ -4,6 +4,7 @@ import type { Registrar } from "@libp2p/interface-internal";
4
4
  import { lpStream } from "@libp2p/utils";
5
5
 
6
6
  const DEFAULT_MAX_MESSAGE_LENGTH = 1024 * 1024;
7
+ const DEFAULT_TIMEOUT = 30_000;
7
8
 
8
9
  export type JsonRpcServiceComponents = {
9
10
  registrar: Registrar;
@@ -12,12 +13,15 @@ export type JsonRpcServiceComponents = {
12
13
  export type JsonRpcServiceConfig = {
13
14
  protocol: string;
14
15
  maxMessageLength?: number;
16
+ timeout?: number;
15
17
  };
16
18
 
17
19
  export type JsonRpcRequest = {
18
20
  connection: Connection;
19
21
  peerId: PeerId;
20
22
  payload: ccc.JsonRpcPayload;
23
+ /** Aborts if the service times out or the request stream closes. */
24
+ signal: AbortSignal;
21
25
  };
22
26
 
23
27
  export type JsonRpcRequestHandler<
@@ -29,6 +33,7 @@ export abstract class JsonRpcService<
29
33
  > {
30
34
  private readonly errorListeners = new Set<(error: Error) => void>();
31
35
  readonly maxMessageLength: number;
36
+ readonly timeout: number;
32
37
 
33
38
  constructor(
34
39
  readonly components: Components,
@@ -43,6 +48,13 @@ export abstract class JsonRpcService<
43
48
  }
44
49
 
45
50
  this.maxMessageLength = maxMessageLength;
51
+
52
+ const timeout = config.timeout ?? DEFAULT_TIMEOUT;
53
+ if (!Number.isSafeInteger(timeout) || timeout <= 0) {
54
+ throw new Error("JSON-RPC request timeout must be a positive integer");
55
+ }
56
+
57
+ this.timeout = timeout;
46
58
  }
47
59
 
48
60
  protected abstract handleRequest(request: JsonRpcRequest): unknown;
@@ -66,28 +78,57 @@ export abstract class JsonRpcService<
66
78
 
67
79
  private async handleProtocol(stream: Stream, connection: Connection) {
68
80
  let requestAborted = false;
81
+ const controller = new AbortController();
82
+ const timeoutSignal = AbortSignal.timeout(this.timeout);
83
+ const abortRequest = (event: { error?: Error }) => {
84
+ requestAborted = true;
85
+ const error = new Error(event.error?.message ?? "JSON-RPC stream closed");
86
+ error.name = "AbortError";
87
+ controller.abort(error);
88
+ };
89
+ const abortTimeout = () => {
90
+ requestAborted = true;
91
+ const error = abortReason(timeoutSignal, "JSON-RPC request timed out");
92
+ controller.abort(error);
93
+ stream.abort(error);
94
+ };
95
+ stream.addEventListener("close", abortRequest, { once: true });
96
+ timeoutSignal.addEventListener("abort", abortTimeout, { once: true });
97
+ if (stream.status !== "open") {
98
+ abortRequest({});
99
+ }
69
100
 
70
101
  try {
71
102
  const rpcStream = lpStream(stream, {
72
103
  maxDataLength: this.maxMessageLength,
73
104
  });
74
105
  const payload = parseJsonRpcRequest(
75
- ccc.bytesTo((await rpcStream.read()).subarray(), "utf8"),
106
+ ccc.bytesTo(
107
+ (await rpcStream.read({ signal: controller.signal })).subarray(),
108
+ "utf8",
109
+ ),
76
110
  );
77
111
 
78
112
  let response: ccc.JsonRpcResponse;
79
113
  try {
114
+ controller.signal.throwIfAborted();
80
115
  response = {
81
116
  jsonrpc: "2.0",
82
117
  id: payload.id,
83
- result: await this.handleRequest({
84
- connection,
85
- peerId: connection.remotePeer,
86
- payload,
87
- }),
118
+ result: await Promise.race([
119
+ Promise.resolve(
120
+ this.handleRequest({
121
+ connection,
122
+ peerId: connection.remotePeer,
123
+ payload,
124
+ signal: controller.signal,
125
+ }),
126
+ ),
127
+ ccc.abortSignalToPromise(controller.signal),
128
+ ]),
88
129
  };
89
130
  } catch (cause) {
90
- requestAborted = isAbortError(cause);
131
+ requestAborted ||= isAbortError(cause);
91
132
  const error = toJsonRpcError(cause);
92
133
  response = {
93
134
  jsonrpc: "2.0",
@@ -100,13 +141,19 @@ export abstract class JsonRpcService<
100
141
  };
101
142
  }
102
143
 
103
- await rpcStream.write(ccc.bytesFrom(JSON.stringify(response), "utf8"));
104
- await stream.close();
144
+ await rpcStream.write(ccc.bytesFrom(JSON.stringify(response), "utf8"), {
145
+ signal: controller.signal,
146
+ });
147
+ stream.removeEventListener("close", abortRequest);
148
+ await stream.close({ signal: controller.signal });
105
149
  } catch (cause) {
106
150
  if (requestAborted) {
107
151
  return;
108
152
  }
109
153
  this.handleError(cause, stream);
154
+ } finally {
155
+ stream.removeEventListener("close", abortRequest);
156
+ timeoutSignal.removeEventListener("abort", abortTimeout);
110
157
  }
111
158
  }
112
159
 
@@ -147,6 +194,10 @@ function asJsonRpcError(cause: unknown) {
147
194
  return cause instanceof Error ? cause : new Error("JSON-RPC request failed");
148
195
  }
149
196
 
197
+ function abortReason(signal: AbortSignal, fallback: string) {
198
+ return signal.reason instanceof Error ? signal.reason : new Error(fallback);
199
+ }
200
+
150
201
  function isAbortError(cause: unknown) {
151
202
  return (
152
203
  cause instanceof Error &&
@@ -4,7 +4,7 @@ import { lpStream } from "@libp2p/utils";
4
4
  import { dialKnownAddresses } from "./dial.js";
5
5
 
6
6
  const DEFAULT_MAX_MESSAGE_LENGTH = 1024 * 1024;
7
- const DEFAULT_TIMEOUT = 120_000;
7
+ const DEFAULT_TIMEOUT = 30_000;
8
8
 
9
9
  export type JsonRpcTransportLibp2pConfig = {
10
10
  protocol: string;
@@ -41,11 +41,15 @@ export class JsonRpcTransportLibp2p implements ccc.JsonRpcTransport {
41
41
  this.timeout = timeout;
42
42
  }
43
43
 
44
- async request(payload: ccc.JsonRpcPayload): Promise<ccc.JsonRpcResponse> {
45
- const timeoutSignal = AbortSignal.timeout(this.timeout);
46
- const signal = this.config.signal
47
- ? ccc.abortSignalAny([this.config.signal, timeoutSignal])
48
- : timeoutSignal;
44
+ async request(
45
+ payload: ccc.JsonRpcPayload,
46
+ options?: ccc.JsonRpcTransportRequestOptions,
47
+ ): Promise<ccc.JsonRpcResponse> {
48
+ const timeoutSignal = AbortSignal.timeout(options?.timeout ?? this.timeout);
49
+ const signals = [this.config.signal, options?.signal, timeoutSignal].filter(
50
+ (signal): signal is AbortSignal => signal !== undefined,
51
+ );
52
+ const signal = ccc.abortSignalAny(signals);
49
53
 
50
54
  let stream: Stream | undefined;
51
55
  let response: ccc.JsonRpcResponse;
@@ -5,19 +5,14 @@ import type {
5
5
  PeerId,
6
6
  Stream,
7
7
  } from "@libp2p/interface";
8
- import type {
9
- AddressManager,
10
- ConnectionManager,
11
- Registrar,
12
- } from "@libp2p/interface-internal";
8
+ import type { ConnectionManager, Registrar } from "@libp2p/interface-internal";
13
9
  import { lpStream } from "@libp2p/utils";
14
- import { CODE_P2P, multiaddr, type Multiaddr } from "@multiformats/multiaddr";
10
+ import type { Multiaddr } from "@multiformats/multiaddr";
15
11
 
16
12
  const MAX_PAIRING_MESSAGE_LENGTH = 1024;
17
13
  const UNPAIR_TIMEOUT_MS = 10_000;
18
14
 
19
15
  export type PairingServiceComponents = {
20
- addressManager?: Pick<AddressManager, "getAddresses">;
21
16
  connectionManager: ConnectionManager;
22
17
  registrar: Registrar;
23
18
  };
@@ -39,8 +34,7 @@ export type PairingGuard<
39
34
  > = (this: PairingService<Components>, peerId: PeerId) => boolean;
40
35
 
41
36
  type PairingRequest =
42
- | { type: "pair"; secret: string; name?: string; addresses?: string[] }
43
- | { type: "unpair" };
37
+ { type: "pair"; secret: string; name?: string } | { type: "unpair" };
44
38
 
45
39
  type PairingResponse =
46
40
  { ok: true; name?: string } | { ok: false; error: string };
@@ -147,7 +141,7 @@ export abstract class PairingService<
147
141
 
148
142
  const response = await exchange(
149
143
  stream,
150
- createPairRequest(target.secret, this.config.name, this.addresses()),
144
+ createPairRequest(target.secret, this.config.name),
151
145
  options,
152
146
  );
153
147
  options.signal?.throwIfAborted();
@@ -223,11 +217,6 @@ export abstract class PairingService<
223
217
  return;
224
218
  }
225
219
 
226
- const addresses = parseAddresses(
227
- request.addresses,
228
- connection.remotePeer,
229
- );
230
-
231
220
  // Consume the credential before the next asynchronous boundary so two
232
221
  // concurrent requests cannot both pair with the same endpoint.
233
222
  this.rotateSecret();
@@ -241,7 +230,6 @@ export abstract class PairingService<
241
230
  });
242
231
  if (addedPeer) {
243
232
  this.notifyPairedPeer(addedPeer, request.name);
244
- void this.openFallbackConnection(addresses);
245
233
  }
246
234
  } catch (cause) {
247
235
  if (addedPeer) {
@@ -266,22 +254,6 @@ export abstract class PairingService<
266
254
  this.pairedListeners.forEach((listener) => listener(peerId, name));
267
255
  }
268
256
 
269
- private addresses() {
270
- return this.components.addressManager?.getAddresses() ?? [];
271
- }
272
-
273
- private async openFallbackConnection(addresses: Multiaddr[]) {
274
- if (addresses.length === 0) {
275
- return;
276
- }
277
-
278
- try {
279
- await this.components.connectionManager.openConnection(addresses);
280
- } catch {
281
- // The original pairing connection remains usable when the fallback fails.
282
- }
283
- }
284
-
285
257
  private rotateSecret() {
286
258
  this.currentSecret = createSecret();
287
259
  this.secretChangedListeners.forEach((listener) =>
@@ -392,22 +364,12 @@ async function exchange(
392
364
  function createPairRequest(
393
365
  secret: string,
394
366
  name: string | undefined,
395
- addresses: Multiaddr[],
396
367
  ): PairingRequest {
397
- const addressStrings = addresses.map((address) => address.toString());
398
368
  const request: PairingRequest = {
399
369
  type: "pair",
400
370
  secret,
401
371
  name,
402
- addresses: addressStrings,
403
372
  };
404
- while (addressStrings.length > 0 && !pairingRequestFits(request)) {
405
- addressStrings.pop();
406
- }
407
-
408
- if (!pairingRequestFits(request)) {
409
- delete request.addresses;
410
- }
411
373
  if (!pairingRequestFits(request)) {
412
374
  delete request.name;
413
375
  }
@@ -446,7 +408,6 @@ function parsePairingRequest(data: Uint8Array): PairingRequest {
446
408
  type: "pair",
447
409
  secret: request.secret,
448
410
  name: typeof request.name === "string" ? request.name : undefined,
449
- addresses: parseAddressStrings(request.addresses),
450
411
  };
451
412
  }
452
413
 
@@ -457,31 +418,6 @@ function parsePairingRequest(data: Uint8Array): PairingRequest {
457
418
  throw new Error("Invalid pairing request");
458
419
  }
459
420
 
460
- function parseAddressStrings(value: unknown): string[] | undefined {
461
- if (!Array.isArray(value)) {
462
- return undefined;
463
- }
464
- return value.filter(
465
- (address): address is string =>
466
- typeof address === "string" && address.length > 0,
467
- );
468
- }
469
-
470
- function parseAddresses(values: string[] | undefined, peerId: PeerId) {
471
- return (values ?? []).flatMap((value) => {
472
- try {
473
- const address = multiaddr(value);
474
- const peer = address
475
- .getComponents()
476
- .filter(({ code }) => code === CODE_P2P)
477
- .at(-1)?.value;
478
- return peer === peerId.toString() ? [address] : [];
479
- } catch {
480
- return [];
481
- }
482
- });
483
- }
484
-
485
421
  function parsePairingResponse(data: Uint8Array): PairingResponse {
486
422
  const response = JSON.parse(
487
423
  ccc.bytesTo(data, "utf8"),
@@ -0,0 +1,120 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import type { Connection, Libp2p } from "@libp2p/interface";
3
+ import { multiaddr, type Multiaddr } from "@multiformats/multiaddr";
4
+
5
+ const DIAL_TIMEOUT_MS = 10_000;
6
+ const RETRY_DELAYS_MS = [5_000, 10_000, 20_000] as const;
7
+ const RETRY_REPEAT_MS = 30_000;
8
+
9
+ export type RelayConnectionControllerOptions = {
10
+ onConnectionChange?: (connection?: Connection) => void;
11
+ };
12
+
13
+ /** Maintains a connection to a relay until stopped. */
14
+ export class RelayConnectionController {
15
+ private readonly controller = new AbortController();
16
+ private readonly addresses: readonly Multiaddr[];
17
+ private selectedAddress?: Multiaddr;
18
+ private connection?: Connection;
19
+ private connecting?: Promise<Connection>;
20
+
21
+ constructor(
22
+ private readonly node: Libp2p,
23
+ addresses: readonly string[],
24
+ private readonly options: RelayConnectionControllerOptions = {},
25
+ ) {
26
+ if (addresses.length === 0) {
27
+ throw new Error("At least one relay address is required");
28
+ }
29
+ this.addresses = addresses.map((address) => multiaddr(address.trim()));
30
+ node.addEventListener("connection:close", this.handleConnectionClose, {
31
+ signal: this.controller.signal,
32
+ });
33
+ }
34
+
35
+ connect(): Promise<Connection> {
36
+ this.controller.signal.throwIfAborted();
37
+ if (this.connection?.status === "open") {
38
+ return Promise.resolve(this.connection);
39
+ }
40
+ if (this.connecting) {
41
+ return this.connecting;
42
+ }
43
+
44
+ this.connection = undefined;
45
+ const connecting = ccc
46
+ .retry<Connection>(
47
+ RETRY_DELAYS_MS,
48
+ async ({ resolve }) => {
49
+ return resolve(await this.dialRelay());
50
+ },
51
+ { repeat: RETRY_REPEAT_MS, signal: this.controller.signal },
52
+ )
53
+ .finally(() => {
54
+ if (this.connecting === connecting) {
55
+ this.connecting = undefined;
56
+ }
57
+ if (!this.controller.signal.aborted && !this.connection) {
58
+ void this.connect().catch(() => {});
59
+ }
60
+ });
61
+ this.connecting = connecting;
62
+ return connecting;
63
+ }
64
+
65
+ async stop() {
66
+ if (this.controller.signal.aborted) {
67
+ return;
68
+ }
69
+
70
+ this.controller.abort();
71
+ const connection = this.connection;
72
+ this.connection = undefined;
73
+ this.options.onConnectionChange?.();
74
+ await connection?.close();
75
+ }
76
+
77
+ private async dialRelay() {
78
+ const addresses = this.selectedAddress
79
+ ? [this.selectedAddress]
80
+ : this.addresses;
81
+ let lastError: unknown;
82
+
83
+ for (const address of addresses) {
84
+ await ccc.waitForAvailability(this.controller.signal);
85
+ let connection: Connection | undefined;
86
+ try {
87
+ connection = await this.node.dial(address, {
88
+ signal: ccc.abortSignalAny([
89
+ this.controller.signal,
90
+ AbortSignal.timeout(DIAL_TIMEOUT_MS),
91
+ ]),
92
+ });
93
+ this.controller.signal.throwIfAborted();
94
+ this.connection = connection;
95
+ if (connection.status !== "open") {
96
+ this.connection = undefined;
97
+ throw new Error("Relay connection closed while dialing");
98
+ }
99
+ this.selectedAddress = address;
100
+ this.options.onConnectionChange?.(connection);
101
+ return connection;
102
+ } catch (cause) {
103
+ lastError = cause;
104
+ await connection?.close();
105
+ }
106
+ }
107
+
108
+ throw lastError;
109
+ }
110
+
111
+ private readonly handleConnectionClose = (event: CustomEvent<Connection>) => {
112
+ if (event.detail.id !== this.connection?.id) {
113
+ return;
114
+ }
115
+
116
+ this.connection = undefined;
117
+ this.options.onConnectionChange?.();
118
+ void this.connect().catch(() => {});
119
+ };
120
+ }