@dxos/edge-client 0.8.4-main.c1de068 → 0.8.4-main.c85a9c8dae
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/lib/{browser/chunk-LMP5TVOP.mjs → neutral/chunk-VESGVCLQ.mjs} +8 -4
- package/dist/lib/{browser/chunk-LMP5TVOP.mjs.map → neutral/chunk-VESGVCLQ.mjs.map} +3 -3
- package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs +1 -1
- package/dist/lib/{browser → neutral}/index.mjs +574 -289
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser → neutral}/testing/index.mjs +1 -1
- package/dist/lib/{node-esm → neutral}/testing/index.mjs.map +2 -2
- package/dist/types/src/edge-client.d.ts +15 -15
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +35 -5
- package/dist/types/src/edge-http-client.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.d.ts +19 -0
- package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
- package/dist/types/src/edge-ws-muxer.d.ts.map +1 -1
- package/dist/types/src/http-client.d.ts +10 -7
- package/dist/types/src/http-client.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +4 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/testing/test-utils.d.ts +3 -3
- package/dist/types/src/testing/test-utils.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +29 -24
- package/src/edge-client.test.ts +4 -4
- package/src/edge-client.ts +73 -42
- package/src/edge-http-client.test.ts +1 -1
- package/src/edge-http-client.ts +223 -45
- package/src/edge-ws-connection.ts +119 -6
- package/src/edge-ws-muxer.ts +1 -1
- package/src/http-client.test.ts +11 -7
- package/src/http-client.ts +18 -8
- package/src/index.ts +4 -3
- package/src/testing/test-utils.ts +8 -8
- package/src/websocket.test.ts +1 -1
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-X7J46ISZ.mjs +0 -299
- package/dist/lib/node-esm/chunk-X7J46ISZ.mjs.map +0 -7
- package/dist/lib/node-esm/edge-ws-muxer.mjs +0 -12
- package/dist/lib/node-esm/edge-ws-muxer.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -1103
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/testing/index.mjs +0 -186
- /package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs.map +0 -0
package/src/edge-http-client.ts
CHANGED
|
@@ -2,47 +2,58 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
|
|
6
|
+
import * as HttpClient from '@effect/platform/HttpClient';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Function from 'effect/Function';
|
|
7
9
|
|
|
8
10
|
import { sleep } from '@dxos/async';
|
|
9
11
|
import { Context } from '@dxos/context';
|
|
12
|
+
import { runAndForwardErrors } from '@dxos/effect';
|
|
13
|
+
import { invariant } from '@dxos/invariant';
|
|
10
14
|
import { type PublicKey, type SpaceId } from '@dxos/keys';
|
|
11
15
|
import { log } from '@dxos/log';
|
|
12
16
|
import {
|
|
13
|
-
type CreateAgentResponseBody,
|
|
14
17
|
type CreateAgentRequestBody,
|
|
18
|
+
type CreateAgentResponseBody,
|
|
15
19
|
type CreateSpaceRequest,
|
|
16
20
|
type CreateSpaceResponseBody,
|
|
17
21
|
EdgeAuthChallengeError,
|
|
18
22
|
EdgeCallFailedError,
|
|
19
|
-
type
|
|
23
|
+
type EdgeFailure,
|
|
24
|
+
type EdgeStatus,
|
|
20
25
|
type ExecuteWorkflowResponseBody,
|
|
26
|
+
type ExportBundleRequest,
|
|
27
|
+
type ExportBundleResponse,
|
|
28
|
+
type FeedProtocol,
|
|
21
29
|
type GetAgentStatusResponseBody,
|
|
22
30
|
type GetNotarizationResponseBody,
|
|
31
|
+
type ImportBundleRequest,
|
|
23
32
|
type InitiateOAuthFlowRequest,
|
|
24
33
|
type InitiateOAuthFlowResponse,
|
|
25
34
|
type JoinSpaceRequest,
|
|
26
35
|
type JoinSpaceResponseBody,
|
|
27
|
-
type RecoverIdentityRequest,
|
|
28
|
-
type RecoverIdentityResponseBody,
|
|
29
36
|
type ObjectId,
|
|
30
37
|
type PostNotarizationRequestBody,
|
|
31
|
-
type
|
|
32
|
-
type
|
|
38
|
+
type RecoverIdentityRequest,
|
|
39
|
+
type RecoverIdentityResponseBody,
|
|
33
40
|
type UploadFunctionRequest,
|
|
34
41
|
type UploadFunctionResponseBody,
|
|
35
|
-
type EdgeStatus,
|
|
36
42
|
} from '@dxos/protocols';
|
|
43
|
+
import {
|
|
44
|
+
type QueryRequest as QueryRequestProto,
|
|
45
|
+
type QueryResponse as QueryResponseProto,
|
|
46
|
+
} from '@dxos/protocols/proto/dxos/echo/query';
|
|
37
47
|
import { createUrl } from '@dxos/util';
|
|
38
48
|
|
|
39
49
|
import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
|
|
40
|
-
import {
|
|
50
|
+
import { HttpConfig, encodeAuthHeader, withLogging, withRetryConfig } from './http-client';
|
|
41
51
|
import { getEdgeUrlWithProtocol } from './utils';
|
|
42
52
|
|
|
43
53
|
const DEFAULT_RETRY_TIMEOUT = 1500;
|
|
44
54
|
const DEFAULT_RETRY_JITTER = 500;
|
|
45
55
|
const DEFAULT_MAX_RETRIES_COUNT = 3;
|
|
56
|
+
const WARNING_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
46
57
|
|
|
47
58
|
export type RetryConfig = {
|
|
48
59
|
/**
|
|
@@ -64,10 +75,25 @@ type EdgeHttpRequestArgs = {
|
|
|
64
75
|
context?: Context;
|
|
65
76
|
retry?: RetryConfig;
|
|
66
77
|
body?: any;
|
|
78
|
+
/**
|
|
79
|
+
* @default true
|
|
80
|
+
*/
|
|
81
|
+
json?: boolean;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Force authentication.
|
|
85
|
+
* This should be used for requests with large bodies to avoid sending the body twice.
|
|
86
|
+
* The client will call /auth endpoint to generate the auth header.
|
|
87
|
+
*/
|
|
88
|
+
auth?: boolean;
|
|
67
89
|
};
|
|
68
90
|
|
|
69
|
-
export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry'>;
|
|
70
|
-
export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body'>;
|
|
91
|
+
export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'auth'>;
|
|
92
|
+
export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body' | 'auth'>;
|
|
93
|
+
|
|
94
|
+
export type GetCronTriggersResponse = {
|
|
95
|
+
cronIds: string[];
|
|
96
|
+
};
|
|
71
97
|
|
|
72
98
|
export class EdgeHttpClient {
|
|
73
99
|
private readonly _baseUrl: string;
|
|
@@ -100,7 +126,7 @@ export class EdgeHttpClient {
|
|
|
100
126
|
//
|
|
101
127
|
|
|
102
128
|
public async getStatus(args?: EdgeHttpGetArgs): Promise<EdgeStatus> {
|
|
103
|
-
return this._call(new URL('/status', this.baseUrl), { ...args, method: 'GET' });
|
|
129
|
+
return this._call(new URL('/status', this.baseUrl), { ...args, method: 'GET', auth: true });
|
|
104
130
|
}
|
|
105
131
|
|
|
106
132
|
//
|
|
@@ -164,10 +190,6 @@ export class EdgeHttpClient {
|
|
|
164
190
|
// OAuth and credentials
|
|
165
191
|
//
|
|
166
192
|
|
|
167
|
-
public async listFunctions(args?: EdgeHttpGetArgs): Promise<any> {
|
|
168
|
-
return this._call(new URL('/functions', this.baseUrl), { ...args, method: 'GET' });
|
|
169
|
-
}
|
|
170
|
-
|
|
171
193
|
public async initiateOAuthFlow(
|
|
172
194
|
body: InitiateOAuthFlowRequest,
|
|
173
195
|
args?: EdgeHttpGetArgs,
|
|
@@ -190,10 +212,11 @@ export class EdgeHttpClient {
|
|
|
190
212
|
public async queryQueue(
|
|
191
213
|
subspaceTag: string,
|
|
192
214
|
spaceId: SpaceId,
|
|
193
|
-
query: QueueQuery,
|
|
215
|
+
query: FeedProtocol.QueueQuery,
|
|
194
216
|
args?: EdgeHttpGetArgs,
|
|
195
|
-
): Promise<QueryResult> {
|
|
196
|
-
const
|
|
217
|
+
): Promise<FeedProtocol.QueryResult> {
|
|
218
|
+
const queueId = query.queueIds?.[0];
|
|
219
|
+
invariant(queueId, 'queueId required');
|
|
197
220
|
return this._call(
|
|
198
221
|
createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}/query`, this.baseUrl), {
|
|
199
222
|
after: query.after,
|
|
@@ -250,8 +273,63 @@ export class EdgeHttpClient {
|
|
|
250
273
|
body: UploadFunctionRequest,
|
|
251
274
|
args?: EdgeHttpGetArgs,
|
|
252
275
|
): Promise<UploadFunctionResponseBody> {
|
|
276
|
+
const formData = new FormData();
|
|
277
|
+
formData.append('name', body.name ?? '');
|
|
278
|
+
formData.append('version', body.version);
|
|
279
|
+
formData.append('ownerPublicKey', body.ownerPublicKey);
|
|
280
|
+
formData.append('entryPoint', body.entryPoint);
|
|
281
|
+
body.runtime && formData.append('runtime', body.runtime);
|
|
282
|
+
for (const [filename, content] of Object.entries(body.assets)) {
|
|
283
|
+
formData.append(
|
|
284
|
+
'assets',
|
|
285
|
+
new Blob([content as Uint8Array<ArrayBuffer>], { type: getFileMimeType(filename) }),
|
|
286
|
+
filename,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
253
290
|
const path = ['functions', ...(pathParts.functionId ? [pathParts.functionId] : [])].join('/');
|
|
254
|
-
return this._call(new URL(path, this.baseUrl), {
|
|
291
|
+
return this._call(new URL(path, this.baseUrl), {
|
|
292
|
+
...args,
|
|
293
|
+
body: formData,
|
|
294
|
+
method: 'PUT',
|
|
295
|
+
json: false,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
public async listFunctions(args?: EdgeHttpGetArgs): Promise<any> {
|
|
300
|
+
return this._call(new URL('/functions', this.baseUrl), { ...args, method: 'GET' });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
public async invokeFunction(
|
|
304
|
+
params: {
|
|
305
|
+
functionId: string;
|
|
306
|
+
version?: string;
|
|
307
|
+
spaceId?: SpaceId;
|
|
308
|
+
cpuTimeLimit?: number;
|
|
309
|
+
subrequestsLimit?: number;
|
|
310
|
+
},
|
|
311
|
+
input: unknown,
|
|
312
|
+
args?: EdgeHttpGetArgs,
|
|
313
|
+
): Promise<any> {
|
|
314
|
+
const url = new URL(`/functions/${params.functionId}`, this.baseUrl);
|
|
315
|
+
if (params.version) {
|
|
316
|
+
url.searchParams.set('version', params.version);
|
|
317
|
+
}
|
|
318
|
+
if (params.spaceId) {
|
|
319
|
+
url.searchParams.set('spaceId', params.spaceId.toString());
|
|
320
|
+
}
|
|
321
|
+
if (params.cpuTimeLimit) {
|
|
322
|
+
url.searchParams.set('cpuTimeLimit', params.cpuTimeLimit.toString());
|
|
323
|
+
}
|
|
324
|
+
if (params.subrequestsLimit) {
|
|
325
|
+
url.searchParams.set('subrequestsLimit', params.subrequestsLimit.toString());
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return this._call(url, {
|
|
329
|
+
...args,
|
|
330
|
+
body: input,
|
|
331
|
+
method: 'POST',
|
|
332
|
+
});
|
|
255
333
|
}
|
|
256
334
|
|
|
257
335
|
//
|
|
@@ -271,63 +349,135 @@ export class EdgeHttpClient {
|
|
|
271
349
|
});
|
|
272
350
|
}
|
|
273
351
|
|
|
352
|
+
//
|
|
353
|
+
// Triggers
|
|
354
|
+
//
|
|
355
|
+
|
|
356
|
+
public async getCronTriggers(spaceId: SpaceId): Promise<GetCronTriggersResponse> {
|
|
357
|
+
return this._call<GetCronTriggersResponse>(new URL(`/test/functions/${spaceId}/triggers/crons`, this.baseUrl), {
|
|
358
|
+
method: 'GET',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
public async forceRunCronTrigger(spaceId: SpaceId, triggerId: ObjectId) {
|
|
363
|
+
return this._call(new URL(`/test/functions/${spaceId}/triggers/crons/${triggerId}/run`, this.baseUrl), {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
//
|
|
369
|
+
// Query
|
|
370
|
+
//
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Execute a QueryAST query against a space.
|
|
374
|
+
*/
|
|
375
|
+
public async execQuery(
|
|
376
|
+
spaceId: SpaceId,
|
|
377
|
+
body: QueryRequestProto,
|
|
378
|
+
args?: EdgeHttpGetArgs,
|
|
379
|
+
): Promise<QueryResponseProto> {
|
|
380
|
+
return this._call(new URL(`/spaces/${spaceId}/exec-query`, this.baseUrl), { ...args, body, method: 'POST' });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
//
|
|
384
|
+
// Import/Export space.
|
|
385
|
+
//
|
|
386
|
+
|
|
387
|
+
public async importBundle(
|
|
388
|
+
spaceId: SpaceId, //
|
|
389
|
+
body: ImportBundleRequest,
|
|
390
|
+
args?: EdgeHttpGetArgs,
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
return this._call(new URL(`/spaces/${spaceId}/import`, this.baseUrl), { ...args, body, method: 'PUT' });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
public async exportBundle(
|
|
396
|
+
spaceId: SpaceId,
|
|
397
|
+
body: ExportBundleRequest,
|
|
398
|
+
args?: EdgeHttpGetArgs,
|
|
399
|
+
): Promise<ExportBundleResponse> {
|
|
400
|
+
return this._call(new URL(`/spaces/${spaceId}/export`, this.baseUrl), {
|
|
401
|
+
...args,
|
|
402
|
+
body,
|
|
403
|
+
method: 'POST',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
274
407
|
//
|
|
275
408
|
// Internal
|
|
276
409
|
//
|
|
277
410
|
|
|
278
|
-
private async _fetch<T>(url: URL,
|
|
279
|
-
return pipe(
|
|
411
|
+
private async _fetch<T>(url: URL, _args: EdgeHttpRequestArgs): Promise<T> {
|
|
412
|
+
return Function.pipe(
|
|
280
413
|
HttpClient.get(url),
|
|
281
414
|
withLogging,
|
|
282
415
|
withRetryConfig,
|
|
283
416
|
Effect.provide(FetchHttpClient.layer),
|
|
284
417
|
Effect.provide(HttpConfig.default),
|
|
285
418
|
Effect.withSpan('EdgeHttpClient'),
|
|
286
|
-
|
|
419
|
+
runAndForwardErrors,
|
|
287
420
|
) as T;
|
|
288
421
|
}
|
|
289
422
|
|
|
290
423
|
// TODO(burdon): Refactor with effect (see edge-http-client.test.ts).
|
|
291
424
|
private async _call<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
|
|
292
425
|
const shouldRetry = createRetryHandler(args);
|
|
293
|
-
const requestContext = args.context ??
|
|
426
|
+
const requestContext = args.context ?? Context.default();
|
|
294
427
|
log('fetch', { url, request: args.body });
|
|
295
428
|
|
|
296
429
|
let handledAuth = false;
|
|
430
|
+
const tryCount = 1;
|
|
297
431
|
while (true) {
|
|
298
|
-
let processingError: EdgeCallFailedError;
|
|
299
|
-
let retryAfterHeaderValue: number = Number.NaN;
|
|
432
|
+
let processingError: EdgeCallFailedError | undefined = undefined;
|
|
300
433
|
try {
|
|
434
|
+
if (!this._authHeader && args.auth) {
|
|
435
|
+
const response = await fetch(new URL(`/auth`, this.baseUrl));
|
|
436
|
+
if (response.status === 401) {
|
|
437
|
+
this._authHeader = await this._handleUnauthorized(response);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
301
441
|
const request = createRequest(args, this._authHeader);
|
|
442
|
+
log('call edge', { url, tryCount, authHeader: !!this._authHeader });
|
|
302
443
|
const response = await fetch(url, request);
|
|
303
|
-
|
|
444
|
+
|
|
304
445
|
if (response.ok) {
|
|
305
|
-
const body =
|
|
446
|
+
const body = await response.clone().json();
|
|
447
|
+
invariant(body, 'Expected body to be present');
|
|
448
|
+
if (!('success' in body)) {
|
|
449
|
+
return body;
|
|
450
|
+
}
|
|
306
451
|
if (body.success) {
|
|
307
452
|
return body.data;
|
|
308
453
|
}
|
|
309
|
-
|
|
310
|
-
log.warn('unsuccessful edge response', { url, body });
|
|
311
|
-
if (body.errorData?.type === 'auth_challenge' && typeof body.errorData?.challenge === 'string') {
|
|
312
|
-
processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
|
|
313
|
-
} else {
|
|
314
|
-
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
315
|
-
}
|
|
316
454
|
} else if (response.status === 401 && !handledAuth) {
|
|
317
455
|
this._authHeader = await this._handleUnauthorized(response);
|
|
318
456
|
handledAuth = true;
|
|
319
457
|
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const body: EdgeFailure =
|
|
461
|
+
response.headers.get('Content-Type') === 'application/json' ? await response.clone().json() : undefined;
|
|
462
|
+
|
|
463
|
+
invariant(!body?.success, 'Expected body to not be a failure response or undefined.');
|
|
464
|
+
|
|
465
|
+
if (body?.data?.type === 'auth_challenge' && typeof body?.data?.challenge === 'string') {
|
|
466
|
+
processingError = new EdgeAuthChallengeError(body.data.challenge, body.data);
|
|
467
|
+
} else if (body?.success === false) {
|
|
468
|
+
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
320
469
|
} else {
|
|
321
|
-
|
|
470
|
+
invariant(!response.ok, 'Expected response to not be ok.');
|
|
471
|
+
processingError = await EdgeCallFailedError.fromHttpFailure(response);
|
|
322
472
|
}
|
|
323
473
|
} catch (error: any) {
|
|
324
474
|
processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
|
|
325
475
|
}
|
|
326
476
|
|
|
327
|
-
if (processingError
|
|
328
|
-
log('retrying edge request', { url, processingError });
|
|
477
|
+
if (processingError?.isRetryable && (await shouldRetry(requestContext, processingError.retryAfterMs))) {
|
|
478
|
+
log.verbose('retrying edge request', { url, processingError });
|
|
329
479
|
} else {
|
|
330
|
-
throw processingError
|
|
480
|
+
throw processingError!;
|
|
331
481
|
}
|
|
332
482
|
}
|
|
333
483
|
}
|
|
@@ -335,7 +485,7 @@ export class EdgeHttpClient {
|
|
|
335
485
|
private async _handleUnauthorized(response: Response): Promise<string> {
|
|
336
486
|
if (!this._edgeIdentity) {
|
|
337
487
|
log.warn('unauthorized response received before identity was set');
|
|
338
|
-
throw EdgeCallFailedError.fromHttpFailure(response);
|
|
488
|
+
throw await EdgeCallFailedError.fromHttpFailure(response);
|
|
339
489
|
}
|
|
340
490
|
|
|
341
491
|
const challenge = await handleAuthChallenge(response, this._edgeIdentity);
|
|
@@ -343,11 +493,32 @@ export class EdgeHttpClient {
|
|
|
343
493
|
}
|
|
344
494
|
}
|
|
345
495
|
|
|
346
|
-
const createRequest = (
|
|
496
|
+
const createRequest = (
|
|
497
|
+
{ method, body, json = true }: EdgeHttpRequestArgs,
|
|
498
|
+
authHeader: string | undefined,
|
|
499
|
+
): RequestInit => {
|
|
500
|
+
let requestBody: BodyInit | undefined;
|
|
501
|
+
const headers: HeadersInit = {};
|
|
502
|
+
|
|
503
|
+
if (json) {
|
|
504
|
+
requestBody = body && JSON.stringify(body);
|
|
505
|
+
headers['Content-Type'] = 'application/json';
|
|
506
|
+
} else {
|
|
507
|
+
requestBody = body;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (typeof requestBody === 'string' && requestBody.length > WARNING_BODY_SIZE) {
|
|
511
|
+
log.warn('Request with large body', { bodySize: requestBody.length });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (authHeader) {
|
|
515
|
+
headers['Authorization'] = authHeader;
|
|
516
|
+
}
|
|
517
|
+
|
|
347
518
|
return {
|
|
348
519
|
method,
|
|
349
|
-
body:
|
|
350
|
-
headers
|
|
520
|
+
body: requestBody,
|
|
521
|
+
headers,
|
|
351
522
|
};
|
|
352
523
|
};
|
|
353
524
|
|
|
@@ -363,7 +534,7 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
|
|
|
363
534
|
const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
|
|
364
535
|
const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
|
|
365
536
|
const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
|
|
366
|
-
return async (ctx: Context, retryAfter
|
|
537
|
+
return async (ctx: Context, retryAfter?: number) => {
|
|
367
538
|
if (++retries > maxRetries || ctx.disposed) {
|
|
368
539
|
return false;
|
|
369
540
|
}
|
|
@@ -378,3 +549,10 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
|
|
|
378
549
|
return true;
|
|
379
550
|
};
|
|
380
551
|
};
|
|
552
|
+
|
|
553
|
+
const getFileMimeType = (filename: string) =>
|
|
554
|
+
['.js', '.mjs'].some((codeExtension) => filename.endsWith(codeExtension))
|
|
555
|
+
? 'application/javascript+module'
|
|
556
|
+
: filename.endsWith('.wasm')
|
|
557
|
+
? 'application/wasm'
|
|
558
|
+
: 'application/octet-stream';
|
|
@@ -10,7 +10,7 @@ import { invariant } from '@dxos/invariant';
|
|
|
10
10
|
import { log, logInfo } from '@dxos/log';
|
|
11
11
|
import { EdgeWebsocketProtocol } from '@dxos/protocols';
|
|
12
12
|
import { buf } from '@dxos/protocols/buf';
|
|
13
|
-
import {
|
|
13
|
+
import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
14
14
|
|
|
15
15
|
import { protocol } from './defs';
|
|
16
16
|
import { type EdgeIdentity } from './edge-identity';
|
|
@@ -32,6 +32,22 @@ export class EdgeWsConnection extends Resource {
|
|
|
32
32
|
private _wsMuxer: WebSocketMuxer | undefined;
|
|
33
33
|
private _lastReceivedMessageTimestamp = Date.now();
|
|
34
34
|
|
|
35
|
+
private _openTimestamp: number | undefined;
|
|
36
|
+
|
|
37
|
+
// Latency tracking.
|
|
38
|
+
private _pingTimestamp: number | undefined;
|
|
39
|
+
private _rtt = 0;
|
|
40
|
+
|
|
41
|
+
// Rate tracking with sliding window.
|
|
42
|
+
private _uploadRate = 0;
|
|
43
|
+
private _downloadRate = 0;
|
|
44
|
+
private readonly _rateWindow = 10000; // 10 second sliding window.
|
|
45
|
+
private readonly _rateUpdateInterval = 1000; // Update rates every second.
|
|
46
|
+
private _bytesSamples: Array<{ timestamp: number; sent: number; received: number }> = [];
|
|
47
|
+
|
|
48
|
+
private _messagesSent = 0;
|
|
49
|
+
private _messagesReceived = 0;
|
|
50
|
+
|
|
35
51
|
constructor(
|
|
36
52
|
private readonly _identity: EdgeIdentity,
|
|
37
53
|
private readonly _connectionInfo: { url: URL; protocolHeader?: string },
|
|
@@ -49,10 +65,35 @@ export class EdgeWsConnection extends Resource {
|
|
|
49
65
|
};
|
|
50
66
|
}
|
|
51
67
|
|
|
68
|
+
public get rtt(): number {
|
|
69
|
+
return this._rtt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public get uptime(): number {
|
|
73
|
+
return this._openTimestamp ? (Date.now() - this._openTimestamp) / 1000 : 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public get uploadRate(): number {
|
|
77
|
+
return this._uploadRate;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public get downloadRate(): number {
|
|
81
|
+
return this._downloadRate;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public get messagesSent(): number {
|
|
85
|
+
return this._messagesSent;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public get messagesReceived(): number {
|
|
89
|
+
return this._messagesReceived;
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
public send(message: Message): void {
|
|
53
93
|
invariant(this._ws);
|
|
54
94
|
invariant(this._wsMuxer);
|
|
55
95
|
log('sending...', { peerKey: this._identity.peerKey, payload: protocol.getPayloadType(message) });
|
|
96
|
+
this._messagesSent++;
|
|
56
97
|
if (this._ws?.protocol.includes(EdgeWebsocketProtocol.V0)) {
|
|
57
98
|
const binary = buf.toBinary(MessageSchema, message);
|
|
58
99
|
if (binary.length > CLOUDFLARE_MESSAGE_MAX_BYTES) {
|
|
@@ -63,8 +104,12 @@ export class EdgeWsConnection extends Resource {
|
|
|
63
104
|
});
|
|
64
105
|
return;
|
|
65
106
|
}
|
|
107
|
+
this._recordBytes(binary.byteLength, 0);
|
|
66
108
|
this._ws.send(binary);
|
|
67
109
|
} else {
|
|
110
|
+
// For muxer, we need to track the size of the message being sent.
|
|
111
|
+
const binary = buf.toBinary(MessageSchema, message);
|
|
112
|
+
this._recordBytes(binary.byteLength, 0);
|
|
68
113
|
this._wsMuxer.send(message).catch((e) => log.catch(e));
|
|
69
114
|
}
|
|
70
115
|
}
|
|
@@ -83,20 +128,22 @@ export class EdgeWsConnection extends Resource {
|
|
|
83
128
|
this._ws.onopen = () => {
|
|
84
129
|
if (this.isOpen) {
|
|
85
130
|
log('connected');
|
|
131
|
+
this._openTimestamp = Date.now();
|
|
86
132
|
this._callbacks.onConnected();
|
|
87
133
|
this._scheduleHeartbeats();
|
|
134
|
+
this._scheduleRateCalculation();
|
|
88
135
|
} else {
|
|
89
136
|
log.verbose('connected after becoming inactive', { currentIdentity: this._identity });
|
|
90
137
|
}
|
|
91
138
|
};
|
|
92
|
-
this._ws.onclose = (event) => {
|
|
139
|
+
this._ws.onclose = (event: WebSocket.CloseEvent) => {
|
|
93
140
|
if (this.isOpen) {
|
|
94
|
-
log.warn('disconnected
|
|
141
|
+
log.warn('server disconnected', { code: event.code, reason: event.reason });
|
|
95
142
|
this._callbacks.onRestartRequired();
|
|
96
143
|
muxer.destroy();
|
|
97
144
|
}
|
|
98
145
|
};
|
|
99
|
-
this._ws.onerror = (event) => {
|
|
146
|
+
this._ws.onerror = (event: WebSocket.ErrorEvent) => {
|
|
100
147
|
if (this.isOpen) {
|
|
101
148
|
log.warn('edge connection socket error', { error: event.error, info: event.message });
|
|
102
149
|
this._callbacks.onRestartRequired();
|
|
@@ -107,21 +154,29 @@ export class EdgeWsConnection extends Resource {
|
|
|
107
154
|
/**
|
|
108
155
|
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
|
|
109
156
|
*/
|
|
110
|
-
this._ws.onmessage = async (event) => {
|
|
157
|
+
this._ws.onmessage = async (event: WebSocket.MessageEvent) => {
|
|
111
158
|
if (!this.isOpen) {
|
|
112
159
|
log.verbose('message ignored on closed connection', { event: event.type });
|
|
113
160
|
return;
|
|
114
161
|
}
|
|
115
162
|
this._lastReceivedMessageTimestamp = Date.now();
|
|
116
163
|
if (event.data === '__pong__') {
|
|
164
|
+
// Calculate latency.
|
|
165
|
+
if (this._pingTimestamp) {
|
|
166
|
+
this._rtt = Date.now() - this._pingTimestamp;
|
|
167
|
+
this._pingTimestamp = undefined;
|
|
168
|
+
}
|
|
117
169
|
this._rescheduleHeartbeatTimeout();
|
|
118
170
|
return;
|
|
119
171
|
}
|
|
120
172
|
const bytes = await toUint8Array(event.data);
|
|
173
|
+
this._recordBytes(0, bytes.byteLength);
|
|
121
174
|
if (!this.isOpen) {
|
|
122
175
|
return;
|
|
123
176
|
}
|
|
124
177
|
|
|
178
|
+
this._messagesReceived++;
|
|
179
|
+
|
|
125
180
|
const message = this._ws?.protocol?.includes(EdgeWebsocketProtocol.V0)
|
|
126
181
|
? buf.fromBinary(MessageSchema, bytes)
|
|
127
182
|
: muxer.receiveData(bytes);
|
|
@@ -145,7 +200,7 @@ export class EdgeWsConnection extends Resource {
|
|
|
145
200
|
if (err instanceof Error && err.message.includes('WebSocket is closed before the connection is established.')) {
|
|
146
201
|
return;
|
|
147
202
|
}
|
|
148
|
-
log.warn('
|
|
203
|
+
log.warn('error closing websocket', { err });
|
|
149
204
|
}
|
|
150
205
|
}
|
|
151
206
|
|
|
@@ -156,10 +211,12 @@ export class EdgeWsConnection extends Resource {
|
|
|
156
211
|
async () => {
|
|
157
212
|
// TODO(mykola): use RFC6455 ping/pong once implemented in the browser?
|
|
158
213
|
// Cloudflare's worker responds to this `without interrupting hibernation`. https://developers.cloudflare.com/durable-objects/api/websockets/#setwebsocketautoresponse
|
|
214
|
+
this._pingTimestamp = Date.now();
|
|
159
215
|
this._ws?.send('__ping__');
|
|
160
216
|
},
|
|
161
217
|
SIGNAL_KEEPALIVE_INTERVAL,
|
|
162
218
|
);
|
|
219
|
+
this._pingTimestamp = Date.now();
|
|
163
220
|
this._ws.send('__ping__');
|
|
164
221
|
this._rescheduleHeartbeatTimeout();
|
|
165
222
|
}
|
|
@@ -187,4 +244,60 @@ export class EdgeWsConnection extends Resource {
|
|
|
187
244
|
SIGNAL_KEEPALIVE_TIMEOUT,
|
|
188
245
|
);
|
|
189
246
|
}
|
|
247
|
+
|
|
248
|
+
private _recordBytes(sent: number, received: number): void {
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
|
|
251
|
+
// Find if we have a sample for the current second.
|
|
252
|
+
const currentSecond = Math.floor(now / 1000) * 1000;
|
|
253
|
+
const existingSample = this._bytesSamples.find((s) => Math.floor(s.timestamp / 1000) * 1000 === currentSecond);
|
|
254
|
+
|
|
255
|
+
if (existingSample) {
|
|
256
|
+
existingSample.sent += sent;
|
|
257
|
+
existingSample.received += received;
|
|
258
|
+
} else {
|
|
259
|
+
this._bytesSamples.push({ timestamp: now, sent, received });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private _scheduleRateCalculation(): void {
|
|
264
|
+
scheduleTaskInterval(
|
|
265
|
+
this._ctx,
|
|
266
|
+
async () => {
|
|
267
|
+
this._calculateRates();
|
|
268
|
+
},
|
|
269
|
+
this._rateUpdateInterval,
|
|
270
|
+
);
|
|
271
|
+
// Calculate initial rates.
|
|
272
|
+
this._calculateRates();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private _calculateRates(): void {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
const cutoff = now - this._rateWindow;
|
|
278
|
+
|
|
279
|
+
// Remove old samples.
|
|
280
|
+
this._bytesSamples = this._bytesSamples.filter((s) => s.timestamp > cutoff);
|
|
281
|
+
|
|
282
|
+
if (this._bytesSamples.length === 0) {
|
|
283
|
+
this._uploadRate = 0;
|
|
284
|
+
this._downloadRate = 0;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Calculate total bytes and time span.
|
|
289
|
+
let totalSent = 0;
|
|
290
|
+
let totalReceived = 0;
|
|
291
|
+
const oldestTimestamp = Math.min(...this._bytesSamples.map((s) => s.timestamp));
|
|
292
|
+
const timeSpan = (now - oldestTimestamp) / 1000; // Convert to seconds.
|
|
293
|
+
|
|
294
|
+
for (const sample of this._bytesSamples) {
|
|
295
|
+
totalSent += sample.sent;
|
|
296
|
+
totalReceived += sample.received;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Calculate rates (bytes per second).
|
|
300
|
+
this._uploadRate = timeSpan > 0 ? Math.round(totalSent / timeSpan) : 0;
|
|
301
|
+
this._downloadRate = timeSpan > 0 ? Math.round(totalReceived / timeSpan) : 0;
|
|
302
|
+
}
|
|
190
303
|
}
|
package/src/edge-ws-muxer.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { Trigger } from '@dxos/async';
|
|
6
6
|
import { log } from '@dxos/log';
|
|
7
7
|
import { buf } from '@dxos/protocols/buf';
|
|
8
|
-
import {
|
|
8
|
+
import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
9
9
|
|
|
10
10
|
import { protocol } from './defs';
|
|
11
11
|
|
package/src/http-client.test.ts
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
|
|
6
|
+
import * as HttpClient from '@effect/platform/HttpClient';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Function from 'effect/Function';
|
|
7
9
|
import { afterEach, beforeEach, describe, it } from 'vitest';
|
|
8
10
|
|
|
11
|
+
import { runAndForwardErrors } from '@dxos/effect';
|
|
9
12
|
import { invariant } from '@dxos/invariant';
|
|
10
13
|
|
|
11
14
|
import { HttpConfig, withLogging, withRetry, withRetryConfig } from './http-client';
|
|
@@ -23,30 +26,31 @@ describe('HttpClient', () => {
|
|
|
23
26
|
server = undefined;
|
|
24
27
|
});
|
|
25
28
|
|
|
26
|
-
// TODO(burdon): Auth headers.
|
|
29
|
+
// TODO(burdon): Auth headers/API key for admin.
|
|
27
30
|
// TODO(burdon): Add request/response schema type checking.
|
|
31
|
+
// TODO(burdon): Test swarm.
|
|
28
32
|
it.skipIf(process.env.CI)('should retry', async ({ expect }) => {
|
|
29
33
|
invariant(server);
|
|
30
34
|
|
|
31
35
|
{
|
|
32
|
-
const result = await pipe(
|
|
36
|
+
const result = await Function.pipe(
|
|
33
37
|
withRetry(HttpClient.get(server.url)),
|
|
34
38
|
Effect.provide(FetchHttpClient.layer),
|
|
35
39
|
Effect.withSpan('EdgeHttpClient'),
|
|
36
|
-
|
|
40
|
+
runAndForwardErrors,
|
|
37
41
|
);
|
|
38
42
|
expect(result).toMatchObject({ success: true, data: { value: 100 } });
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
{
|
|
42
|
-
const result = await pipe(
|
|
46
|
+
const result = await Function.pipe(
|
|
43
47
|
HttpClient.get(server.url),
|
|
44
48
|
withLogging,
|
|
45
49
|
withRetryConfig,
|
|
46
50
|
Effect.provide(FetchHttpClient.layer),
|
|
47
51
|
Effect.provide(HttpConfig.default), // TODO(burdon): Swap out to mock.
|
|
48
52
|
Effect.withSpan('EdgeHttpClient'), // TODO(burdon): OTEL.
|
|
49
|
-
|
|
53
|
+
runAndForwardErrors,
|
|
50
54
|
);
|
|
51
55
|
expect(result).toMatchObject({ success: true, data: { value: 100 } });
|
|
52
56
|
}
|