@enbox/dwn-clients 0.4.3 → 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.
- 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 +67 -5
- package/dist/esm/http-dwn-rpc-client.js.map +1 -1
- package/dist/esm/index.js +2 -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/replication-apply-result.js +79 -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 +141 -13
- 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 -1
- package/dist/types/http-dwn-rpc-client.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/json-rpc-socket.d.ts.map +1 -1
- 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 +2 -2
- package/src/dwn-rpc-error.ts +52 -0
- package/src/dwn-rpc-types.ts +52 -1
- package/src/http-dwn-rpc-client.ts +78 -5
- package/src/index.ts +2 -0
- package/src/json-rpc-socket.ts +5 -6
- package/src/replication-apply-result.ts +92 -0
- package/src/rpc-client.ts +16 -5
- package/src/web-socket-clients.ts +197 -15
- package/src/ws-payload-size.ts +14 -0
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
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
|
|
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(
|
|
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';
|
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,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 {
|
|
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 {
|
|
2
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|