@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.
- package/dist/esm/dwn-rpc-error.js +45 -0
- package/dist/esm/dwn-rpc-error.js.map +1 -0
- package/dist/esm/http-dwn-rpc-client.js +109 -50
- package/dist/esm/http-dwn-rpc-client.js.map +1 -1
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/json-rpc-socket.js +5 -5
- package/dist/esm/json-rpc-socket.js.map +1 -1
- package/dist/esm/readable-stream.js +68 -0
- package/dist/esm/readable-stream.js.map +1 -0
- package/dist/esm/replication-apply-result.js +105 -0
- package/dist/esm/replication-apply-result.js.map +1 -0
- package/dist/esm/rpc-client.js +10 -5
- package/dist/esm/rpc-client.js.map +1 -1
- package/dist/esm/web-socket-clients.js +152 -17
- package/dist/esm/web-socket-clients.js.map +1 -1
- package/dist/esm/ws-payload-size.js +12 -0
- package/dist/esm/ws-payload-size.js.map +1 -0
- package/dist/types/dwn-rpc-error.d.ts +18 -0
- package/dist/types/dwn-rpc-error.d.ts.map +1 -0
- package/dist/types/dwn-rpc-types.d.ts +37 -1
- package/dist/types/dwn-rpc-types.d.ts.map +1 -1
- package/dist/types/http-dwn-rpc-client.d.ts +3 -3
- package/dist/types/http-dwn-rpc-client.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/json-rpc-socket.d.ts.map +1 -1
- package/dist/types/readable-stream.d.ts +6 -0
- package/dist/types/readable-stream.d.ts.map +1 -0
- package/dist/types/replication-apply-result.d.ts +3 -0
- package/dist/types/replication-apply-result.d.ts.map +1 -0
- package/dist/types/rpc-client.d.ts +3 -2
- package/dist/types/rpc-client.d.ts.map +1 -1
- package/dist/types/web-socket-clients.d.ts +14 -1
- package/dist/types/web-socket-clients.d.ts.map +1 -1
- package/dist/types/ws-payload-size.d.ts +6 -0
- package/dist/types/ws-payload-size.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/dwn-rpc-error.ts +52 -0
- package/src/dwn-rpc-types.ts +52 -1
- package/src/http-dwn-rpc-client.ts +132 -58
- package/src/index.ts +3 -0
- package/src/json-rpc-socket.ts +5 -6
- package/src/readable-stream.ts +59 -0
- package/src/replication-apply-result.ts +124 -0
- package/src/rpc-client.ts +16 -5
- package/src/web-socket-clients.ts +210 -19
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/dwn-rpc-types.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
|
220
|
-
|
|
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(
|
|
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 <=
|
|
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
|
|
294
|
-
|
|
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 (
|
|
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
|
-
|
|
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';
|
package/src/json-rpc-socket.ts
CHANGED
|
@@ -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
|
+
}
|