@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,167 @@
1
+ import { ccc, JsonRpcError } from "@ckb-ccc/core";
2
+ import type { Connection, PeerId, Stream } from "@libp2p/interface";
3
+ import type { Registrar } from "@libp2p/interface-internal";
4
+ import { lpStream } from "@libp2p/utils";
5
+
6
+ const DEFAULT_MAX_MESSAGE_LENGTH = 1024 * 1024;
7
+
8
+ export type JsonRpcServiceComponents = {
9
+ registrar: Registrar;
10
+ };
11
+
12
+ export type JsonRpcServiceConfig = {
13
+ protocol: string;
14
+ maxMessageLength?: number;
15
+ };
16
+
17
+ export type JsonRpcRequest = {
18
+ peerId: PeerId;
19
+ payload: ccc.JsonRpcPayload;
20
+ };
21
+
22
+ export type JsonRpcRequestHandler<
23
+ Components extends JsonRpcServiceComponents = JsonRpcServiceComponents,
24
+ > = (this: JsonRpcService<Components>, request: JsonRpcRequest) => unknown;
25
+
26
+ export abstract class JsonRpcService<
27
+ Components extends JsonRpcServiceComponents = JsonRpcServiceComponents,
28
+ > {
29
+ private readonly errorListeners = new Set<(error: Error) => void>();
30
+ readonly maxMessageLength: number;
31
+
32
+ constructor(
33
+ readonly components: Components,
34
+ readonly config: JsonRpcServiceConfig,
35
+ ) {
36
+ const maxMessageLength =
37
+ config.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
38
+ if (!Number.isSafeInteger(maxMessageLength) || maxMessageLength <= 0) {
39
+ throw new Error(
40
+ "Maximum JSON-RPC message length must be a positive integer",
41
+ );
42
+ }
43
+
44
+ this.maxMessageLength = maxMessageLength;
45
+ }
46
+
47
+ protected abstract handleRequest(request: JsonRpcRequest): unknown;
48
+
49
+ onError(listener: (error: Error) => void) {
50
+ this.errorListeners.add(listener);
51
+ return () => this.errorListeners.delete(listener);
52
+ }
53
+
54
+ async start() {
55
+ await this.components.registrar.handle(
56
+ this.config.protocol,
57
+ this.handleProtocol.bind(this),
58
+ { runOnLimitedConnection: true },
59
+ );
60
+ }
61
+
62
+ async stop() {
63
+ await this.components.registrar.unhandle(this.config.protocol);
64
+ }
65
+
66
+ private async handleProtocol(stream: Stream, connection: Connection) {
67
+ let requestAborted = false;
68
+
69
+ try {
70
+ const rpcStream = lpStream(stream, {
71
+ maxDataLength: this.maxMessageLength,
72
+ });
73
+ const payload = parseJsonRpcRequest(
74
+ ccc.bytesTo((await rpcStream.read()).subarray(), "utf8"),
75
+ );
76
+
77
+ let response: ccc.JsonRpcResponse;
78
+ try {
79
+ response = {
80
+ jsonrpc: "2.0",
81
+ id: payload.id,
82
+ result: await this.handleRequest({
83
+ peerId: connection.remotePeer,
84
+ payload,
85
+ }),
86
+ };
87
+ } catch (cause) {
88
+ requestAborted = isAbortError(cause);
89
+ const error = toJsonRpcError(cause);
90
+ response = {
91
+ jsonrpc: "2.0",
92
+ id: payload.id,
93
+ error: {
94
+ code: error.code,
95
+ message: error.message,
96
+ data: error.data,
97
+ },
98
+ };
99
+ }
100
+
101
+ await rpcStream.write(ccc.bytesFrom(JSON.stringify(response), "utf8"));
102
+ await stream.close();
103
+ } catch (cause) {
104
+ if (requestAborted) {
105
+ return;
106
+ }
107
+ this.handleError(cause, stream);
108
+ }
109
+ }
110
+
111
+ private handleError(cause: unknown, stream?: Stream) {
112
+ const error = asJsonRpcError(cause);
113
+
114
+ stream?.abort(error);
115
+ this.errorListeners.forEach((listener) => listener(error));
116
+ return error;
117
+ }
118
+ }
119
+
120
+ function toJsonRpcError(cause: unknown) {
121
+ if (cause instanceof JsonRpcError) {
122
+ return cause;
123
+ }
124
+
125
+ return new JsonRpcError({
126
+ code: -32603,
127
+ message: asJsonRpcError(cause).message,
128
+ });
129
+ }
130
+
131
+ function parseJsonRpcRequest(data: string) {
132
+ const payload = JSON.parse(data) as Partial<ccc.JsonRpcPayload>;
133
+ if (
134
+ payload?.jsonrpc !== "2.0" ||
135
+ (typeof payload.id !== "number" && typeof payload.id !== "string") ||
136
+ typeof payload.method !== "string"
137
+ ) {
138
+ throw new Error("Invalid JSON-RPC request");
139
+ }
140
+
141
+ return payload as ccc.JsonRpcPayload;
142
+ }
143
+
144
+ function asJsonRpcError(cause: unknown) {
145
+ return cause instanceof Error ? cause : new Error("JSON-RPC request failed");
146
+ }
147
+
148
+ function isAbortError(cause: unknown) {
149
+ return (
150
+ cause instanceof Error &&
151
+ (cause.name === "AbortError" || cause.name === "TimeoutError")
152
+ );
153
+ }
154
+
155
+ export function jsonRpcService<
156
+ Components extends JsonRpcServiceComponents = JsonRpcServiceComponents,
157
+ >(
158
+ config: JsonRpcServiceConfig,
159
+ handler: JsonRpcRequestHandler<Components>,
160
+ ): (components: Components) => JsonRpcService<Components> {
161
+ return (components: Components) =>
162
+ new (class extends JsonRpcService<Components> {
163
+ protected handleRequest(request: JsonRpcRequest) {
164
+ return handler.call(this, request);
165
+ }
166
+ })(components, config);
167
+ }
@@ -0,0 +1,85 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import type { Libp2p, PeerId, Stream } from "@libp2p/interface";
3
+ import { lpStream } from "@libp2p/utils";
4
+
5
+ const DEFAULT_MAX_MESSAGE_LENGTH = 1024 * 1024;
6
+ const DEFAULT_TIMEOUT = 120_000;
7
+
8
+ export type JsonRpcTransportLibp2pConfig = {
9
+ protocol: string;
10
+ maxMessageLength?: number;
11
+ timeout?: number;
12
+ signal?: AbortSignal;
13
+ onResponse?: (response: ccc.JsonRpcResponse) => void;
14
+ };
15
+
16
+ export class JsonRpcTransportLibp2p implements ccc.JsonRpcTransport {
17
+ private readonly maxMessageLength: number;
18
+ private readonly timeout: number;
19
+
20
+ constructor(
21
+ private readonly node: Libp2p,
22
+ readonly peerId: PeerId,
23
+ private readonly config: JsonRpcTransportLibp2pConfig,
24
+ ) {
25
+ const maxMessageLength =
26
+ config.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
27
+ if (!Number.isSafeInteger(maxMessageLength) || maxMessageLength <= 0) {
28
+ throw new Error(
29
+ "Maximum JSON-RPC message length must be a positive integer",
30
+ );
31
+ }
32
+
33
+ this.maxMessageLength = maxMessageLength;
34
+
35
+ const timeout = config.timeout ?? DEFAULT_TIMEOUT;
36
+ if (!Number.isSafeInteger(timeout) || timeout <= 0) {
37
+ throw new Error("JSON-RPC request timeout must be a positive integer");
38
+ }
39
+
40
+ this.timeout = timeout;
41
+ }
42
+
43
+ async request(payload: ccc.JsonRpcPayload): Promise<ccc.JsonRpcResponse> {
44
+ const timeoutSignal = AbortSignal.timeout(this.timeout);
45
+ const signal = this.config.signal
46
+ ? ccc.abortSignalAny([this.config.signal, timeoutSignal])
47
+ : timeoutSignal;
48
+
49
+ let stream: Stream | undefined;
50
+ let response: ccc.JsonRpcResponse;
51
+ try {
52
+ // dialProtocol handles the signal, so no separate entry check is needed.
53
+ stream = await this.node.dialProtocol(this.peerId, this.config.protocol, {
54
+ runOnLimitedConnection: true,
55
+ signal,
56
+ });
57
+
58
+ const rpcStream = lpStream(stream, {
59
+ maxDataLength: this.maxMessageLength,
60
+ });
61
+
62
+ await rpcStream.write(ccc.bytesFrom(JSON.stringify(payload), "utf8"), {
63
+ signal,
64
+ });
65
+ await stream.close({ signal });
66
+
67
+ response = JSON.parse(
68
+ ccc.bytesTo((await rpcStream.read({ signal })).subarray(), "utf8"),
69
+ ) as ccc.JsonRpcResponse;
70
+ await stream.closeRead({ signal });
71
+ signal.throwIfAborted();
72
+ } catch (cause) {
73
+ const error = asJsonRpcError(cause);
74
+ stream?.abort(error);
75
+ throw error;
76
+ }
77
+
78
+ this.config.onResponse?.(response);
79
+ return response;
80
+ }
81
+ }
82
+
83
+ function asJsonRpcError(cause: unknown) {
84
+ return cause instanceof Error ? cause : new Error("JSON-RPC request failed");
85
+ }
@@ -0,0 +1,181 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import { multiaddr, type Multiaddr } from "@multiformats/multiaddr";
3
+ import * as lp from "it-length-prefixed";
4
+ import type { PairingTarget } from "./pairingService.js";
5
+
6
+ const MAX_DECOMPRESSED_ADDRESSES_LENGTH = 16 * 1024;
7
+ const MAX_ADDRESSES = 16;
8
+ const PAIRING_PARAMETER_NAMES = ["addresses", "role", "secret"] as const;
9
+
10
+ export class PairingEndpointRoleError extends Error {
11
+ constructor(
12
+ readonly expectedRole: string,
13
+ readonly actualRole: string | undefined,
14
+ ) {
15
+ super(
16
+ `Expected a ${expectedRole} pairing endpoint, received ${actualRole ? `role "${actualRole}"` : "an endpoint without a role"}`,
17
+ );
18
+ this.name = "PairingEndpointRoleError";
19
+ }
20
+ }
21
+
22
+ export async function encodePairingEndpoint(
23
+ endpointUrl: string,
24
+ addresses: readonly Multiaddr[],
25
+ secret: string,
26
+ role?: string,
27
+ ) {
28
+ if (addresses.length === 0) {
29
+ throw new Error("Pairing endpoint requires at least one address");
30
+ }
31
+ if (addresses.length > MAX_ADDRESSES) {
32
+ throw new Error(
33
+ `Pairing endpoint supports at most ${MAX_ADDRESSES} addresses`,
34
+ );
35
+ }
36
+
37
+ const addressBytes = encodeAddresses(addresses);
38
+ if (addressBytes.byteLength > MAX_DECOMPRESSED_ADDRESSES_LENGTH) {
39
+ throw new Error("Pairing endpoint address data is too large");
40
+ }
41
+
42
+ const compressedAddressBytes = await transformBytes(
43
+ addressBytes,
44
+ new CompressionStream("deflate"),
45
+ );
46
+
47
+ const encodedAddresses = ccc.bytesTo(compressedAddressBytes, "base64url");
48
+
49
+ const url = new URL(endpointUrl);
50
+ const { params, route } = fragmentParameters(url);
51
+
52
+ PAIRING_PARAMETER_NAMES.forEach((name) => {
53
+ params.delete(name);
54
+ });
55
+ if (role) {
56
+ params.set("role", role);
57
+ }
58
+ params.set("addresses", encodedAddresses);
59
+ params.set("secret", secret);
60
+ url.hash = `${route}?${params.toString()}`;
61
+
62
+ return url.toString();
63
+ }
64
+
65
+ export async function decodePairingEndpoint(
66
+ endpoint: string,
67
+ expectedRole?: string,
68
+ ): Promise<PairingTarget> {
69
+ const url = new URL(endpoint.trim());
70
+ const { params } = fragmentParameters(url);
71
+ if (expectedRole) {
72
+ const actualRole = params.get("role")?.trim() || undefined;
73
+ if (actualRole !== expectedRole) {
74
+ throw new PairingEndpointRoleError(expectedRole, actualRole);
75
+ }
76
+ }
77
+ const compressedAddresses = params.get("addresses")?.trim();
78
+ const secret = params.get("secret")?.trim();
79
+
80
+ if (!compressedAddresses || !secret) {
81
+ throw new Error("Pairing endpoint is incomplete");
82
+ }
83
+
84
+ const addresses = await decodeCompressedAddresses(compressedAddresses);
85
+
86
+ return { addresses, secret };
87
+ }
88
+
89
+ function fragmentParameters(url: URL) {
90
+ const fragment = url.hash.slice(1);
91
+ const separator = fragment.indexOf("?");
92
+ if (separator === -1) {
93
+ return { params: new URLSearchParams(), route: fragment };
94
+ }
95
+
96
+ return {
97
+ params: new URLSearchParams(fragment.slice(separator + 1)),
98
+ route: fragment.slice(0, separator),
99
+ };
100
+ }
101
+
102
+ function encodeAddresses(addresses: readonly Multiaddr[]): Uint8Array {
103
+ return ccc.bytesConcat(
104
+ ...lp.encode(addresses.map((address) => address.bytes)),
105
+ );
106
+ }
107
+
108
+ async function decodeCompressedAddresses(value: string): Promise<Multiaddr[]> {
109
+ try {
110
+ const compressedBytes = ccc.bytesFrom(value, "base64url");
111
+ const bytes = await transformBytes(
112
+ compressedBytes,
113
+ new DecompressionStream("deflate"),
114
+ MAX_DECOMPRESSED_ADDRESSES_LENGTH,
115
+ );
116
+ const addresses: Multiaddr[] = [];
117
+ for (const address of lp.decode([bytes], {
118
+ maxDataLength: MAX_DECOMPRESSED_ADDRESSES_LENGTH,
119
+ })) {
120
+ if (addresses.length >= MAX_ADDRESSES) {
121
+ throw new Error("Too many addresses");
122
+ }
123
+ addresses.push(multiaddr(address.subarray()));
124
+ }
125
+
126
+ if (addresses.length === 0) {
127
+ throw new Error("Missing addresses");
128
+ }
129
+ return addresses;
130
+ } catch (cause) {
131
+ throw new Error("Pairing endpoint contains invalid compressed addresses", {
132
+ cause,
133
+ });
134
+ }
135
+ }
136
+
137
+ async function transformBytes(
138
+ bytes: Uint8Array,
139
+ transform: CompressionStream | DecompressionStream,
140
+ maxOutputLength?: number,
141
+ ) {
142
+ const readable =
143
+ maxOutputLength === undefined
144
+ ? transform.readable
145
+ : transform.readable.pipeThrough(limitBytes(maxOutputLength));
146
+ const output = new Response(readable).arrayBuffer().then(
147
+ (buffer) => ({ buffer, ok: true }) as const,
148
+ (error: unknown) => ({ error, ok: false }) as const,
149
+ );
150
+ const writer = transform.writable.getWriter();
151
+ try {
152
+ await writer.write(Uint8Array.from(bytes));
153
+ await writer.close();
154
+ } catch (cause) {
155
+ const result = await output;
156
+ if (!result.ok) {
157
+ throw result.error;
158
+ }
159
+ throw cause;
160
+ }
161
+
162
+ const result = await output;
163
+ if (!result.ok) {
164
+ throw result.error;
165
+ }
166
+ return new Uint8Array(result.buffer);
167
+ }
168
+
169
+ function limitBytes(maxLength: number) {
170
+ let length = 0;
171
+
172
+ return new TransformStream<Uint8Array, Uint8Array>({
173
+ transform(chunk, controller) {
174
+ if (chunk.byteLength > maxLength - length) {
175
+ throw new Error("Decompressed address data is too large");
176
+ }
177
+ length += chunk.byteLength;
178
+ controller.enqueue(chunk);
179
+ },
180
+ });
181
+ }