@enbox/dwn-clients 0.4.4 → 0.4.6

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 (48) hide show
  1. package/dist/esm/dwn-rpc-error.js +45 -0
  2. package/dist/esm/dwn-rpc-error.js.map +1 -0
  3. package/dist/esm/http-dwn-rpc-client.js +109 -50
  4. package/dist/esm/http-dwn-rpc-client.js.map +1 -1
  5. package/dist/esm/index.js +3 -0
  6. package/dist/esm/index.js.map +1 -1
  7. package/dist/esm/json-rpc-socket.js +5 -5
  8. package/dist/esm/json-rpc-socket.js.map +1 -1
  9. package/dist/esm/readable-stream.js +68 -0
  10. package/dist/esm/readable-stream.js.map +1 -0
  11. package/dist/esm/replication-apply-result.js +105 -0
  12. package/dist/esm/replication-apply-result.js.map +1 -0
  13. package/dist/esm/rpc-client.js +10 -5
  14. package/dist/esm/rpc-client.js.map +1 -1
  15. package/dist/esm/web-socket-clients.js +152 -17
  16. package/dist/esm/web-socket-clients.js.map +1 -1
  17. package/dist/esm/ws-payload-size.js +12 -0
  18. package/dist/esm/ws-payload-size.js.map +1 -0
  19. package/dist/types/dwn-rpc-error.d.ts +18 -0
  20. package/dist/types/dwn-rpc-error.d.ts.map +1 -0
  21. package/dist/types/dwn-rpc-types.d.ts +37 -1
  22. package/dist/types/dwn-rpc-types.d.ts.map +1 -1
  23. package/dist/types/http-dwn-rpc-client.d.ts +3 -3
  24. package/dist/types/http-dwn-rpc-client.d.ts.map +1 -1
  25. package/dist/types/index.d.ts +3 -0
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/json-rpc-socket.d.ts.map +1 -1
  28. package/dist/types/readable-stream.d.ts +6 -0
  29. package/dist/types/readable-stream.d.ts.map +1 -0
  30. package/dist/types/replication-apply-result.d.ts +3 -0
  31. package/dist/types/replication-apply-result.d.ts.map +1 -0
  32. package/dist/types/rpc-client.d.ts +3 -2
  33. package/dist/types/rpc-client.d.ts.map +1 -1
  34. package/dist/types/web-socket-clients.d.ts +14 -1
  35. package/dist/types/web-socket-clients.d.ts.map +1 -1
  36. package/dist/types/ws-payload-size.d.ts +6 -0
  37. package/dist/types/ws-payload-size.d.ts.map +1 -0
  38. package/package.json +4 -4
  39. package/src/dwn-rpc-error.ts +52 -0
  40. package/src/dwn-rpc-types.ts +52 -1
  41. package/src/http-dwn-rpc-client.ts +132 -58
  42. package/src/index.ts +3 -0
  43. package/src/json-rpc-socket.ts +5 -6
  44. package/src/readable-stream.ts +59 -0
  45. package/src/replication-apply-result.ts +124 -0
  46. package/src/rpc-client.ts +16 -5
  47. package/src/web-socket-clients.ts +210 -19
  48. package/src/ws-payload-size.ts +14 -0
package/src/rpc-client.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { JsonRpcResponse } from './json-rpc.js';
2
- import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './dwn-rpc-types.js';
2
+ import type { ReplicationApplyResult } from '@enbox/dwn-sdk-js';
3
+ import type { DwnReplicationApplyRequest, DwnRpc, DwnRpcRequest, DwnRpcResponse } from './dwn-rpc-types.js';
3
4
  import type { DwnServerInfoRpc, ServerInfo } from './server-info-types.js';
4
5
 
5
6
  import { createJsonRpcRequest } from './json-rpc.js';
@@ -94,6 +95,20 @@ export class EnboxRpcClient implements EnboxRpc {
94
95
  return transportClient.sendDwnRequest(request);
95
96
  }
96
97
 
