@enbox/dwn-clients 0.4.4 → 0.4.5

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 (43) 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 +67 -5
  4. package/dist/esm/http-dwn-rpc-client.js.map +1 -1
  5. package/dist/esm/index.js +2 -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/replication-apply-result.js +79 -0
  10. package/dist/esm/replication-apply-result.js.map +1 -0
  11. package/dist/esm/rpc-client.js +10 -5
  12. package/dist/esm/rpc-client.js.map +1 -1
  13. package/dist/esm/web-socket-clients.js +141 -13
  14. package/dist/esm/web-socket-clients.js.map +1 -1
  15. package/dist/esm/ws-payload-size.js +12 -0
  16. package/dist/esm/ws-payload-size.js.map +1 -0
  17. package/dist/types/dwn-rpc-error.d.ts +18 -0
  18. package/dist/types/dwn-rpc-error.d.ts.map +1 -0
  19. package/dist/types/dwn-rpc-types.d.ts +37 -1
  20. package/dist/types/dwn-rpc-types.d.ts.map +1 -1
  21. package/dist/types/http-dwn-rpc-client.d.ts +3 -1
  22. package/dist/types/http-dwn-rpc-client.d.ts.map +1 -1
  23. package/dist/types/index.d.ts +2 -0
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types/json-rpc-socket.d.ts.map +1 -1
  26. package/dist/types/replication-apply-result.d.ts +3 -0
  27. package/dist/types/replication-apply-result.d.ts.map +1 -0
  28. package/dist/types/rpc-client.d.ts +3 -2
  29. package/dist/types/rpc-client.d.ts.map +1 -1
  30. package/dist/types/web-socket-clients.d.ts +14 -1
  31. package/dist/types/web-socket-clients.d.ts.map +1 -1
  32. package/dist/types/ws-payload-size.d.ts +6 -0
  33. package/dist/types/ws-payload-size.d.ts.map +1 -0
  34. package/package.json +2 -2
  35. package/src/dwn-rpc-error.ts +52 -0
  36. package/src/dwn-rpc-types.ts +52 -1
  37. package/src/http-dwn-rpc-client.ts +78 -5
  38. package/src/index.ts +2 -0
  39. package/src/json-rpc-socket.ts +5 -6
  40. package/src/replication-apply-result.ts +92 -0
  41. package/src/rpc-client.ts +16 -5
  42. package/src/web-socket-clients.ts +197 -15
  43. package/src/ws-payload-size.ts +14 -0
@@ -1,4 +1,11 @@
1
- import type { GenericMessage, ProgressToken, RecordsReadReply, SubscriptionMessage, UnionMessageReply } from '@enbox/dwn-sdk-js';
1
+ import type {
2
+ GenericMessage,
3
+ ProgressToken,
4
+ RecordsReadReply,
5
+ ReplicationApplyResult,
6
+ SubscriptionMessage,
7
+ UnionMessageReply,
8
+ } from '@enbox/dwn-sdk-js';
2
9
 
