@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enbox/dwn-clients",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "main": "./dist/esm/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -43,12 +43,12 @@
43
43
  "access": "public"
44
44
  },
45
45
  "engines": {
46
- "bun": ">=1.0.0"
46
+ "bun": ">=1.3.14"
47
47
  },
48
48
  "dependencies": {
49
49
  "@enbox/common": "0.1.1",
50
50
  "@enbox/crypto": "0.1.1",
51
- "@enbox/dwn-sdk-js": "0.3.9",
51
+ "@enbox/dwn-sdk-js": "0.4.1",
52
52
  "ms": "2.1.3"
53
53
  },
54
54
  "devDependencies": {
@@ -57,7 +57,7 @@
57
57
  "@types/sinon": "17.0.3",
58
58
  "@typescript-eslint/eslint-plugin": "8.32.1",
59
59
  "@typescript-eslint/parser": "8.32.1",
60
- "bun-types": "1.3.10",
60
+ "bun-types": "1.3.14",
61
61
  "eslint": "9.7.0",
62
62
  "sinon": "18.0.0",
63
63
  "typescript": "5.9.3"
@@ -0,0 +1,52 @@
1
+ import { JsonRpcErrorCodes } from './json-rpc.js';
2
+
3
+ /**
4
+ * Error surfaced by DWN JSON-RPC transports when the server returns a typed
5
+ * JSON-RPC error envelope.
6
+ */
7
+ export class DwnRpcError extends Error {
8
+ public readonly code: JsonRpcErrorCodes;
9
+ public readonly data?: unknown;
10
+ public readonly terminal: boolean;
11
+
12
+ constructor(code: JsonRpcErrorCodes, message: string, data?: unknown) {
13
+ super(`(${code}) - ${message}`);
14
+ this.name = 'DwnRpcError';
15
+ this.code = code;
16
+ this.data = data;
17
+ this.terminal = isTerminalJsonRpcErrorCode(code, message, data);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * JSON-RPC errors that represent deterministic request rejection before DWN
23
+ * replicated admission can run. Internal/server/transport/rate-limit errors
24
+ * remain retryable.
25
+ */
26
+ export function isTerminalJsonRpcErrorCode(code: JsonRpcErrorCodes, message = '', data?: unknown): boolean {
27
+ if (isQuotaExceededError(message, data)) {
28
+ return false;
29
+ }
30
+
31
+ switch (code) {
32
+ case JsonRpcErrorCodes.InvalidRequest:
33
+ case JsonRpcErrorCodes.InvalidParams:
34
+ case JsonRpcErrorCodes.BadRequest:
35
+ case JsonRpcErrorCodes.Unauthorized:
36
+ case JsonRpcErrorCodes.Forbidden:
37
+ case JsonRpcErrorCodes.Conflict:
38
+ return true;
39
+ default:
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function isQuotaExceededError(message: string, data: unknown): boolean {
45
+ const code = typeof data === 'object' && data !== null && 'code' in data
46
+ ? (data as { code?: unknown }).code
47
+ : undefined;
48
+ return code === 'TenantMessageQuotaExceeded' ||
49
+ code === 'TenantStorageQuotaExceeded' ||
50
+ message.includes('TenantMessageQuotaExceeded') ||
51
+ message.includes('TenantStorageQuotaExceeded');
52
+ }
@@ -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
- import { DataStream } from '@enbox/dwn-sdk-js';
7
+ import { DwnRpcError } from './dwn-rpc-error.js';
7
8
  import { DwnServerInfoCacheMemory } from './dwn-server-info-cache-memory.js';
9
+ import { normalizeReadableStream } from './readable-stream.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
 
@@ -93,6 +99,48 @@ function parseRetryAfterMs(response: Response): number | undefined {
93
99
  return undefined;
94
100
  }
95
101
 
102
+ function createAttemptInit(init: RequestInit | undefined, requestTimeoutMs: number): RequestInit {
103
+ const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
104
+ if (init?.signal === undefined || init.signal === null) {
105
+ return { ...init, signal: timeoutSignal };
106
+ }
107
+
108
+ return { ...init, signal: AbortSignal.any([init.signal, timeoutSignal]) };
109
+ }
110
+
111
+ function shouldReturnResponse(response: Response, attempt: number, maxRetriesForRequest: number): boolean {
112
+ return !RETRYABLE_STATUS_CODES.has(response.status) || attempt === maxRetriesForRequest;
113
+ }
114
+
115
+ function shouldRethrowFetchError(error: unknown, attempt: number, maxRetriesForRequest: number): boolean {
116
+ return !isRetryable(error) || attempt === maxRetriesForRequest;
117
+ }
118
+
119
+ function getRetryDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number, lastResponse?: Response): number {
120
+ const retryAfterMs = lastResponse !== undefined ? parseRetryAfterMs(lastResponse) : undefined;
121
+ const backoffMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
122
+
123
+ return retryAfterMs === undefined ? backoffMs : Math.max(retryAfterMs, backoffMs);
124
+ }
125
+
126
+ /**
127
+ * Mutates the request options and headers with an octet-stream body.
128
+ * Returns whether the body is replayable for transport-level retries.
129
+ */
130
+ function attachDataRequestBody(fetchOpts: RequestInit, requestHeaders: Record<string, string>, requestBody: BodyInit): boolean {
131
+ requestHeaders['content-type'] = 'application/octet-stream';
132
+ fetchOpts.body = requestBody;
133
+
134
+ if (requestBody instanceof ReadableStream) {
135
+ // Required by the Fetch standard for streaming request bodies. The stream is one-shot,
136
+ // so transport-level retries must not replay the same body after a failed attempt.
137
+ (fetchOpts as RequestInit & { duplex: 'half' }).duplex = 'half';
138
+ return false;
139
+ }
140
+
141
+ return true;
142
+ }
143
+
96
144
  /**
97
145
  * HTTP client that can be used to communicate with Dwn Servers.
98
146
  *
@@ -113,11 +161,6 @@ export class HttpDwnRpcClient implements DwnRpc {
113
161
  };
114
162
  }
115
163
 
116
- /** Detects whether the current runtime is Bun (vs a browser). */
117
- static isBunRuntime(): boolean {
118
- return typeof (globalThis as Record<string, unknown>).Bun !== 'undefined';
119
- }
120
-
121
164
  get transportProtocols(): string[] { return ['http:', 'https:']; }
122
165
 
123
166
  async sendDwnRequest(request: DwnRpcRequest): Promise<DwnRpcResponse> {
@@ -141,29 +184,15 @@ export class HttpDwnRpcClient implements DwnRpc {
141
184
  ...(request.signal ? { signal: request.signal } : {}),
142
185
  };
143
186
 
144
- if (request.data) {
145
- requestHeaders['content-type'] = 'application/octet-stream';
146
- let requestBody = request.data;
147
-
148
- if (requestBody instanceof ReadableStream) {
149
- // Bun's fetch currently fails on some ReadableStream uploads in the sync push path.
150
- // Buffer to a Blob in Bun to avoid the broken path. In browsers, keep the stream
151
- // and set `duplex: 'half'` which the Fetch spec requires for streaming request bodies.
152
- // See: https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
153
- if (HttpDwnRpcClient.isBunRuntime()) {
154
- const bodyBytes = await DataStream.toBytes(requestBody as ReadableStream<Uint8Array>);
155
- requestBody = new Blob([bodyBytes as BlobPart], { type: 'application/octet-stream' });
156
- } else {
157
- // Browsers require `duplex: 'half'` when the fetch body is a ReadableStream.
158
- // TypeScript's built-in RequestInit does not include `duplex` yet.
159
- (fetchOpts as Record<string, unknown>).duplex = 'half';
160
- }
161
- }
162
-
163
- fetchOpts.body = requestBody;
187
+ let isRequestBodyReplayable = true;
188
+ if (request.data !== undefined) {
189
+ isRequestBodyReplayable = attachDataRequestBody(fetchOpts, requestHeaders, request.data as BodyInit);
164
190
  }
165
191
 
166
- const resp = await this.fetchWithRetry(request.dwnUrl, fetchOpts);
192
+ const resp = await this.fetchWithRetry(request.dwnUrl, fetchOpts, {
193
+ requestTimeoutMs : request.timeoutMs,
194
+ retryableRequestBody : isRequestBodyReplayable,
195
+ });
167
196
 
168
197
  // After retries are exhausted, a 429 means we're still rate-limited.
169
198
  // Per-IP 429s return plain JSON (not a JSON-RPC envelope), so we must
@@ -205,29 +234,74 @@ export class HttpDwnRpcClient implements DwnRpc {
205
234
  const retryAfter = dwnRpcResponse.error.data?.retryAfterSec ?? 1;
206
235
  throw new RateLimitError(retryAfter);
207
236
  }
208
- throw new Error(`(${code}) - ${message}`);
237
+ throw new DwnRpcError(code, message, dwnRpcResponse.error.data);
209
238
  }
210
239
 
211
- // Materialise the response body before attaching to the reply.
212
- // Bun has a bug where ReadableStream from fetch resp.body crashes in
213
- // DataStream.toBytes() (reader.releaseLock() is undefined) when the
214
- // stream is later consumed by the local DWN node (e.g. during sync).
215
- // Buffering via arrayBuffer() avoids the broken getReader() path.
216
- // TODO: https://github.com/enboxorg/enbox/issues/90 — remove once Bun ships fix
217
240
  const { reply } = dwnRpcResponse.result;
218
241
  if (hasDataStream) {
219
- const bodyBytes = new Uint8Array(await resp.arrayBuffer());
220
- const dataStream = DataStream.fromBytes(bodyBytes);
242
+ const dataStream = resp.body;
243
+ if (dataStream === null) {
244
+ throw new Error(`missing data stream in json rpc response. dwn url: ${request.dwnUrl}`);
245
+ }
221
246
  if (reply.record) {
222
- reply.record.data = dataStream;
247
+ reply.record.data = normalizeReadableStream(dataStream);
223
248
  } else if (reply.entry) {
224
- reply.entry.data = dataStream;
249
+ reply.entry.data = normalizeReadableStream(dataStream);
225
250
  }
226
251
  }
227
252
 
228
253
  return reply as DwnRpcResponse;
229
254
  }
230
255
 
256
+ async applyReplicatedMessage(request: DwnReplicationApplyRequest): Promise<ReplicationApplyResult> {
257
+ const requestId = CryptoUtils.randomUuid();
258
+ const jsonRpcRequest = createJsonRpcRequest(requestId, 'dwn.applyReplicatedMessage', {
259
+ target : request.targetDid,
260
+ message : request.message
261
+ });
262
+
263
+ const requestHeaders: Record<string, string> = {
264
+ 'dwn-request': JSON.stringify(jsonRpcRequest)
265
+ };
266
+
267
+ const fetchOpts: RequestInit = {
268
+ method : 'POST',
269
+ headers : requestHeaders,
270
+ ...(request.signal ? { signal: request.signal } : {}),
271
+ };
272
+
273
+ let isRequestBodyReplayable = true;
274
+ if (request.data !== undefined) {
275
+ isRequestBodyReplayable = attachDataRequestBody(fetchOpts, requestHeaders, request.data as BodyInit);
276
+ }
277
+
278
+ const resp = await this.fetchWithRetry(request.dwnUrl, fetchOpts, {
279
+ requestTimeoutMs : request.timeoutMs ?? defaultReplicationApplyTimeoutMs(request.message),
280
+ retryableRequestBody : isRequestBodyReplayable,
281
+ });
282
+ if (resp.status === 429) {
283
+ const retryAfter = Number.parseInt(resp.headers.get('retry-after') ?? '1', 10);
284
+ throw new RateLimitError(retryAfter);
285
+ }
286
+
287
+ const responseBody = await resp.text();
288
+ const jsonRpcResponse = parseJson(responseBody) as JsonRpcResponse;
289
+ if (jsonRpcResponse == null) {
290
+ throw new Error(`failed to parse json rpc response. dwn url: ${request.dwnUrl}, status: ${resp.status}`);
291
+ }
292
+
293
+ if (jsonRpcResponse.error) {
294
+ const { code, message } = jsonRpcResponse.error;
295
+ if (code === JsonRpcErrorCodes.TooManyRequests) {
296
+ const retryAfter = jsonRpcResponse.error.data?.retryAfterSec ?? 1;
297
+ throw new RateLimitError(retryAfter);
298
+ }
299
+ throw new DwnRpcError(code, message, jsonRpcResponse.error.data);
300
+ }
301
+
302
+ return parseReplicationApplyResult(jsonRpcResponse.result.result);
303
+ }
304
+
231
305
  async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
232
306
  const serverInfo = await this.serverInfoCache.get(dwnUrl);
233
307
  if (serverInfo) {
@@ -279,46 +353,39 @@ export class HttpDwnRpcClient implements DwnRpc {
279
353
  * retryable HTTP status codes with exponential backoff and jitter.
280
354
  * Honours the `Retry-After` response header when present.
281
355
  */
282
- private async fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
356
+ private async fetchWithRetry(
357
+ url: string,
358
+ init?: RequestInit,
359
+ options: { requestTimeoutMs?: number; retryableRequestBody?: boolean } = {},
360
+ ): Promise<Response> {
283
361
  const { maxRetries, baseDelayMs, maxDelayMs } = this._retryOptions;
362
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
363
+ const maxRetriesForRequest = options.retryableRequestBody === false ? 0 : maxRetries;
284
364
 
285
365
  let lastError: unknown;
286
366
  let lastResponse: Response | undefined;
287
367
 
288
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
368
+ for (let attempt = 0; attempt <= maxRetriesForRequest; attempt++) {
289
369
  try {
290
370
  // Apply a per-attempt timeout to prevent hung connections / SSRF.
291
371
  // If the caller already supplied a signal, combine it with the timeout
292
372
  // via AbortSignal.any(); otherwise create a fresh timeout signal.
293
- const timeoutSignal = AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS);
294
- const attemptInit: RequestInit = {
295
- ...init,
296
- signal: init?.signal
297
- ? AbortSignal.any([init.signal, timeoutSignal])
298
- : timeoutSignal,
299
- };
300
-
301
- const response = await fetch(url, attemptInit);
302
-
303
- if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt === maxRetries) {
373
+ const response = await fetch(url, createAttemptInit(init, requestTimeoutMs));
374
+ if (shouldReturnResponse(response, attempt, maxRetriesForRequest)) {
304
375
  return response;
305
376
  }
306
377
 
307
378
  // Retryable status — back off and try again.
308
379
  lastResponse = response;
309
380
  } catch (error: unknown) {
310
- if (!isRetryable(error) || attempt === maxRetries) {
381
+ if (shouldRethrowFetchError(error, attempt, maxRetriesForRequest)) {
311
382
  throw error;
312
383
  }
313
384
  lastError = error;
314
385
  }
315
386
 
316
387
  // Compute the delay, preferring Retry-After when available.
317
- const retryAfterMs = lastResponse ? parseRetryAfterMs(lastResponse) : undefined;
318
- const backoffMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
319
- const delayMs = retryAfterMs === undefined ? backoffMs : Math.max(retryAfterMs, backoffMs);
320
-
321
- await sleep(delayMs);
388
+ await sleep(getRetryDelayMs(attempt, baseDelayMs, maxDelayMs, lastResponse));
322
389
  }
323
390
 
324
391
  // Should not reach here, but satisfy the compiler.
@@ -328,3 +395,10 @@ export class HttpDwnRpcClient implements DwnRpc {
328
395
  throw lastError;
329
396
  }
330
397
  }
398
+
399
+ function defaultReplicationApplyTimeoutMs(message: DwnReplicationApplyRequest['message']): number | undefined {
400
+ const dataSize = (message as { descriptor?: { dataSize?: unknown } }).descriptor?.dataSize;
401
+ return typeof dataSize === 'number' && dataSize > 1_048_576
402
+ ? DEFAULT_LARGE_REPLICATED_APPLY_TIMEOUT_MS
403
+ : undefined;
404
+ }
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';
@@ -6,9 +7,11 @@ export * from './json-rpc.js';
6
7
  export * from './json-rpc-socket.js';
7
8
  export * from './provider-directory-types.js';
8
9
  export * from './rate-limit-error.js';
10
+ export * from './readable-stream.js';
9
11
  export * from './registration-types.js';
10
12
  export * from './rpc-client.js';
11
13
  export * from './server-info-types.js';
14
+ export * from './ws-payload-size.js';
12
15
  // `./utils.js` removed: `concatenateUrl` (its only export) lives in
13
16
  // `@enbox/common` now — import from there if you need it.
14
17
  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,59 @@
1
+ type ReadableStreamReader = {
2
+ cancel?: (reason?: unknown) => Promise<void>;
3
+ read(): Promise<{ done: boolean; value?: Uint8Array }>;
4
+ releaseLock?: () => void;
5
+ };
6
+
7
+ /**
8
+ * Wraps runtime-provided streams in a standard `ReadableStream` so downstream
9
+ * consumers see consistent reader behavior across Bun and browsers.
10
+ */
11
+ export function normalizeReadableStream(readableStream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
12
+ const reader = readableStream.getReader() as ReadableStreamReader;
13
+ let readerReleased = false;
14
+
15
+ const releaseReader = (): void => {
16
+ if (readerReleased) {
17
+ return;
18
+ }
19
+
20
+ reader.releaseLock?.();
21
+ readerReleased = true;
22
+ };
23
+
24
+ const cancelReader = async (reason?: unknown): Promise<void> => {
25
+ try {
26
+ await reader.cancel?.(reason);
27
+ } finally {
28
+ releaseReader();
29
+ }
30
+ };
31
+
32
+ return new ReadableStream<Uint8Array>({
33
+ async pull(controller): Promise<void> {
34
+ try {
35
+ const { done, value } = await reader.read();
36
+ if (done) {
37
+ releaseReader();
38
+ controller.close();
39
+ return;
40
+ }
41
+
42
+ controller.enqueue(value!);
43
+ } catch (error: unknown) {
44
+ try {
45
+ await reader.cancel?.(error);
46
+ } catch {
47
+ // Preserve the original read error.
48
+ } finally {
49
+ releaseReader();
50
+ }
51
+ throw error;
52
+ }
53
+ },
54
+
55
+ async cancel(reason): Promise<void> {
56
+ await cancelReader(reason);
57
+ },
58
+ });
59
+ }
@@ -0,0 +1,124 @@
1
+ import type { DependencyRef, ProgressToken, 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
+ return parseAppliedResult(value);
16
+ case 'Duplicate':
17
+ case 'Superseded':
18
+ return { kind: value.kind };
19
+ case 'Incomplete':
20
+ if (!Array.isArray(value.missing) || !value.missing.every(isDependencyRef)) {
21
+ throw malformedReplicationApplyResult('Incomplete result must include missing dependency refs');
22
+ }
23
+ return { kind: 'Incomplete', missing: value.missing };
24
+ case 'Invalid':
25
+ if (typeof value.reason !== 'string') {
26
+ throw malformedReplicationApplyResult('Invalid result must include a string reason');
27
+ }
28
+ return { kind: 'Invalid', reason: value.reason };
29
+ case 'Deferred':
30
+ if (typeof value.reason !== 'string' || !deferredReasons.has(value.reason)) {
31
+ throw malformedReplicationApplyResult('Deferred result must include a valid reason');
32
+ }
33
+ return { kind: 'Deferred', reason: value.reason as Extract<ReplicationApplyResult, { kind: 'Deferred' }>['reason'] };
34
+ default:
35
+ throw malformedReplicationApplyResult(`unknown result kind ${value.kind}`);
36
+ }
37
+ }
38
+
39
+ function parseAppliedResult(value: Record<string, unknown>): Extract<ReplicationApplyResult, { kind: 'Applied' }> {
40
+ const result: Extract<ReplicationApplyResult, { kind: 'Applied' }> = { kind: 'Applied' };
41
+
42
+ if (value.ancestryOnly !== undefined) {
43
+ if (value.ancestryOnly !== true) {
44
+ throw malformedReplicationApplyResult('Applied result ancestryOnly must be true when present');
45
+ }
46
+ result.ancestryOnly = true;
47
+ }
48
+
49
+ if (value.position !== undefined) {
50
+ if (!isProgressToken(value.position)) {
51
+ throw malformedReplicationApplyResult('Applied result position must be a valid ProgressToken when present');
52
+ }
53
+ result.position = value.position;
54
+ }
55
+
56
+ return result;
57
+ }
58
+
59
+ function malformedReplicationApplyResult(detail: string): DwnRpcError {
60
+ return new DwnRpcError(
61
+ JsonRpcErrorCodes.InternalError,
62
+ `malformed dwn.applyReplicatedMessage result: ${detail}`,
63
+ );
64
+ }
65
+
66
+ function isDependencyRef(value: unknown): value is DependencyRef {
67
+ if (!isObject(value) || typeof value.type !== 'string') {
68
+ return false;
69
+ }
70
+ if (!isOptionalString(value.messageCid) || !isOptionalBoolean(value.terminal)) {
71
+ return false;
72
+ }
73
+
74
+ switch (value.type) {
75
+ case 'Protocol':
76
+ return typeof value.protocol === 'string';
77
+ case 'InitialWrite':
78
+ return typeof value.recordId === 'string' && isOptionalString(value.protocol);
79
+ case 'Parent':
80
+ return typeof value.recordId === 'string' && typeof value.protocol === 'string';
81
+ case 'Ancestor':
82
+ return typeof value.recordId === 'string' && isOptionalString(value.protocol);
83
+ case 'Role':
84
+ return typeof value.protocol === 'string' &&
85
+ typeof value.protocolPath === 'string' &&
86
+ typeof value.recipient === 'string' &&
87
+ isOptionalString(value.contextPrefix);
88
+ case 'Grant':
89
+ return typeof value.permissionGrantId === 'string';
90
+ case 'KeyDelivery':
91
+ return typeof value.protocol === 'string' && typeof value.contextId === 'string';
92
+ case 'CrossProtocolRef':
93
+ return typeof value.protocol === 'string' && typeof value.recordId === 'string';
94
+ case 'RecordData':
95
+ return typeof value.recordId === 'string' &&
96
+ typeof value.dataCid === 'string' &&
97
+ isOptionalString(value.protocol);
98
+ default:
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function isObject(value: unknown): value is Record<string, unknown> {
104
+ return typeof value === 'object' && value !== null;
105
+ }
106
+
107
+ function isProgressToken(value: unknown): value is ProgressToken {
108
+ if (!isObject(value)) {
109
+ return false;
110
+ }
111
+
112
+ return typeof value.streamId === 'string' && value.streamId !== '' &&
113
+ typeof value.epoch === 'string' && value.epoch !== '' &&
114
+ typeof value.position === 'string' && value.position !== '' &&
115
+ (value.messageCid === undefined || (typeof value.messageCid === 'string' && value.messageCid !== ''));
116
+ }
117
+
118
+ function isOptionalString(value: unknown): value is string | undefined {
119
+ return value === undefined || typeof value === 'string';
120
+ }
121
+
122
+ function isOptionalBoolean(value: unknown): value is boolean | undefined {
123
+ return value === undefined || typeof value === 'boolean';
124
+ }