@enbox/dwn-clients 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.
Files changed (62) hide show
  1. package/README.md +73 -0
  2. package/dist/esm/dwn-registrar.js +119 -0
  3. package/dist/esm/dwn-registrar.js.map +1 -0
  4. package/dist/esm/dwn-rpc-types.js +2 -0
  5. package/dist/esm/dwn-rpc-types.js.map +1 -0
  6. package/dist/esm/dwn-server-info-cache-memory.js +74 -0
  7. package/dist/esm/dwn-server-info-cache-memory.js.map +1 -0
  8. package/dist/esm/http-dwn-rpc-client.js +115 -0
  9. package/dist/esm/http-dwn-rpc-client.js.map +1 -0
  10. package/dist/esm/index.js +12 -0
  11. package/dist/esm/index.js.map +1 -0
  12. package/dist/esm/json-rpc-socket.js +175 -0
  13. package/dist/esm/json-rpc-socket.js.map +1 -0
  14. package/dist/esm/json-rpc.js +77 -0
  15. package/dist/esm/json-rpc.js.map +1 -0
  16. package/dist/esm/registration-types.js +2 -0
  17. package/dist/esm/registration-types.js.map +1 -0
  18. package/dist/esm/rpc-client.js +123 -0
  19. package/dist/esm/rpc-client.js.map +1 -0
  20. package/dist/esm/server-info-types.js +2 -0
  21. package/dist/esm/server-info-types.js.map +1 -0
  22. package/dist/esm/utils.js +13 -0
  23. package/dist/esm/utils.js.map +1 -0
  24. package/dist/esm/web-socket-clients.js +90 -0
  25. package/dist/esm/web-socket-clients.js.map +1 -0
  26. package/dist/types/dwn-registrar.d.ts +28 -0
  27. package/dist/types/dwn-registrar.d.ts.map +1 -0
  28. package/dist/types/dwn-rpc-types.d.ts +45 -0
  29. package/dist/types/dwn-rpc-types.d.ts.map +1 -0
  30. package/dist/types/dwn-server-info-cache-memory.d.ts +57 -0
  31. package/dist/types/dwn-server-info-cache-memory.d.ts.map +1 -0
  32. package/dist/types/http-dwn-rpc-client.d.ts +13 -0
  33. package/dist/types/http-dwn-rpc-client.d.ts.map +1 -0
  34. package/dist/types/index.d.ts +12 -0
  35. package/dist/types/index.d.ts.map +1 -0
  36. package/dist/types/json-rpc-socket.d.ts +40 -0
  37. package/dist/types/json-rpc-socket.d.ts.map +1 -0
  38. package/dist/types/json-rpc.d.ts +64 -0
  39. package/dist/types/json-rpc.d.ts.map +1 -0
  40. package/dist/types/registration-types.d.ts +25 -0
  41. package/dist/types/registration-types.d.ts.map +1 -0
  42. package/dist/types/rpc-client.d.ts +51 -0
  43. package/dist/types/rpc-client.d.ts.map +1 -0
  44. package/dist/types/server-info-types.d.ts +28 -0
  45. package/dist/types/server-info-types.d.ts.map +1 -0
  46. package/dist/types/utils.d.ts +3 -0
  47. package/dist/types/utils.d.ts.map +1 -0
  48. package/dist/types/web-socket-clients.d.ts +10 -0
  49. package/dist/types/web-socket-clients.d.ts.map +1 -0
  50. package/package.json +66 -0
  51. package/src/dwn-registrar.ts +129 -0
  52. package/src/dwn-rpc-types.ts +55 -0
  53. package/src/dwn-server-info-cache-memory.ts +80 -0
  54. package/src/http-dwn-rpc-client.ts +122 -0
  55. package/src/index.ts +11 -0
  56. package/src/json-rpc-socket.ts +198 -0
  57. package/src/json-rpc.ts +142 -0
  58. package/src/registration-types.ts +26 -0
  59. package/src/rpc-client.ts +160 -0
  60. package/src/server-info-types.ts +29 -0
  61. package/src/utils.ts +14 -0
  62. package/src/web-socket-clients.ts +107 -0