98
+ applyReplicatedMessage(request: DwnReplicationApplyRequest): Promise<ReplicationApplyResult> {
99
+ const url = new URL(request.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.applyReplicatedMessage(request);
110
+ }
111
+
97
112
  async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
98
113
  // will throw if url is invalid
99
114
  const url = new URL(dwnUrl);
@@ -153,8 +168,4 @@ export class WebSocketEnboxRpcClient extends WebSocketDwnRpcClient implements En
153
168
  async sendDidRequest(_request: DidRpcRequest): Promise<DidRpcResponse> {
154
169
  throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
155
170
  }
156
-
157
- async getServerInfo(_dwnUrl: string): Promise<ServerInfo> {
158
- throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
159
- }
160
171
  }
@@ -1,9 +1,31 @@
1
- import type { DwnRpc, DwnRpcRequest, DwnRpcResponse, DwnSubscriptionHandler, ResubscribeFactory } from './dwn-rpc-types.js';
2
- import type { GenericMessage, MessageSubscription, ProgressToken, SubscriptionMessage, UnionMessageReply } from '@enbox/dwn-sdk-js';
1
+ import type {
2
+ DwnReplicationApplyRequest,
3
+ DwnRpc,
4
+ DwnRpcRequest,
5
+ DwnRpcResponse,
6
+ DwnSubscriptionHandler,
7
+ ResubscribeFactory,
8
+ } from './dwn-rpc-types.js';
9
+ import type { DwnServerInfoRpc, ServerInfo } from './server-info-types.js';
10
+ import type {
11
+ GenericMessage,
12
+ MessageSubscription,
13
+ ProgressToken,
14
+ ReplicationApplyResult,
15
+ SubscriptionMessage,
16
+ UnionMessageReply,
17
+ } from '@enbox/dwn-sdk-js';
3
18
 
4
19
  import { CryptoUtils } from '@enbox/crypto';
20
+ import { DwnRpcError } from './dwn-rpc-error.js';
21
+ import { HttpDwnRpcClient } from './http-dwn-rpc-client.js';
5
22
  import { JsonRpcSocket } from './json-rpc-socket.js';
6
- import { createJsonRpcAck, createJsonRpcRequest, createJsonRpcSubscriptionRequest } from './json-rpc.js';
23
+ import { parseReplicationApplyResult } from './replication-apply-result.js';
24
+ import { createJsonRpcAck, createJsonRpcRequest, createJsonRpcSubscriptionRequest, JsonRpcErrorCodes } from './json-rpc.js';
25
+ import { DataStream, Encoder } from '@enbox/dwn-sdk-js';
26
+ import { DEFAULT_MAX_WS_RAW_RECORD_DATA_BYTES, maxWsJsonRpcPayloadBytes } from './ws-payload-size.js';
27
+
28
+ const DEFAULT_MAX_WS_JSON_RPC_PAYLOAD_BYTES = maxWsJsonRpcPayloadBytes(DEFAULT_MAX_WS_RAW_RECORD_DATA_BYTES);
7
29
 