3
10
  export interface SerializableDwnMessage {
4
11
  toJSON(): string;
@@ -74,6 +81,15 @@ export interface DwnRpc {
74
81
  * @returns A promise that resolves to the response from the DWN server.
75
82
  */
76
83
  sendDwnRequest(request: DwnRpcRequest): Promise<DwnRpcResponse>
84
+
85
+ /**
86
+ * Applies a replicated message through the DWN server's replication entry point.
87
+ *
88
+ * This differs from `sendDwnRequest()` in duplicate replay handling: the server
89
+ * calls `Dwn.applyReplicatedMessage()`, which repairs missing replication index
90
+ * entries when the message store already contains the exact message.
91
+ */
92
+ applyReplicatedMessage(request: DwnReplicationApplyRequest): Promise<ReplicationApplyResult>
77
93
  }
78
94
 
79
95
 
@@ -110,6 +126,13 @@ export type DwnRpcRequest = {
110
126
  */
111
127
  signal?: AbortSignal;
112
128
 
129
+ /**
130
+ * Optional HTTP per-attempt timeout in milliseconds. When supplied, this
131
+ * replaces the transport default timeout; use this for legitimate large
132
+ * uploads that need more than the default budget.
133
+ */
134
+ timeoutMs?: number;
135
+
113
136
  /**
114
137
  * Subscription options — only set for subscribe requests.
115
138
  * Groups the handler, resubscribe factory, and any future subscription
@@ -129,6 +152,34 @@ export type DwnRpcRequest = {
129
152
  };
130
153
  };
131
154
 
155
+ /**
156
+ * Represents a JSON RPC request to apply a replicated DWN message through the
157
+ * server-side replication entry point.
158
+ */
159
+ export type DwnReplicationApplyRequest = {
160
+ /** Optional data to be sent with the request. */
161
+ data?: any;
162
+
163
+ /** The URL of the DWN server to which the request is sent. */
164
+ dwnUrl: string;
165
+
166
+ /** The replicated message to apply. */
167
+ message: GenericMessage | SerializableDwnMessage;
168
+
169
+ /** The DID of the target tenant to which the message is addressed. */
170
+ targetDid: string;
171
+
172
+ /** Optional caller-provided abort signal. Honoured by the HTTP transport. */
173
+ signal?: AbortSignal;
174
+
175
+ /**
176
+ * Optional HTTP per-attempt timeout in milliseconds. When omitted, HTTP
177
+ * replicated apply uses a larger default for data-bearing RecordsWrite
178
+ * messages so large sync uploads are not aborted by the normal short budget.
179
+ */
180
+ timeoutMs?: number;
181
+ };
182
+
132
183
  /**
133
184
  * Represents the JSON RPC response from a DWN server to a request, combining the results of various
134
185
  * DWN operations.
@@ -1,10 +1,13 @@
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 { DwnServerInfoCache, ServerInfo } from './server-info-types.js';
4
5
 
5
6
  import { CryptoUtils } from '@enbox/crypto';
6
7
  import { DataStream } from '@enbox/dwn-sdk-js';
8
+ import { DwnRpcError } from './dwn-rpc-error.js';
7
9
  import { DwnServerInfoCacheMemory } from './dwn-server-info-cache-memory.js';
10
+ import { parseReplicationApplyResult } from './replication-apply-result.js';
8
11
  import { RateLimitError } from './rate-limit-error.js';
9
12
  import { sleep } from '@enbox/common';
10
13
  import { createJsonRpcRequest, JsonRpcErrorCodes, parseJson } from './json-rpc.js';
@@ -25,6 +28,9 @@ const DEFAULT_MAX_DELAY_MS = 10_000;
25
28
  /** Per-request timeout in milliseconds (prevents hung connections / SSRF). */
26
29
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
27
30
 
31
+ /** Larger per-attempt timeout for data-bearing replicated apply uploads. */
32
+ const DEFAULT_LARGE_REPLICATED_APPLY_TIMEOUT_MS = 300_000;
33
+
28
34
  /** HTTP status codes that are considered retryable. */
29
35
  const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
30
36
 
@@ -163,7 +169,7 @@ export class HttpDwnRpcClient implements DwnRpc {
163
169
  fetchOpts.body = requestBody;
164
170
  }
165
171
 
166
- const resp = await this.fetchWithRetry(request.dwnUrl, fetchOpts);
172
+ const resp = await this.fetchWithRetry(request.dwnUrl, fetchOpts, { requestTimeoutMs: request.timeoutMs });
167
173
 
168
174
  // After retries are exhausted, a 429 means we're still rate-limited.
169
175
  // Per-IP 429s return plain JSON (not a JSON-RPC envelope), so we must
@@ -205,7 +211,7 @@ export class HttpDwnRpcClient implements DwnRpc {
205
211
  const retryAfter = dwnRpcResponse.error.data?.retryAfterSec ?? 1;
206
212
  throw new RateLimitError(retryAfter);
207
213
  }
208
- throw new Error(`(${code}) - ${message}`);
214
+ throw new DwnRpcError(code, message, dwnRpcResponse.error.data);
209
215
  }
210
216
 
211
217
  // Materialise the response body before attaching to the reply.
@@ -228,6 +234,65 @@ export class HttpDwnRpcClient implements DwnRpc {
228
234
  return reply as DwnRpcResponse;
229
235
  }
230
236
 
237
+ async applyReplicatedMessage(request: DwnReplicationApplyRequest): Promise<ReplicationApplyResult> {
238
+ const requestId = CryptoUtils.randomUuid();
239
+ const jsonRpcRequest = createJsonRpcRequest(requestId, 'dwn.applyReplicatedMessage', {
240
+ target : request.targetDid,
241
+ message : request.message
242
+ });
243
+
244
+ const requestHeaders: Record<string, string> = {
245
+ 'dwn-request': JSON.stringify(jsonRpcRequest)
246
+ };
247
+
248
+ const fetchOpts: RequestInit = {
249
+ method : 'POST',
250
+ headers : requestHeaders,
251
+ ...(request.signal ? { signal: request.signal } : {}),
252
+ };
253
+
254
+ if (request.data) {
255
+ requestHeaders['content-type'] = 'application/octet-stream';
256
+ let requestBody = request.data;
257
+
258
+ if (requestBody instanceof ReadableStream) {
259
+ if (HttpDwnRpcClient.isBunRuntime()) {
260
+ const bodyBytes = await DataStream.toBytes(requestBody as ReadableStream<Uint8Array>);
261
+ requestBody = new Blob([bodyBytes as BlobPart], { type: 'application/octet-stream' });
262
+ } else {
263
+ (fetchOpts as Record<string, unknown>).duplex = 'half';
264
+ }
265
+ }
266
+
267
+ fetchOpts.body = requestBody;
268
+ }
269
+
270
+ const resp = await this.fetchWithRetry(request.dwnUrl, fetchOpts, {
271
+ requestTimeoutMs: request.timeoutMs ?? defaultReplicationApplyTimeoutMs(request.message),
272
+ });
273
+ if (resp.status === 429) {
274
+ const retryAfter = Number.parseInt(resp.headers.get('retry-after') ?? '1', 10);
275
+ throw new RateLimitError(retryAfter);
276
+ }
277
+
278
+ const responseBody = await resp.text();
279
+ const jsonRpcResponse = parseJson(responseBody) as JsonRpcResponse;
280
+ if (jsonRpcResponse == null) {
281
+ throw new Error(`failed to parse json rpc response. dwn url: ${request.dwnUrl}, status: ${resp.status}`);
282
+ }
283
+
284
+ if (jsonRpcResponse.error) {
285
+ const { code, message } = jsonRpcResponse.error;
286
+ if (code === JsonRpcErrorCodes.TooManyRequests) {
287
+ const retryAfter = jsonRpcResponse.error.data?.retryAfterSec ?? 1;
288
+ throw new RateLimitError(retryAfter);
289
+ }
290
+ throw new DwnRpcError(code, message, jsonRpcResponse.error.data);
291
+ }
292
+
293
+ return parseReplicationApplyResult(jsonRpcResponse.result.result);
294
+ }
295
+
231
296
  async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
232
297
  const serverInfo = await this.serverInfoCache.get(dwnUrl);
233
298
  if (serverInfo) {
@@ -279,8 +344,9 @@ export class HttpDwnRpcClient implements DwnRpc {
279
344
  * retryable HTTP status codes with exponential backoff and jitter.
280
345
  * Honours the `Retry-After` response header when present.
281
346
  */
282
- private async fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
347
+ private async fetchWithRetry(url: string, init?: RequestInit, options: { requestTimeoutMs?: number } = {}): Promise<Response> {
283
348
  const { maxRetries, baseDelayMs, maxDelayMs } = this._retryOptions;
349
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
284
350
 
285
351
  let lastError: unknown;
286
352
  let lastResponse: Response | undefined;
@@ -290,7 +356,7 @@ export class HttpDwnRpcClient implements DwnRpc {
290
356
  // Apply a per-attempt timeout to prevent hung connections / SSRF.
291
357
  // If the caller already supplied a signal, combine it with the timeout
292
358
  // via AbortSignal.any(); otherwise create a fresh timeout signal.
293
- const timeoutSignal = AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS);
359
+ const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
294
360
  const attemptInit: RequestInit = {
295
361
  ...init,
296
362
  signal: init?.signal
@@ -328,3 +394,10 @@ export class HttpDwnRpcClient implements DwnRpc {
328
394
  throw lastError;
329
395
  }
330
396
  }
397
+
398
+ function defaultReplicationApplyTimeoutMs(message: DwnReplicationApplyRequest['message']): number | undefined {
399
+ const dataSize = (message as { descriptor?: { dataSize?: unknown } }).descriptor?.dataSize;
400
+ return typeof dataSize === 'number' && dataSize > 1_048_576
401
+ ? DEFAULT_LARGE_REPLICATED_APPLY_TIMEOUT_MS
402
+ : undefined;
403
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './dwn-registrar.js';
2
+ export * from './dwn-rpc-error.js';
2
3
  export * from './dwn-rpc-types.js';
3
4
  export * from './dwn-server-info-cache-memory.js';
4
5
  export * from './http-dwn-rpc-client.js';
@@ -9,6 +10,7 @@ export * from './rate-limit-error.js';
9
10
  export * from './registration-types.js';
10
11
  export * from './rpc-client.js';
11
12
  export * from './server-info-types.js';
13
+ export * from './ws-payload-size.js';
12
14
  // `./utils.js` removed: `concatenateUrl` (its only export) lives in
13
15
  // `@enbox/common` now — import from there if you need it.
14
16
  export * from './web-socket-clients.js';
@@ -177,12 +177,17 @@ export class JsonRpcSocket {
177
177
  public async request(request: JsonRpcRequest): Promise<JsonRpcResponse> {
178
178
  return new Promise((resolve, reject) => {
179
179
  request.id ??= CryptoUtils.randomUuid();
180
+ const timeout = setTimeout(() => {
181
+ this.messageHandlers.delete(request.id!);
182
+ reject(new Error('request timed out'));
183
+ }, this.responseTimeout);
180
184
 
181
185
  const handleResponse = (event: { data: any }):void => {
182
186
  const jsonRpsResponse = parseJson(toText(event.data)) as JsonRpcResponse;
183
187
  if (jsonRpsResponse.id === request.id) {
184
188
  // if the incoming response id matches the request id, we will remove the listener and resolve the response
185
189
  this.messageHandlers.delete(request.id);
190
+ clearTimeout(timeout);
186
191
  return resolve(jsonRpsResponse);
187
192
  }
188
193
  };
@@ -190,12 +195,6 @@ export class JsonRpcSocket {
190
195
  // add the listener to the map of message handlers
191
196
  this.messageHandlers.set(request.id!, handleResponse);
192
197
  this.send(request);
193
-
194
- // reject this promise if we don't receive any response back within the timeout period
195
- setTimeout(() => {
196
- this.messageHandlers.delete(request.id!);
197
- reject(new Error('request timed out'));
198
- }, this.responseTimeout);
199
198
  });
200
199
  }
201
200
 
@@ -0,0 +1,92 @@
1
+ import type { DependencyRef, ReplicationApplyResult } from '@enbox/dwn-sdk-js';
2
+
3
+ import { DwnRpcError } from './dwn-rpc-error.js';
4
+ import { JsonRpcErrorCodes } from './json-rpc.js';
5
+
6
+ const deferredReasons = new Set(['tenant-inactive', 'resolver-unavailable', 'storage']);
7
+
8
+ export function parseReplicationApplyResult(value: unknown): ReplicationApplyResult {
9
+ if (!isObject(value) || typeof value.kind !== 'string') {
10
+ throw malformedReplicationApplyResult('result must be an object with a string kind');
11
+ }
12
+
13
+ switch (value.kind) {
14
+ case 'Applied':
15
+ case 'Duplicate':
16
+ case 'Superseded':
17
+ return { kind: value.kind };
18
+ case 'Incomplete':
19
+ if (!Array.isArray(value.missing) || !value.missing.every(isDependencyRef)) {
20
+ throw malformedReplicationApplyResult('Incomplete result must include missing dependency refs');
21
+ }
22
+ return { kind: 'Incomplete', missing: value.missing };
23
+ case 'Invalid':
24
+ if (typeof value.reason !== 'string') {
25
+ throw malformedReplicationApplyResult('Invalid result must include a string reason');
26
+ }
27
+ return { kind: 'Invalid', reason: value.reason };
28
+ case 'Deferred':
29
+ if (typeof value.reason !== 'string' || !deferredReasons.has(value.reason)) {
30
+ throw malformedReplicationApplyResult('Deferred result must include a valid reason');
31
+ }
32
+ return { kind: 'Deferred', reason: value.reason as Extract<ReplicationApplyResult, { kind: 'Deferred' }>['reason'] };
33
+ default:
34
+ throw malformedReplicationApplyResult(`unknown result kind ${value.kind}`);
35
+ }
36
+ }
37
+
38
+ function malformedReplicationApplyResult(detail: string): DwnRpcError {
39
+ return new DwnRpcError(
40
+ JsonRpcErrorCodes.InternalError,
41
+ `malformed dwn.applyReplicatedMessage result: ${detail}`,
42
+ );
43
+ }
44
+
45
+ function isDependencyRef(value: unknown): value is DependencyRef {
46
+ if (!isObject(value) || typeof value.type !== 'string') {
47
+ return false;
48
+ }
49
+ if (!isOptionalString(value.messageCid) || !isOptionalBoolean(value.terminal)) {
50
+ return false;
51
+ }
52
+
53
+ switch (value.type) {
54
+ case 'Protocol':
55
+ return typeof value.protocol === 'string';
56
+ case 'InitialWrite':
57
+ return typeof value.recordId === 'string' && isOptionalString(value.protocol);
58
+ case 'Parent':
59
+ return typeof value.recordId === 'string' && typeof value.protocol === 'string';
60
+ case 'Ancestor':
61
+ return typeof value.recordId === 'string' && isOptionalString(value.protocol);
62
+ case 'Role':
63
+ return typeof value.protocol === 'string' &&
64
+ typeof value.protocolPath === 'string' &&
65
+ typeof value.recipient === 'string' &&
66
+ isOptionalString(value.contextPrefix);
67
+ case 'Grant':
68
+ return typeof value.permissionGrantId === 'string';
69
+ case 'KeyDelivery':
70
+ return typeof value.protocol === 'string' && typeof value.contextId === 'string';
71
+ case 'CrossProtocolRef':
72
+ return typeof value.protocol === 'string' && typeof value.recordId === 'string';
73
+ case 'RecordData':
74
+ return typeof value.recordId === 'string' &&
75
+ typeof value.dataCid === 'string' &&
76
+ isOptionalString(value.protocol);
77
+ default:
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function isObject(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === 'object' && value !== null;
84
+ }
85
+
86
+ function isOptionalString(value: unknown): value is string | undefined {
87
+ return value === undefined || typeof value === 'string';
88
+ }
89
+
90
+ function isOptionalBoolean(value: unknown): value is boolean | undefined {
91
+ return value === undefined || typeof value === 'boolean';
92
+ }
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
@@ -55,6 +77,9 @@ export class WebSocketDwnRpcClient implements DwnRpc {
55
77
  public get transportProtocols(): string[] { return ['ws:', 'wss:']; }
56
78
  // a map of dwn host to WebSocket connection
57
79
  private static readonly connections = new Map<string, SocketConnection>();
80
+ private static readonly pendingConnections = new Map<string, Promise<SocketConnection>>();
81
+
82
+ public constructor(private readonly serverInfoRpc: DwnServerInfoRpc = new HttpDwnRpcClient()) {}
58
83
 
59
84
  async sendDwnRequest(request: DwnRpcRequest): Promise<DwnRpcResponse> {
60
85
 
@@ -64,18 +89,7 @@ export class WebSocketDwnRpcClient implements DwnRpc {
64
89
  throw new Error(`Invalid websocket protocol ${url.protocol}`);
65
90
  }
66
91
 
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)!;
92
+ const connection = await WebSocketDwnRpcClient.getConnection(request.dwnUrl);
79
93
  const { targetDid, message, subscription } = request;
80
94
 
81
95
  if (subscription) {
@@ -87,6 +101,59 @@ export class WebSocketDwnRpcClient implements DwnRpc {
87
101
  return WebSocketDwnRpcClient.processMessage(connection, targetDid, message);
88
102
  }
89
103
 
104
+ async applyReplicatedMessage(request: DwnReplicationApplyRequest): Promise<ReplicationApplyResult> {
105
+ WebSocketDwnRpcClient.assertReplicatedApplyDataIsPresent(request);
106
+ const maxPayloadBytes = await this.maxPayloadBytesForReplicatedApply(request);
107
+ WebSocketDwnRpcClient.assertReplicatedApplyDataSizeIsSupported(request, maxPayloadBytes);
108
+ const connection = await WebSocketDwnRpcClient.getConnection(request.dwnUrl);
109
+ const encodedData = request.data === undefined ? undefined : await dataToBase64Url(request.data);
110
+ return WebSocketDwnRpcClient.applyReplicatedMessage(connection, request.targetDid, request.message, encodedData, maxPayloadBytes);
111
+ }
112
+
113
+ async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
114
+ return this.serverInfoRpc.getServerInfo(httpUrlForWsDwnUrl(dwnUrl));
115
+ }
116
+
117
+ private async maxPayloadBytesForReplicatedApply(request: DwnReplicationApplyRequest): Promise<number> {
118
+ const dataSize = recordsWriteDataSize(request.message);
119
+ if (dataSize === undefined) {
120
+ return DEFAULT_MAX_WS_JSON_RPC_PAYLOAD_BYTES;
121
+ }
122
+
123
+ const serverInfo = await this.getServerInfo(request.dwnUrl);
124
+ return maxWsJsonRpcPayloadBytes(serverInfo.maxFileSize);
125
+ }
126
+
127
+ private static async getConnection(dwnUrl: string): Promise<SocketConnection> {
128
+ const url = new URL(dwnUrl);
129
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
130
+ throw new Error(`Invalid websocket protocol ${url.protocol}`);
131
+ }
132
+
133
+ const existing = WebSocketDwnRpcClient.connections.get(url.host);
134
+ if (existing !== undefined) {
135
+ return existing;
136
+ }
137
+
138
+ let pending = WebSocketDwnRpcClient.pendingConnections.get(url.host);
139
+ if (pending === undefined) {
140
+ pending = WebSocketDwnRpcClient.createConnection(url)
141
+ .then((connection) => {
142
+ WebSocketDwnRpcClient.connections.set(url.host, connection);
143
+ return connection;
144
+ })
145
+ .catch((error: unknown) => {
146
+ throw new Error(`Error connecting to ${url.host}: ${(error as Error).message}`);
147
+ })
148
+ .finally(() => {
149
+ WebSocketDwnRpcClient.pendingConnections.delete(url.host);
150
+ });
151
+ WebSocketDwnRpcClient.pendingConnections.set(url.host, pending);
152
+ }
153
+
154
+ return pending;
155
+ }
156
+
90
157
  /**
91
158
  * Creates a new `SocketConnection` with lifecycle wiring for reconnection.
92
159
  */
@@ -141,6 +208,32 @@ export class WebSocketDwnRpcClient implements DwnRpc {
141
208
  return result.reply as DwnRpcResponse;
142
209
  }
143
210
 
211
+ private static async applyReplicatedMessage(
212
+ connection: SocketConnection,
213
+ target: string,
214
+ message: DwnReplicationApplyRequest['message'],
215
+ encodedData?: string,
216
+ maxPayloadBytes: number = DEFAULT_MAX_WS_JSON_RPC_PAYLOAD_BYTES,
217
+ ): Promise<ReplicationApplyResult> {
218
+ const requestId = CryptoUtils.randomUuid();
219
+ const request = createJsonRpcRequest(requestId, 'dwn.applyReplicatedMessage', {
220
+ target,
221
+ message,
222
+ ...(encodedData === undefined ? {} : { encodedData }),
223
+ });
224
+ WebSocketDwnRpcClient.assertPayloadFitsFrame(request, encodedData, maxPayloadBytes);
225
+
226
+ const { socket } = connection;
227
+ const response = await socket.request(request);
228
+
229
+ const { error, result } = response;
230
+ if (error !== undefined) {
231
+ throw new DwnRpcError(error.code, error.message, error.data);
232
+ }
233
+
234
+ return parseReplicationApplyResult(result.result);
235
+ }
236
+
144
237
  private static async subscriptionRequest(
145
238
  connection: SocketConnection,
146
239
  target: string,
@@ -273,4 +366,93 @@ export class WebSocketDwnRpcClient implements DwnRpc {
273
366
  }
274
367
  }
275
368
  }
369
+
370
+ private static assertReplicatedApplyDataIsPresent(request: DwnReplicationApplyRequest): void {
371
+ const dataSize = recordsWriteDataSize(request.message);
372
+ if (dataSize !== undefined && dataSize > 0 && request.data === undefined) {
373
+ throw new DwnRpcError(
374
+ JsonRpcErrorCodes.InvalidParams,
375
+ 'data-bearing RecordsWrite replicated apply over WebSocket requires encoded data',
376
+ );
377
+ }
378
+ }
379
+
380
+ private static assertReplicatedApplyDataSizeIsSupported(request: DwnReplicationApplyRequest, maxPayloadBytes: number): void {
381
+ const dataSize = recordsWriteDataSize(request.message);
382
+ if (dataSize !== undefined && maxWsJsonRpcPayloadBytes(dataSize) > maxPayloadBytes) {
383
+ throw new DwnRpcError(
384
+ JsonRpcErrorCodes.InvalidParams,
385
+ `RecordsWrite replicated apply data is too large for WebSocket JSON-RPC framing`,
386
+ );
387
+ }
388
+ }
389
+
390
+ private static assertPayloadFitsFrame(
391
+ request: ReturnType<typeof createJsonRpcRequest>,
392
+ encodedData: string | undefined,
393
+ maxPayloadBytes: number,
394
+ ): void {
395
+ const payloadBytes = estimatedJsonRpcPayloadBytes(request, encodedData);
396
+ if (payloadBytes > maxPayloadBytes) {
397
+ throw new DwnRpcError(
398
+ JsonRpcErrorCodes.InvalidParams,
399
+ `replicated apply JSON-RPC payload is too large for WebSocket transport`,
400
+ );
401
+ }
402
+ }
403
+ }
404
+
405
+ function httpUrlForWsDwnUrl(dwnUrl: string): string {
406
+ const url = new URL(dwnUrl);
407
+ if (url.protocol === 'ws:') {
408
+ url.protocol = 'http:';
409
+ } else if (url.protocol === 'wss:') {
410
+ url.protocol = 'https:';
411
+ }
412
+ return url.toString();
413
+ }
414
+
415
+ function estimatedJsonRpcPayloadBytes(request: ReturnType<typeof createJsonRpcRequest>, encodedData: string | undefined): number {
416
+ if (encodedData === undefined) {
417
+ return utf8ByteLength(JSON.stringify(request));
418
+ }
419
+
420
+ const params = request.params as Record<string, unknown>;
421
+ const requestWithoutData = {
422
+ ...request,
423
+ params: {
424
+ ...params,
425
+ encodedData: '',
426
+ },
427
+ };
428
+ return utf8ByteLength(JSON.stringify(requestWithoutData)) + encodedData.length;
429
+ }
430
+
431
+ function utf8ByteLength(text: string): number {
432
+ return new TextEncoder().encode(text).byteLength;
433
+ }
434
+
435
+ async function dataToBase64Url(data: DwnReplicationApplyRequest['data']): Promise<string> {
436
+ if (data instanceof Blob) {
437
+ return Encoder.bytesToBase64Url(new Uint8Array(await data.arrayBuffer()));
438
+ }
439
+
440
+ if (data instanceof ReadableStream) {
441
+ return Encoder.bytesToBase64Url(await DataStream.toBytes(data as ReadableStream<Uint8Array>));
442
+ }
443
+
444
+ if (data instanceof Uint8Array) {
445
+ return Encoder.bytesToBase64Url(data);
446
+ }
447
+
448
+ return Encoder.bytesToBase64Url(new Uint8Array(await new Blob([data] as BlobPart[]).arrayBuffer()));
449
+ }
450
+
451
+ function recordsWriteDataSize(message: DwnReplicationApplyRequest['message']): number | undefined {
452
+ const descriptor = (message as { descriptor?: { interface?: unknown; method?: unknown; dataSize?: unknown } }).descriptor;
453
+ return descriptor?.interface === 'Records' &&
454
+ descriptor.method === 'Write' &&
455
+ typeof descriptor.dataSize === 'number'
456
+ ? descriptor.dataSize
457
+ : undefined;
276
458
  }
@@ -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
+ }