@@ -0,0 +1,198 @@
1
+ import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse } from './json-rpc.js';
2
+
3
+ import { CryptoUtils } from '@enbox/crypto';
4
+ import { createJsonRpcSubscriptionRequest, parseJson } from './json-rpc.js';
5
+
6
+ /**
7
+ * Converts WebSocket message data to a string.
8
+ * Bun's native WebSocket delivers `event.data` as an `ArrayBuffer`,
9
+ * whereas Node.js `ws` delivers it as a `string`.
10
+ */
11
+ function toText(data: unknown): string {
12
+ if (typeof data === 'string') {
13
+ return data;
14
+ }
15
+ if (data instanceof ArrayBuffer) {
16
+ return new TextDecoder().decode(data);
17
+ }
18
+ // Buffer / Uint8Array fallback
19
+ if (data instanceof Uint8Array) {
20
+ return new TextDecoder().decode(data);
21
+ }
22
+ return String(data);
23
+ }
24
+
25
+ // These were arbitrarily chosen, but can be modified via connect options
26
+ const CONNECT_TIMEOUT = 3_000;
27
+ const RESPONSE_TIMEOUT = 30_000;
28
+
29
+ export interface JsonRpcSocketOptions {
30
+ /** socket connection timeout in milliseconds */
31
+ connectTimeout?: number;
32
+ /** response timeout for rpc requests in milliseconds */
33
+ responseTimeout?: number;
34
+ /** optional connection close handler */
35
+ onclose?: () => void;
36
+ /** optional socket error handler */
37
+ onerror?: (error?: any) => void;
38
+ }
39
+
40
+ /**
41
+ * JSON RPC Socket Client for WebSocket request/response and long-running subscriptions.
42
+ */
43
+ export class JsonRpcSocket {
44
+ private messageHandlers: Map<JsonRpcId, (event: { data: any }) => void> = new Map();
45
+
46
+ private constructor(private socket: WebSocket, private responseTimeout: number) {}
47
+
48
+ public static async connect(url: string, options: JsonRpcSocketOptions = {}): Promise<JsonRpcSocket> {
49
+ const { connectTimeout = CONNECT_TIMEOUT, responseTimeout = RESPONSE_TIMEOUT, onclose, onerror } = options;
50
+
51
+ const socket = new WebSocket(url);
52
+
53
+ if (!onclose) {
54
+ socket.onclose = ():void => {
55
+ console.info(`JSON RPC Socket close ${url}`);
56
+ };
57
+ } else {
58
+ socket.onclose = onclose;
59
+ }
60
+
61
+ if (!onerror) {
62
+ socket.onerror = (error?: any):void => {
63
+ console.error(`JSON RPC Socket error ${url}`, error);
64
+ };
65
+ } else {
66
+ socket.onerror = onerror;
67
+ }
68
+
69
+ return new Promise<JsonRpcSocket>((resolve, reject) => {
70
+ socket.addEventListener('open', () => {
71
+ const jsonRpcSocket = new JsonRpcSocket(socket, responseTimeout);
72
+
73
+ socket.addEventListener('message', (event: { data: any }) => {
74
+ const jsonRpcResponse = parseJson(toText(event.data)) as JsonRpcResponse;
75
+ const handler = jsonRpcSocket.messageHandlers.get(jsonRpcResponse.id);
76
+ if (handler) {
77
+ handler(event);
78
+ }
79
+ });
80
+
81
+ resolve(jsonRpcSocket);
82
+ });
83
+
84
+ socket.addEventListener('error', (error: any) => {
85
+ reject(error);
86
+ });
87
+
88
+ setTimeout(() => reject(new Error('connect timed out')), connectTimeout);
89
+ });
90
+ }
91
+
92
+ public close(): void {
93
+ this.socket.close();
94
+ }
95
+
96
+ /**
97
+ * Sends a JSON-RPC request through the socket and waits for a single response.
98
+ */
99
+ public async request(request: JsonRpcRequest): Promise<JsonRpcResponse> {
100
+ return new Promise((resolve, reject) => {
101
+ request.id ??= CryptoUtils.randomUuid();
102
+
103
+ const handleResponse = (event: { data: any }):void => {
104
+ const jsonRpsResponse = parseJson(toText(event.data)) as JsonRpcResponse;
105
+ if (jsonRpsResponse.id === request.id) {
106
+ // if the incoming response id matches the request id, we will remove the listener and resolve the response
107
+ this.messageHandlers.delete(request.id);
108
+ return resolve(jsonRpsResponse);
109
+ }
110
+ };
111
+
112
+ // add the listener to the map of message handlers
113
+ this.messageHandlers.set(request.id!, handleResponse);
114
+ this.send(request);
115
+
116
+ // reject this promise if we don't receive any response back within the timeout period
117
+ setTimeout(() => {
118
+ this.messageHandlers.delete(request.id!);
119
+ reject(new Error('request timed out'));
120
+ }, this.responseTimeout);
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Sends a JSON-RPC request through the socket and keeps a listener open to read associated responses as they arrive.
126
+ * Returns a close method to clean up the listener.
127
+ */
128
+ public async subscribe(request: JsonRpcRequest, listener: (response: JsonRpcResponse) => void): Promise<{
129
+ response: JsonRpcResponse;
130
+ close?: () => Promise<void>;
131
+ }> {
132
+
133
+ if (!request.method.startsWith('rpc.subscribe.')) {
134
+ throw new Error('subscribe rpc requests must include the `rpc.subscribe` prefix');
135
+ }
136
+
137
+ if (!request.subscription) {
138
+ throw new Error('subscribe rpc requests must include subscribe options');
139
+ }
140
+
141
+ const subscriptionId = request.subscription.id;
142
+
143
+ // Preserve any existing handler for this subscriptionId so that a rejected
144
+ // duplicate-subscribe attempt does not clobber an active subscription.
145
+ const existingHandler = this.messageHandlers.get(subscriptionId);
146
+
147
+ const socketEventListener = (event: { data: any }):void => {
148
+ const jsonRpcResponse = parseJson(toText(event.data)) as JsonRpcResponse;
149
+ if (jsonRpcResponse.id === subscriptionId) {
150
+ if (jsonRpcResponse.error !== undefined) {
151
+ // remove the event listener upon receipt of a JSON RPC Error.
152
+ this.messageHandlers.delete(subscriptionId);
153
+ this.closeSubscription(subscriptionId).catch(() => {
154
+ // swallow timeout errors; the subscription is already cleaned up locally.
155
+ });
156
+ }
157
+ listener(jsonRpcResponse);
158
+ }
159
+ };
160
+
161
+ this.messageHandlers.set(subscriptionId, socketEventListener);
162
+
163
+ const response = await this.request(request);
164
+ if (response.error) {
165
+ // Restore the previous handler if one existed, otherwise clean up.
166
+ if (existingHandler) {
167
+ this.messageHandlers.set(subscriptionId, existingHandler);
168
+ } else {
169
+ this.messageHandlers.delete(subscriptionId);
170
+ }
171
+ return { response };
172
+ }
173
+
174
+ // clean up listener and create a `rpc.subscribe.close` message to use when closing this JSON RPC subscription
175
+ const close = async (): Promise<void> => {
176
+ this.messageHandlers.delete(subscriptionId);
177
+ await this.closeSubscription(subscriptionId);
178
+ };
179
+
180
+ return {
181
+ response,
182
+ close
183
+ };
184
+ }
185
+
186
+ private closeSubscription(id: JsonRpcId): Promise<JsonRpcResponse> {
187
+ const requestId = CryptoUtils.randomUuid();
188
+ const request = createJsonRpcSubscriptionRequest(requestId, 'rpc.subscribe.close', {}, id);
189
+ return this.request(request);
190
+ }
191
+
192
+ /**
193
+ * Sends a JSON-RPC request through the socket. You must subscribe to a message listener separately to capture the response.
194
+ */
195
+ public send(request: JsonRpcRequest):void {
196
+ this.socket.send(JSON.stringify(request));
197
+ }
198
+ }
@@ -0,0 +1,142 @@
1
+ export type JsonRpcId = string | number | null;
2
+ export type JsonRpcParams = any;
3
+ export type JsonRpcVersion = '2.0';
4
+
5
+ export interface JsonRpcRequest {
6
+ jsonrpc: JsonRpcVersion;
7
+ id?: JsonRpcId;
8
+ method: string;
9
+ params?: any;
10
+ /** JSON RPC Subscription Extension Parameters */
11
+ subscription?: { id: JsonRpcId };
12
+ }
13
+
14
+ export interface JsonRpcError {
15
+ code: JsonRpcErrorCodes;
16
+ message: string;
17
+ data?: any;
18
+ }
19
+
20
+ export interface JsonRpcSubscription {
21
+ /** JSON RPC Id of the Subscription Request */
22
+ id: JsonRpcId;
23
+ close: () => Promise<void>;
24
+ }
25
+
26
+ export enum JsonRpcErrorCodes {
27
+ // JSON-RPC 2.0 pre-defined errors
28
+ InvalidRequest = -32600,
29
+ MethodNotFound = -32601,
30
+ InvalidParams = -32602,
31
+ InternalError = -32603,
32
+ ParseError = -32700,
33
+ TransportError = -32300,
34
+
35
+ // App defined errors
36
+ BadRequest = -50400, // equivalent to HTTP Status 400
37
+ Unauthorized = -50401, // equivalent to HTTP Status 401
38
+ Forbidden = -50403, // equivalent to HTTP Status 403
39
+ Conflict = -50409, // equivalent to HTTP Status 409
40
+ }
41
+
42
+ export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
43
+
44
+ export interface JsonRpcSuccessResponse {
45
+ jsonrpc: JsonRpcVersion;
46
+ id: JsonRpcId;
47
+ result: any;
48
+ error?: never;
49
+ }
50
+
51
+ export interface JsonRpcErrorResponse {
52
+ jsonrpc: JsonRpcVersion;
53
+ id: JsonRpcId;
54
+ result?: never;
55
+ error: JsonRpcError;
56
+ }
57
+
58
+ export const createJsonRpcErrorResponse = (
59
+ id: JsonRpcId,
60
+ code: JsonRpcErrorCodes,
61
+ message: string,
62
+ data?: any,
63
+ ): JsonRpcErrorResponse => {
64
+ const error: JsonRpcError = { code, message };
65
+ if (data != undefined) {
66
+ error.data = data;
67
+ }
68
+ return {
69
+ jsonrpc: '2.0',
70
+ id,
71
+ error,
72
+ };
73
+ };
74
+
75
+ export const createJsonRpcNotification = (
76
+ method: string,
77
+ params?: any,
78
+ ): JsonRpcRequest => {
79
+ return {
80
+ jsonrpc: '2.0',
81
+ method,
82
+ params,
83
+ };
84
+ };
85
+
86
+ export const createJsonRpcRequest = (
87
+ id: JsonRpcId,
88
+ method: string,
89
+ params?: JsonRpcParams,
90
+ ): JsonRpcRequest => {
91
+ return {
92
+ jsonrpc: '2.0',
93
+ id,
94
+ method,
95
+ params,
96
+ };
97
+ };
98
+
99
+ /**
100
+ * Creates a JSON-RPC subscription request.
101
+ *
102
+ * The caller is responsible for providing the full method name including
103
+ * any `rpc.subscribe.` prefix.
104
+ */
105
+ export const createJsonRpcSubscriptionRequest = (
106
+ id: JsonRpcId,
107
+ method: string,
108
+ params?: any,
109
+ subscriptionId?: JsonRpcId,
110
+ ): JsonRpcRequest => {
111
+ return {
112
+ jsonrpc : '2.0',
113
+ id,
114
+ method,
115
+ params,
116
+ subscription : {
117
+ id: subscriptionId ?? null,
118
+ }
119
+ };
120
+ };
121
+
122
+ export const createJsonRpcSuccessResponse = (
123
+ id: JsonRpcId,
124
+ result?: any,
125
+ ): JsonRpcSuccessResponse => {
126
+ return {
127
+ jsonrpc : '2.0',
128
+ id,
129
+ result : result ?? null,
130
+ };
131
+ };
132
+
133
+ /**
134
+ * Safely parses a JSON string, returning `null` on failure instead of throwing.
135
+ */
136
+ export function parseJson(text: string): object | null {
137
+ try {
138
+ return JSON.parse(text);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Proof-of-work challenge returned by `GET /registration/proof-of-work`.
3
+ */
4
+ export type ProofOfWorkChallengeModel = {
5
+ challengeNonce: string;
6
+ maximumAllowedHashValue: string;
7
+ };
8
+
9
+ /**
10
+ * Registration data included in the `POST /registration` request body.
11
+ */
12
+ export type RegistrationData = {
13
+ did: string;
14
+ termsOfServiceHash: string;
15
+ };
16
+
17
+ /**
18
+ * Full registration request body for `POST /registration`.
19
+ */
20
+ export type RegistrationRequest = {
21
+ proofOfWork: {
22
+ challengeNonce: string;
23
+ responseNonce: string;
24
+ },
25
+ registrationData: RegistrationData
26
+ };
@@ -0,0 +1,160 @@
1
+ import type { JsonRpcResponse } from './json-rpc.js';
2
+ import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './dwn-rpc-types.js';
3
+ import type { DwnServerInfoRpc, ServerInfo } from './server-info-types.js';
4
+
5
+ import { createJsonRpcRequest } from './json-rpc.js';
6
+ import { CryptoUtils } from '@enbox/crypto';
7
+ import { HttpDwnRpcClient } from './http-dwn-rpc-client.js';
8
+ import { WebSocketDwnRpcClient } from './web-socket-clients.js';
9
+
10
+ /**
11
+ * Interface that can be implemented to communicate with {@link Web5Agent | Web5 Agent}
12
+ * implementations via JSON-RPC.
13
+ */
14
+ export interface DidRpc {
15
+ get transportProtocols(): string[]
16
+ sendDidRequest(request: DidRpcRequest): Promise<DidRpcResponse>
17
+ }
18
+
19
+ export enum DidRpcMethod {
20
+ Create = 'did.create',
21
+ Resolve = 'did.resolve'
22
+ }
23
+
24
+ export type DidRpcRequest = {
25
+ data: string;
26
+ method: DidRpcMethod;
27
+ url: string;
28
+ };
29
+
30
+ export type DidRpcResponse = {
31
+ data?: string;
32
+ ok: boolean;
33
+ status: RpcStatus;
34
+ };
35
+
36
+ export type RpcStatus = {
37
+ code: number;
38
+ message: string;
39
+ };
40
+
41
+ export interface Web5Rpc extends DwnRpc, DidRpc, DwnServerInfoRpc {}
42
+
43
+ /**
44
+ * Client used to communicate with Dwn Servers
45
+ */
46
+ export class Web5RpcClient implements Web5Rpc {
47
+ private transportClients: Map<string, Web5Rpc>;
48
+
49
+ constructor(clients: Web5Rpc[] = []) {
50
+ this.transportClients = new Map();
51
+
52
+ // include http and socket clients as default.
53
+ // can be overwritten for 'http:', 'https:', 'ws: or ':wss' if instantiated with other clients.
54
+ clients = [new HttpWeb5RpcClient(), new WebSocketWeb5RpcClient(), ...clients];
55
+
56
+ for (const client of clients) {
57
+ for (const transportScheme of client.transportProtocols) {
58
+ this.transportClients.set(transportScheme, client);
59
+ }
60
+ }
61
+ }
62
+
63
+ get transportProtocols(): string[] {
64
+ return Array.from(this.transportClients.keys());
65
+ }
66
+
67
+ async sendDidRequest(request: DidRpcRequest): Promise<DidRpcResponse> {
68
+ // URL() will throw if provided `url` is invalid.
69
+ const url = new URL(request.url);
70
+
71
+ const transportClient = this.transportClients.get(url.protocol);
72
+ if (!transportClient) {
73
+ const error = new Error(`no ${url.protocol} transport client available`);
74
+ error.name = 'NO_TRANSPORT_CLIENT';
75
+
76
+ throw error;
77
+ }
78
+
79
+ return transportClient.sendDidRequest(request);
80
+ }
81
+
82
+ sendDwnRequest(request: DwnRpcRequest): Promise<DwnRpcResponse> {
83
+ // will throw if url is invalid
84
+ const url = new URL(request.dwnUrl);
85
+
86
+ const transportClient = this.transportClients.get(url.protocol);
87
+ if (!transportClient) {
88
+ const error = new Error(`no ${url.protocol} transport client available`);
89
+ error.name = 'NO_TRANSPORT_CLIENT';
90
+
91
+ throw error;
92
+ }
93
+
94
+ return transportClient.sendDwnRequest(request);
95
+ }
96
+
97
+ async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
98
+ // will throw if url is invalid
99
+ const url = new URL(dwnUrl);
100
+
101
+ const transportClient = this.transportClients.get(url.protocol);
102
+ if (!transportClient) {
103
+ const error = new Error(`no ${url.protocol} transport client available`);
104
+ error.name = 'NO_TRANSPORT_CLIENT';
105
+
106
+ throw error;
107
+ }
108
+
109
+ return transportClient.getServerInfo(dwnUrl);
110
+ }
111
+ }
112
+
113
+ export class HttpWeb5RpcClient extends HttpDwnRpcClient implements Web5Rpc {
114
+ async sendDidRequest(request: DidRpcRequest): Promise<DidRpcResponse> {
115
+ const requestId = CryptoUtils.randomUuid();
116
+ const jsonRpcRequest = createJsonRpcRequest(requestId, request.method, {
117
+ data: request.data
118
+ });
119
+
120
+ const httpRequest = new Request(request.url, {
121
+ method : 'POST',
122
+ headers : {
123
+ 'Content-Type': 'application/json',
124
+ },
125
+ body: JSON.stringify(jsonRpcRequest),
126
+ });
127
+
128
+ let jsonRpcResponse: JsonRpcResponse;
129
+
130
+ try {
131
+ const response = await fetch(httpRequest);
132
+
133
+ if (response.ok) {
134
+ jsonRpcResponse = await response.json();
135
+
136
+ // If the response is an error, throw an error.
137
+ if (jsonRpcResponse.error) {
138
+ const { code, message } = jsonRpcResponse.error;
139
+ throw new Error(`JSON RPC (${code}) - ${message}`);
140
+ }
141
+ } else {
142
+ throw new Error(`HTTP (${response.status}) - ${response.statusText}`);
143
+ }
144
+ } catch (error: any) {
145
+ throw new Error(`Error encountered while processing response from ${request.url}: ${error.message}`);
146
+ }
147
+
148
+ return jsonRpcResponse.result as DidRpcResponse;
149
+ }
150
+ }
151
+
152
+ export class WebSocketWeb5RpcClient extends WebSocketDwnRpcClient implements Web5Rpc {
153
+ async sendDidRequest(_request: DidRpcRequest): Promise<DidRpcResponse> {
154
+ throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
155
+ }
156
+
157
+ async getServerInfo(_dwnUrl: string): Promise<ServerInfo> {
158
+ throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
159
+ }
160
+ }
@@ -0,0 +1,29 @@
1
+ import type { KeyValueStore } from '@enbox/common';
2
+
3
+ export type ServerInfo = {
4
+ /** the maximum file size the user can request to store */
5
+ maxFileSize: number,
6
+ /**
7
+ * an array of strings representing the server's registration requirements.
8
+ *
9
+ * ie. ['proof-of-work-sha256-v0', 'terms-of-service']
10
+ */
11
+ registrationRequirements: string[],
12
+ /** the DWN server's package name */
13
+ server: string,
14
+ /** the DWN SDK version used by the server */
15
+ sdkVersion: string,
16
+ /** the base URL of the DWN server */
17
+ url: string,
18
+ /** the DWN server version */
19
+ version: string,
20
+ /** whether web socket support is enabled on this server */
21
+ webSocketSupport: boolean,
22
+ };
23
+
24
+ export interface DwnServerInfoCache extends KeyValueStore<string, ServerInfo| undefined> {}
25
+
26
+ export interface DwnServerInfoRpc {
27
+ /** retrieves the DWN Sever info, used to detect features such as WebSocket Subscriptions */
28
+ getServerInfo(url: string): Promise<ServerInfo>;
29
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Concatenates a base URL and a path ensuring that there is exactly one slash between them. */
2
+ export function concatenateUrl(baseUrl: string, path: string): string {
3
+ // Remove trailing slash from baseUrl if it exists
4
+ if (baseUrl.endsWith('/')) {
5
+ baseUrl = baseUrl.slice(0, -1);
6
+ }
7
+
8
+ // Remove leading slash from path if it exists
9
+ if (path.startsWith('/')) {
10
+ path = path.slice(1);
11
+ }
12
+
13
+ return `${baseUrl}/${path}`;
14
+ }
@@ -0,0 +1,107 @@
1
+ import type { JsonRpcSocketOptions } from './json-rpc-socket.js';
2
+ import type { DwnRpc, DwnRpcRequest, DwnRpcResponse, DwnSubscriptionHandler } from './dwn-rpc-types.js';
3
+ import type { GenericMessage, MessageSubscription, UnionMessageReply } from '@enbox/dwn-sdk-js';
4
+
5
+ import { CryptoUtils } from '@enbox/crypto';
6
+ import { JsonRpcSocket } from './json-rpc-socket.js';
7
+ import { createJsonRpcRequest, createJsonRpcSubscriptionRequest } from './json-rpc.js';
8
+
9
+ interface SocketConnection {
10
+ socket: JsonRpcSocket;
11
+ subscriptions: Map<string, MessageSubscription>;
12
+ }
13
+
14
+ export class WebSocketDwnRpcClient implements DwnRpc {
15
+ public get transportProtocols(): string[] { return ['ws:', 'wss:']; }
16
+ // a map of dwn host to WebSocket connection
17
+ private static connections = new Map<string, SocketConnection>();
18
+
19
+ async sendDwnRequest(request: DwnRpcRequest, jsonRpcSocketOptions?: JsonRpcSocketOptions): Promise<DwnRpcResponse> {
20
+
21
+ // validate that the dwn URL provided is a valid WebSocket URL
22
+ const url = new URL(request.dwnUrl);
23
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
24
+ throw new Error(`Invalid websocket protocol ${url.protocol}`);
25
+ }
26
+
27
+ // check if there is already a connection to this host, if it does not exist, initiate a new connection
28
+ const hasConnection = WebSocketDwnRpcClient.connections.has(url.host);
29
+ if (!hasConnection) {
30
+ try {
31
+ const socket = await JsonRpcSocket.connect(url.toString(), jsonRpcSocketOptions);
32
+ const subscriptions = new Map();
33
+ WebSocketDwnRpcClient.connections.set(url.host, { socket, subscriptions });
34
+ } catch (error) {
35
+ throw new Error(`Error connecting to ${url.host}: ${(error as Error).message}`);
36
+ }
37
+ }
38
+
39
+ const connection = WebSocketDwnRpcClient.connections.get(url.host)!;
40
+ const { targetDid, message, subscriptionHandler } = request;
41
+
42
+ if (subscriptionHandler) {
43
+ return WebSocketDwnRpcClient.subscriptionRequest(connection, targetDid, message, subscriptionHandler);
44
+ }
45
+
46
+ return WebSocketDwnRpcClient.processMessage(connection, targetDid, message);
47
+ }
48
+
49
+ private static async processMessage(
50
+ connection: SocketConnection, target: string, message: GenericMessage
51
+ ): Promise<DwnRpcResponse> {
52
+ const requestId = CryptoUtils.randomUuid();
53
+ const request = createJsonRpcRequest(requestId, 'dwn.processMessage', { target, message });
54
+
55
+ const { socket } = connection;
56
+ const response = await socket.request(request);
57
+
58
+ const { error, result } = response;
59
+ if (error !== undefined) {
60
+ throw new Error(`error sending DWN request: ${error.message}`);
61
+ }
62
+
63
+ return result.reply as DwnRpcResponse;
64
+ }
65
+
66
+ private static async subscriptionRequest(
67
+ connection: SocketConnection, target:string, message: GenericMessage, messageHandler: DwnSubscriptionHandler
68
+ ): Promise<DwnRpcResponse> {
69
+ const requestId = CryptoUtils.randomUuid();
70
+ const subscriptionId = CryptoUtils.randomUuid();
71
+ const request = createJsonRpcSubscriptionRequest(
72
+ requestId, 'rpc.subscribe.dwn.processMessage', { target, message }, subscriptionId
73
+ );
74
+
75
+ const { socket, subscriptions } = connection;
76
+ const { response, close } = await socket.subscribe(request, (response) => {
77
+ const { result, error } = response;
78
+ if (error) {
79
+
80
+ // if there is an error, close the subscription and delete it from the connection
81
+ const subscription = subscriptions.get(subscriptionId);
82
+ if (subscription) {
83
+ subscription.close();
84
+ }
85
+
86
+ subscriptions.delete(subscriptionId);
87
+ return;
88
+ }
89
+
90
+ const { event } = result;
91
+ messageHandler(event);
92
+ });
93
+
94
+ const { error, result } = response;
95
+ if (error) {
96
+ throw new Error(`could not subscribe via jsonrpc socket: ${error.message}`);
97
+ }
98
+
99
+ const { reply } = result as { reply: UnionMessageReply };
100
+ if (reply.subscription && close) {
101
+ subscriptions.set(subscriptionId, { ...reply.subscription, close });
102
+ reply.subscription.close = close;
103
+ }
104
+
105
+ return reply;
106
+ }
107
+ }