8
30
  /**
9
31
  * Metadata for a tracked subscription, including everything needed to
@@ -39,6 +61,14 @@ interface SocketConnection {
39
61
  url: string;
40
62
  }
41
63
 
64
+ function connectionCacheKey(url: URL): string {
65
+ return stripTrailingSlash(url.toString());
66
+ }
67
+
68
+ function stripTrailingSlash(url: string): string {
69
+ return url.endsWith('/') ? url.slice(0, -1) : url;
70
+ }
71
+
42
72
  function shouldReplaceLastCursor(current: ProgressToken | undefined, candidate: ProgressToken): boolean {
43
73
  if (current === undefined) {
44
74
  return true;
@@ -53,8 +83,11 @@ function shouldReplaceLastCursor(current: ProgressToken | undefined, candidate:
53
83
 
54
84
  export class WebSocketDwnRpcClient implements DwnRpc {
55
85
  public get transportProtocols(): string[] { return ['ws:', 'wss:']; }
56
- // a map of dwn host to WebSocket connection
86
+ // a map of normalized DWN WebSocket endpoint URLs to WebSocket connections
57
87
  private static readonly connections = new Map<string, SocketConnection>();
88
+ private static readonly pendingConnections = new Map<string, Promise<SocketConnection>>();
89
+
90
+ public constructor(private readonly serverInfoRpc: DwnServerInfoRpc = new HttpDwnRpcClient()) {}
58
91
 
59
92
  async sendDwnRequest(request: DwnRpcRequest): Promise<DwnRpcResponse> {
60
93
 
@@ -64,18 +97,7 @@ export class WebSocketDwnRpcClient implements DwnRpc {
64
97
  throw new Error(`Invalid websocket protocol ${url.protocol}`);
65
98
  }
66
99
 
67
- // check if there is already a connection to this host, if it does not exist, initiate a new connection
68
- const hasConnection = WebSocketDwnRpcClient.connections.has(url.host);
69
- if (!hasConnection) {
70
- try {
71
- const connection = await WebSocketDwnRpcClient.createConnection(url);
72
- WebSocketDwnRpcClient.connections.set(url.host, connection);
73
- } catch (error) {
74
- throw new Error(`Error connecting to ${url.host}: ${(error as Error).message}`);
75
- }
76
- }
77
-
78
- const connection = WebSocketDwnRpcClient.connections.get(url.host)!;
100
+ const connection = await WebSocketDwnRpcClient.getConnection(request.dwnUrl);
79
101
  const { targetDid, message, subscription } = request;
80
102
 
81
103
  if (subscription) {
@@ -87,17 +109,71 @@ export class WebSocketDwnRpcClient implements DwnRpc {
87
109
  return WebSocketDwnRpcClient.processMessage(connection, targetDid, message);
88
110
  }
89
111
 
112
+ async applyReplicatedMessage(request: DwnReplicationApplyRequest): Promise<ReplicationApplyResult> {
113
+ WebSocketDwnRpcClient.assertReplicatedApplyDataIsPresent(request);
114
+ const maxPayloadBytes = await this.maxPayloadBytesForReplicatedApply(request);
115
+ WebSocketDwnRpcClient.assertReplicatedApplyDataSizeIsSupported(request, maxPayloadBytes);
116
+ const connection = await WebSocketDwnRpcClient.getConnection(request.dwnUrl);
117
+ const encodedData = request.data === undefined ? undefined : await dataToBase64Url(request.data);
118
+ return WebSocketDwnRpcClient.applyReplicatedMessage(connection, request.targetDid, request.message, encodedData, maxPayloadBytes);
119
+ }
120
+
121
+ async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
122
+ return this.serverInfoRpc.getServerInfo(httpUrlForWsDwnUrl(dwnUrl));
123
+ }
124
+
125
+ private async maxPayloadBytesForReplicatedApply(request: DwnReplicationApplyRequest): Promise<number> {
126
+ const dataSize = recordsWriteDataSize(request.message);
127
+ if (dataSize === undefined) {
128
+ return DEFAULT_MAX_WS_JSON_RPC_PAYLOAD_BYTES;
129
+ }
130
+
131
+ const serverInfo = await this.getServerInfo(request.dwnUrl);
132
+ return maxWsJsonRpcPayloadBytes(serverInfo.maxFileSize);
133
+ }
134
+
135
+ private static async getConnection(dwnUrl: string): Promise<SocketConnection> {
136
+ const url = new URL(dwnUrl);
137
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
138
+ throw new Error(`Invalid websocket protocol ${url.protocol}`);
139
+ }
140
+
141
+ const key = connectionCacheKey(url);
142
+ const existing = WebSocketDwnRpcClient.connections.get(key);
143
+ if (existing !== undefined) {
144
+ return existing;
145
+ }
146
+
147
+ let pending = WebSocketDwnRpcClient.pendingConnections.get(key);
148
+ if (pending === undefined) {
149
+ pending = WebSocketDwnRpcClient.createConnection(url)
150
+ .then((connection) => {
151
+ WebSocketDwnRpcClient.connections.set(key, connection);
152
+ return connection;
153
+ })
154
+ .catch((error: unknown) => {
155
+ throw new Error(`Error connecting to ${key}: ${(error as Error).message}`);
156
+ })
157
+ .finally(() => {
158
+ WebSocketDwnRpcClient.pendingConnections.delete(key);
159
+ });
160
+ WebSocketDwnRpcClient.pendingConnections.set(key, pending);
161
+ }
162
+
163
+ return pending;
164
+ }
165
+
90
166
  /**
91
167
  * Creates a new `SocketConnection` with lifecycle wiring for reconnection.
92
168
  */
