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