93
169
  private static async createConnection(url: URL): Promise<SocketConnection> {
94
- const host = url.host;
170
+ const key = connectionCacheKey(url);
95
171
  const subscriptions = new Map<string, TrackedSubscription>();
96
172
 
97
173
  const socket = await JsonRpcSocket.connect(url.toString(), {
98
174
  onclose: (): void => {
99
175
  // Remove the stale connection from the map so new requests create a fresh one.
100
- WebSocketDwnRpcClient.connections.delete(host);
176
+ WebSocketDwnRpcClient.connections.delete(key);
101
177
 
102
178
  // Notify all subscription handlers of disconnection.
103
179
  for (const tracked of subscriptions.values()) {
@@ -114,7 +190,7 @@ export class WebSocketDwnRpcClient implements DwnRpc {
114
190
  onreconnected: (): void => {
115
191
  // Re-register this connection in the map (it was deleted on close).
116
192
  const conn = { socket, subscriptions, url: url.toString() };
117
- WebSocketDwnRpcClient.connections.set(host, conn);
193
+ WebSocketDwnRpcClient.connections.set(key, conn);
118
194
 
119
195
  // Resubscribe all tracked subscriptions with their last known cursor.
120
196
  WebSocketDwnRpcClient.resubscribeAll(conn);
@@ -141,6 +217,32 @@ export class WebSocketDwnRpcClient implements DwnRpc {
141
217
  return result.reply as DwnRpcResponse;
142
218
  }
143
219
 
220
+ private static async applyReplicatedMessage(
221
+ connection: SocketConnection,
222
+ target: string,
223
+ message: DwnReplicationApplyRequest['message'],
224
+ encodedData?: string,
225
+ maxPayloadBytes: number = DEFAULT_MAX_WS_JSON_RPC_PAYLOAD_BYTES,
226
+ ): Promise<ReplicationApplyResult> {
227
+ const requestId = CryptoUtils.randomUuid();
228
+ const request = createJsonRpcRequest(requestId, 'dwn.applyReplicatedMessage', {
229
+ target,
230
+ message,
231
+ ...(encodedData === undefined ? {} : { encodedData }),
232
+ });
233
+ WebSocketDwnRpcClient.assertPayloadFitsFrame(request, encodedData, maxPayloadBytes);
234
+
235
+ const { socket } = connection;
236
+ const response = await socket.request(request);
237
+
238
+ const { error, result } = response;
239
+ if (error !== undefined) {
240
+ throw new DwnRpcError(error.code, error.message, error.data);
241
+ }
242
+
243
+ return parseReplicationApplyResult(result.result);
244
+ }
245
+
144
246
  private static async subscriptionRequest(
145
247
  connection: SocketConnection,
146
248
  target: string,
@@ -273,4 +375,93 @@ export class WebSocketDwnRpcClient implements DwnRpc {
273
375
  }
274
376
  }
275
377
  }
378
+
379
+ private static assertReplicatedApplyDataIsPresent(request: DwnReplicationApplyRequest): void {
380
+ const dataSize = recordsWriteDataSize(request.message);
381
+ if (dataSize !== undefined && dataSize > 0 && request.data === undefined) {
382
+ throw new DwnRpcError(
383
+ JsonRpcErrorCodes.InvalidParams,
384
+ 'data-bearing RecordsWrite replicated apply over WebSocket requires encoded data',
385
+ );
386
+ }
387
+ }
388
+
389
+ private static assertReplicatedApplyDataSizeIsSupported(request: DwnReplicationApplyRequest, maxPayloadBytes: number): void {
390
+ const dataSize = recordsWriteDataSize(request.message);
391
+ if (dataSize !== undefined && maxWsJsonRpcPayloadBytes(dataSize) > maxPayloadBytes) {
392
+ throw new DwnRpcError(
393
+ JsonRpcErrorCodes.InvalidParams,
394
+ `RecordsWrite replicated apply data is too large for WebSocket JSON-RPC framing`,
395
+ );
396
+ }
397
+ }
398
+
399
+ private static assertPayloadFitsFrame(
400
+ request: ReturnType<typeof createJsonRpcRequest>,
401
+ encodedData: string | undefined,
402
+ maxPayloadBytes: number,
403
+ ): void {
404
+ const payloadBytes = estimatedJsonRpcPayloadBytes(request, encodedData);
405
+ if (payloadBytes > maxPayloadBytes) {
406
+ throw new DwnRpcError(
407
+ JsonRpcErrorCodes.InvalidParams,
408
+ `replicated apply JSON-RPC payload is too large for WebSocket transport`,
409
+ );
410
+ }
411
+ }
412
+ }
413
+
414
+ function httpUrlForWsDwnUrl(dwnUrl: string): string {
415
+ const url = new URL(dwnUrl);
416
+ if (url.protocol === 'ws:') {
417
+ url.protocol = 'http:';
418
+ } else if (url.protocol === 'wss:') {
419
+ url.protocol = 'https:';
420
+ }
421
+ return url.toString();
422
+ }
423
+
424
+ function estimatedJsonRpcPayloadBytes(request: ReturnType<typeof createJsonRpcRequest>, encodedData: string | undefined): number {
425
+ if (encodedData === undefined) {
426
+ return utf8ByteLength(JSON.stringify(request));
427
+ }
428
+
429
+ const params = request.params as Record<string, unknown>;
430
+ const requestWithoutData = {
431
+ ...request,
432
+ params: {
433
+ ...params,
434
+ encodedData: '',
435
+ },
436
+ };
437
+ return utf8ByteLength(JSON.stringify(requestWithoutData)) + encodedData.length;
438
+ }
439
+
440
+ function utf8ByteLength(text: string): number {
441
+ return new TextEncoder().encode(text).byteLength;
442
+ }
443
+
444
+ async function dataToBase64Url(data: DwnReplicationApplyRequest['data']): Promise<string> {
445
+ if (data instanceof Blob) {
446
+ return Encoder.bytesToBase64Url(new Uint8Array(await data.arrayBuffer()));
447
+ }
448
+
449
+ if (data instanceof ReadableStream) {
450
+ return Encoder.bytesToBase64Url(await DataStream.toBytes(data as ReadableStream<Uint8Array>));
451
+ }
452
+
453
+ if (data instanceof Uint8Array) {
454
+ return Encoder.bytesToBase64Url(data);
455
+ }
456
+
457
+ return Encoder.bytesToBase64Url(new Uint8Array(await new Blob([data] as BlobPart[]).arrayBuffer()));
458
+ }
459
+
460
+ function recordsWriteDataSize(message: DwnReplicationApplyRequest['message']): number | undefined {
461
+ const descriptor = (message as { descriptor?: { interface?: unknown; method?: unknown; dataSize?: unknown } }).descriptor;
462
+ return descriptor?.interface === 'Records' &&
463
+ descriptor.method === 'Write' &&
464
+ typeof descriptor.dataSize === 'number'
465
+ ? descriptor.dataSize
466
+ : undefined;
276
467
  }
@@ -0,0 +1,14 @@
1
+ export const DEFAULT_MAX_WS_RAW_RECORD_DATA_BYTES = 100 * 1024 * 1024;
2
+ export const WS_JSON_RPC_ENVELOPE_BYTES = 64 * 1024;
3
+
4
+ export function base64UrlEncodedLength(byteLength: number): number {
5
+ return Math.ceil(byteLength / 3) * 4;
6
+ }
7
+
8
+ export function estimatedWsJsonRpcPayloadBytes(rawDataSize: number): number {
9
+ return base64UrlEncodedLength(rawDataSize) + WS_JSON_RPC_ENVELOPE_BYTES;
10
+ }
11
+
12
+ export function maxWsJsonRpcPayloadBytes(maxRecordDataSize: number): number {
13
+ return estimatedWsJsonRpcPayloadBytes(maxRecordDataSize);
14
+ }