@dxos/edge-client 0.8.4-main.5ea62a8 → 0.8.4-main.72ec0f3
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-IKP53CBQ.mjs → chunk-VESGVCLQ.mjs} +15 -44
- package/dist/lib/browser/{chunk-IKP53CBQ.mjs.map → chunk-VESGVCLQ.mjs.map} +2 -2
- package/dist/lib/browser/edge-ws-muxer.mjs +1 -1
- package/dist/lib/browser/index.mjs +285 -169
- package/dist/lib/browser/index.mjs.map +3 -3
- 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-DR5YNW5K.mjs → chunk-JTBFRYNM.mjs} +15 -44
- package/dist/lib/node-esm/{chunk-DR5YNW5K.mjs.map → chunk-JTBFRYNM.mjs.map} +2 -2
- package/dist/lib/node-esm/edge-ws-muxer.mjs +1 -1
- package/dist/lib/node-esm/index.mjs +285 -169
- package/dist/lib/node-esm/index.mjs.map +3 -3
- 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.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +9 -2
- 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/http-client.d.ts +10 -7
- package/dist/types/src/http-client.d.ts.map +1 -1
- package/dist/types/src/testing/test-utils.d.ts +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +15 -15
- package/src/edge-client.test.ts +4 -4
- package/src/edge-client.ts +36 -5
- package/src/edge-http-client.test.ts +1 -1
- package/src/edge-http-client.ts +54 -25
- package/src/edge-ws-connection.ts +118 -5
- package/src/http-client.test.ts +6 -4
- package/src/http-client.ts +18 -8
- package/src/testing/test-utils.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/edge-client",
|
|
3
|
-
"version": "0.8.4-main.
|
|
3
|
+
"version": "0.8.4-main.72ec0f3",
|
|
4
4
|
"description": "EDGE Client",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -42,24 +42,24 @@
|
|
|
42
42
|
"README.md"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@effect/platform": "^0.
|
|
45
|
+
"@effect/platform": "^0.92.1",
|
|
46
46
|
"isomorphic-ws": "^5.0.0",
|
|
47
47
|
"ws": "^8.14.2",
|
|
48
|
-
"@dxos/async": "0.8.4-main.
|
|
49
|
-
"@dxos/context": "0.8.4-main.
|
|
50
|
-
"@dxos/credentials": "0.8.4-main.
|
|
51
|
-
"@dxos/
|
|
52
|
-
"@dxos/
|
|
53
|
-
"@dxos/
|
|
54
|
-
"@dxos/
|
|
55
|
-
"@dxos/
|
|
56
|
-
"@dxos/
|
|
57
|
-
"@dxos/
|
|
58
|
-
"@dxos/
|
|
59
|
-
"@dxos/
|
|
48
|
+
"@dxos/async": "0.8.4-main.72ec0f3",
|
|
49
|
+
"@dxos/context": "0.8.4-main.72ec0f3",
|
|
50
|
+
"@dxos/credentials": "0.8.4-main.72ec0f3",
|
|
51
|
+
"@dxos/invariant": "0.8.4-main.72ec0f3",
|
|
52
|
+
"@dxos/debug": "0.8.4-main.72ec0f3",
|
|
53
|
+
"@dxos/keyring": "0.8.4-main.72ec0f3",
|
|
54
|
+
"@dxos/crypto": "0.8.4-main.72ec0f3",
|
|
55
|
+
"@dxos/log": "0.8.4-main.72ec0f3",
|
|
56
|
+
"@dxos/keys": "0.8.4-main.72ec0f3",
|
|
57
|
+
"@dxos/node-std": "0.8.4-main.72ec0f3",
|
|
58
|
+
"@dxos/util": "0.8.4-main.72ec0f3",
|
|
59
|
+
"@dxos/protocols": "0.8.4-main.72ec0f3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@dxos/test-utils": "0.8.4-main.
|
|
62
|
+
"@dxos/test-utils": "0.8.4-main.72ec0f3"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"effect": "^3.13.3"
|
package/src/edge-client.test.ts
CHANGED
|
@@ -41,17 +41,17 @@ describe('EdgeClient', () => {
|
|
|
41
41
|
|
|
42
42
|
const { client } = await openNewClient(endpoint);
|
|
43
43
|
|
|
44
|
-
expect(client.status).toBe(EdgeStatus.NOT_CONNECTED);
|
|
44
|
+
expect(client.status.state).toBe(EdgeStatus.ConnectionState.NOT_CONNECTED);
|
|
45
45
|
admitConnection.wake();
|
|
46
|
-
await expect.poll(() => client.status).toBe(EdgeStatus.CONNECTED);
|
|
46
|
+
await expect.poll(() => client.status.state).toBe(EdgeStatus.ConnectionState.CONNECTED);
|
|
47
47
|
|
|
48
48
|
admitConnection.reset();
|
|
49
49
|
await closeConnection();
|
|
50
50
|
expect(client.isOpen).is.true;
|
|
51
|
-
await expect.poll(() => client.status).toBe(EdgeStatus.NOT_CONNECTED);
|
|
51
|
+
await expect.poll(() => client.status.state).toBe(EdgeStatus.ConnectionState.NOT_CONNECTED);
|
|
52
52
|
|
|
53
53
|
admitConnection.wake();
|
|
54
|
-
await expect.poll(() => client.status).toBe(EdgeStatus.CONNECTED);
|
|
54
|
+
await expect.poll(() => client.status.state).toBe(EdgeStatus.ConnectionState.CONNECTED);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
test('set identity reconnects', async () => {
|
package/src/edge-client.ts
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Event,
|
|
7
|
+
PersistentLifecycle,
|
|
8
|
+
Trigger,
|
|
9
|
+
TriggerState,
|
|
10
|
+
scheduleMicroTask,
|
|
11
|
+
scheduleTaskInterval,
|
|
12
|
+
} from '@dxos/async';
|
|
6
13
|
import { type Lifecycle, Resource } from '@dxos/context';
|
|
7
14
|
import { log, logInfo } from '@dxos/log';
|
|
8
15
|
import { type Message } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
@@ -17,6 +24,9 @@ import { getEdgeUrlWithProtocol } from './utils';
|
|
|
17
24
|
|
|
18
25
|
const DEFAULT_TIMEOUT = 10_000;
|
|
19
26
|
|
|
27
|
+
// Refresh status every second: rtt, rate counters.
|
|
28
|
+
const STATUS_REFRESH_INTERVAL = 1000;
|
|
29
|
+
|
|
20
30
|
export type MessageListener = (message: Message) => void;
|
|
21
31
|
export type ReconnectListener = () => void;
|
|
22
32
|
|
|
@@ -80,10 +90,19 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
80
90
|
};
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
get status() {
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
get status(): EdgeStatus {
|
|
94
|
+
return {
|
|
95
|
+
state:
|
|
96
|
+
Boolean(this._currentConnection) && this._ready.state === TriggerState.RESOLVED
|
|
97
|
+
? EdgeStatus.ConnectionState.CONNECTED
|
|
98
|
+
: EdgeStatus.ConnectionState.NOT_CONNECTED,
|
|
99
|
+
uptime: this._currentConnection?.uptime ?? 0,
|
|
100
|
+
rtt: this._currentConnection?.rtt ?? 0,
|
|
101
|
+
rateBytesUp: this._currentConnection?.uploadRate ?? 0,
|
|
102
|
+
rateBytesDown: this._currentConnection?.downloadRate ?? 0,
|
|
103
|
+
messagesSent: this._currentConnection?.messagesSent ?? 0,
|
|
104
|
+
messagesReceived: this._currentConnection?.messagesReceived ?? 0,
|
|
105
|
+
};
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
get identityKey() {
|
|
@@ -159,6 +178,18 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
159
178
|
this._persistentLifecycle.open().catch((err) => {
|
|
160
179
|
log.warn('Error while opening connection', { err });
|
|
161
180
|
});
|
|
181
|
+
|
|
182
|
+
// Notify about status changes (rtt, rate counters).
|
|
183
|
+
scheduleTaskInterval(
|
|
184
|
+
this._ctx,
|
|
185
|
+
async () => {
|
|
186
|
+
if (!this._currentConnection) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.statusChanged.emit(this.status);
|
|
190
|
+
},
|
|
191
|
+
STATUS_REFRESH_INTERVAL,
|
|
192
|
+
);
|
|
162
193
|
}
|
|
163
194
|
|
|
164
195
|
/**
|
|
@@ -11,7 +11,7 @@ import { EdgeHttpClient } from './edge-http-client';
|
|
|
11
11
|
const DEV_SERVER = 'https://edge.dxos.workers.dev';
|
|
12
12
|
|
|
13
13
|
describe.skipIf(process.env.CI)('EdgeHttpClient', () => {
|
|
14
|
-
it.
|
|
14
|
+
it.skip('should get status', async ({ expect }) => {
|
|
15
15
|
const client = new EdgeHttpClient(DEV_SERVER);
|
|
16
16
|
const identity = await createEphemeralEdgeIdentity();
|
|
17
17
|
client.setIdentity(identity);
|
package/src/edge-http-client.ts
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
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 { invariant } from '@dxos/invariant';
|
|
10
13
|
import { type PublicKey, type SpaceId } from '@dxos/keys';
|
|
11
14
|
import { log } from '@dxos/log';
|
|
12
15
|
import {
|
|
@@ -16,7 +19,6 @@ import {
|
|
|
16
19
|
type CreateSpaceResponseBody,
|
|
17
20
|
EdgeAuthChallengeError,
|
|
18
21
|
EdgeCallFailedError,
|
|
19
|
-
type EdgeHttpResponse,
|
|
20
22
|
type EdgeStatus,
|
|
21
23
|
type ExecuteWorkflowResponseBody,
|
|
22
24
|
type ExportBundleRequest,
|
|
@@ -75,12 +77,20 @@ type EdgeHttpRequestArgs = {
|
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* Do not expect a standard EDGE JSON response with a `success` field.
|
|
80
|
+
* @deprecated Use only for debugging.
|
|
78
81
|
*/
|
|
79
82
|
rawResponse?: boolean;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Force authentication.
|
|
86
|
+
* This should be used for requests with large bodies to avoid sending the body twice.
|
|
87
|
+
* The client will call /auth endpoint to generate the auth header.
|
|
88
|
+
*/
|
|
89
|
+
auth?: boolean;
|
|
80
90
|
};
|
|
81
91
|
|
|
82
|
-
export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry'>;
|
|
83
|
-
export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body'>;
|
|
92
|
+
export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'auth'>;
|
|
93
|
+
export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body' | 'auth'>;
|
|
84
94
|
|
|
85
95
|
export class EdgeHttpClient {
|
|
86
96
|
private readonly _baseUrl: string;
|
|
@@ -264,10 +274,11 @@ export class EdgeHttpClient {
|
|
|
264
274
|
formData.append('version', body.version);
|
|
265
275
|
formData.append('ownerPublicKey', body.ownerPublicKey);
|
|
266
276
|
formData.append('entryPoint', body.entryPoint);
|
|
277
|
+
body.runtime && formData.append('runtime', body.runtime);
|
|
267
278
|
for (const [filename, content] of Object.entries(body.assets)) {
|
|
268
279
|
formData.append(
|
|
269
280
|
'assets',
|
|
270
|
-
new Blob([content as Uint8Array<ArrayBuffer>], { type:
|
|
281
|
+
new Blob([content as Uint8Array<ArrayBuffer>], { type: getFileMimeType(filename) }),
|
|
271
282
|
filename,
|
|
272
283
|
);
|
|
273
284
|
}
|
|
@@ -371,8 +382,8 @@ export class EdgeHttpClient {
|
|
|
371
382
|
// Internal
|
|
372
383
|
//
|
|
373
384
|
|
|
374
|
-
private async _fetch<T>(url: URL,
|
|
375
|
-
return pipe(
|
|
385
|
+
private async _fetch<T>(url: URL, _args: EdgeHttpRequestArgs): Promise<T> {
|
|
386
|
+
return Function.pipe(
|
|
376
387
|
HttpClient.get(url),
|
|
377
388
|
withLogging,
|
|
378
389
|
withRetryConfig,
|
|
@@ -386,51 +397,62 @@ export class EdgeHttpClient {
|
|
|
386
397
|
// TODO(burdon): Refactor with effect (see edge-http-client.test.ts).
|
|
387
398
|
private async _call<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
|
|
388
399
|
const shouldRetry = createRetryHandler(args);
|
|
389
|
-
const requestContext = args.context ??
|
|
400
|
+
const requestContext = args.context ?? Context.default();
|
|
390
401
|
log('fetch', { url, request: args.body });
|
|
391
402
|
|
|
392
403
|
let handledAuth = false;
|
|
404
|
+
const tryCount = 1;
|
|
393
405
|
while (true) {
|
|
394
406
|
let processingError: EdgeCallFailedError | undefined = undefined;
|
|
395
|
-
let retryAfterHeaderValue: number = Number.NaN;
|
|
396
407
|
try {
|
|
408
|
+
if (!this._authHeader && args.auth) {
|
|
409
|
+
const response = await fetch(new URL(`/auth`, this.baseUrl));
|
|
410
|
+
if (response.status === 401) {
|
|
411
|
+
this._authHeader = await this._handleUnauthorized(response);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
397
415
|
const request = createRequest(args, this._authHeader);
|
|
416
|
+
log('call edge', { url, tryCount, authHeader: !!this._authHeader });
|
|
398
417
|
const response = await fetch(url, request);
|
|
399
|
-
retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
|
|
400
|
-
if (response.ok) {
|
|
401
|
-
const body = (await response.json()) as EdgeHttpResponse<T>;
|
|
402
418
|
|
|
419
|
+
if (response.ok) {
|
|
420
|
+
const body = await response.clone().json();
|
|
403
421
|
if (args.rawResponse) {
|
|
404
422
|
return body as any;
|
|
405
423
|
}
|
|
406
|
-
|
|
424
|
+
invariant(body, 'Expected body to be present');
|
|
407
425
|
if (!('success' in body)) {
|
|
408
426
|
return body;
|
|
409
427
|
}
|
|
410
|
-
|
|
411
428
|
if (body.success) {
|
|
412
429
|
return body.data;
|
|
413
430
|
}
|
|
414
|
-
|
|
415
|
-
log.warn('unsuccessful edge response', { url, body });
|
|
416
|
-
if (body.errorData?.type === 'auth_challenge' && typeof body.errorData?.challenge === 'string') {
|
|
417
|
-
processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
|
|
418
|
-
} else if (body.errorData) {
|
|
419
|
-
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
420
|
-
}
|
|
421
431
|
} else if (response.status === 401 && !handledAuth) {
|
|
422
432
|
this._authHeader = await this._handleUnauthorized(response);
|
|
423
433
|
handledAuth = true;
|
|
424
434
|
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const body =
|
|
438
|
+
response.headers.get('Content-Type') === 'application/json' ? await response.clone().json() : undefined;
|
|
439
|
+
|
|
440
|
+
invariant(!body?.success, 'Expected body to not be a failure response or undefined.');
|
|
441
|
+
|
|
442
|
+
if (body?.errorData?.type === 'auth_challenge' && typeof body?.errorData?.challenge === 'string') {
|
|
443
|
+
processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
|
|
444
|
+
} else if (body?.success === false) {
|
|
445
|
+
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
425
446
|
} else {
|
|
447
|
+
invariant(!response.ok, 'Expected response to not be ok.');
|
|
426
448
|
processingError = await EdgeCallFailedError.fromHttpFailure(response);
|
|
427
449
|
}
|
|
428
450
|
} catch (error: any) {
|
|
429
451
|
processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
|
|
430
452
|
}
|
|
431
453
|
|
|
432
|
-
if (processingError?.isRetryable && (await shouldRetry(requestContext,
|
|
433
|
-
log('retrying edge request', { url, processingError });
|
|
454
|
+
if (processingError?.isRetryable && (await shouldRetry(requestContext, processingError.retryAfterMs))) {
|
|
455
|
+
log.verbose('retrying edge request', { url, processingError });
|
|
434
456
|
} else {
|
|
435
457
|
throw processingError!;
|
|
436
458
|
}
|
|
@@ -489,7 +511,7 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
|
|
|
489
511
|
const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
|
|
490
512
|
const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
|
|
491
513
|
const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
|
|
492
|
-
return async (ctx: Context, retryAfter
|
|
514
|
+
return async (ctx: Context, retryAfter?: number) => {
|
|
493
515
|
if (++retries > maxRetries || ctx.disposed) {
|
|
494
516
|
return false;
|
|
495
517
|
}
|
|
@@ -504,3 +526,10 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
|
|
|
504
526
|
return true;
|
|
505
527
|
};
|
|
506
528
|
};
|
|
529
|
+
|
|
530
|
+
const getFileMimeType = (filename: string) =>
|
|
531
|
+
['.js', '.mjs'].some((codeExtension) => filename.endsWith(codeExtension))
|
|
532
|
+
? 'application/javascript+module'
|
|
533
|
+
: filename.endsWith('.wasm')
|
|
534
|
+
? 'application/wasm'
|
|
535
|
+
: 'application/octet-stream';
|
|
@@ -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/http-client.test.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
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
|
|
|
9
11
|
import { invariant } from '@dxos/invariant';
|
|
@@ -30,7 +32,7 @@ describe('HttpClient', () => {
|
|
|
30
32
|
invariant(server);
|
|
31
33
|
|
|
32
34
|
{
|
|
33
|
-
const result = await pipe(
|
|
35
|
+
const result = await Function.pipe(
|
|
34
36
|
withRetry(HttpClient.get(server.url)),
|
|
35
37
|
Effect.provide(FetchHttpClient.layer),
|
|
36
38
|
Effect.withSpan('EdgeHttpClient'),
|
|
@@ -40,7 +42,7 @@ describe('HttpClient', () => {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
{
|
|
43
|
-
const result = await pipe(
|
|
45
|
+
const result = await Function.pipe(
|
|
44
46
|
HttpClient.get(server.url),
|
|
45
47
|
withLogging,
|
|
46
48
|
withRetryConfig,
|
package/src/http-client.ts
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
5
|
+
import type * as HttpClient from '@effect/platform/HttpClient';
|
|
6
|
+
import type * as HttpClientError from '@effect/platform/HttpClientError';
|
|
7
|
+
import type * as HttpClientResponse from '@effect/platform/HttpClientResponse';
|
|
8
|
+
import * as Context from 'effect/Context';
|
|
9
|
+
import * as Duration from 'effect/Duration';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as Schedule from 'effect/Schedule';
|
|
9
13
|
|
|
10
14
|
import { log } from '@dxos/log';
|
|
11
15
|
|
|
@@ -28,7 +32,7 @@ export class HttpConfig extends Context.Tag('HttpConfig')<HttpConfig, RetryOptio
|
|
|
28
32
|
|
|
29
33
|
// HOC pattern.
|
|
30
34
|
export const withRetry = (
|
|
31
|
-
effect: Effect.Effect<HttpClientResponse, HttpClientError, HttpClient.HttpClient>,
|
|
35
|
+
effect: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError, HttpClient.HttpClient>,
|
|
32
36
|
{
|
|
33
37
|
timeout = Duration.millis(1_000),
|
|
34
38
|
retryBaseDelay = Duration.millis(1_000),
|
|
@@ -48,14 +52,20 @@ export const withRetry = (
|
|
|
48
52
|
);
|
|
49
53
|
};
|
|
50
54
|
|
|
51
|
-
export const withRetryConfig = (
|
|
55
|
+
export const withRetryConfig = (
|
|
56
|
+
effect: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError, HttpClient.HttpClient>,
|
|
57
|
+
) =>
|
|
52
58
|
Effect.gen(function* () {
|
|
53
59
|
const config = yield* HttpConfig;
|
|
54
60
|
return yield* withRetry(effect, config);
|
|
55
61
|
});
|
|
56
62
|
|
|
57
|
-
export const withLogging = <A extends HttpClientResponse, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
58
|
-
effect.pipe(
|
|
63
|
+
export const withLogging = <A extends HttpClientResponse.HttpClientResponse, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
64
|
+
effect.pipe(
|
|
65
|
+
Effect.tap((res) => {
|
|
66
|
+
log.info('response', { status: res.status });
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
59
69
|
|
|
60
70
|
/**
|
|
61
71
|
*
|
|
@@ -36,11 +36,11 @@ export const createTestEdgeWsServer = async (port = DEFAULT_PORT, params?: TestE
|
|
|
36
36
|
const closeTrigger = new Trigger();
|
|
37
37
|
const sendResponseMessage = createResponseSender(() => connection!.muxer);
|
|
38
38
|
|
|
39
|
-
wsServer.on('connection', (ws) => {
|
|
39
|
+
wsServer.on('connection', (ws: WebSocket) => {
|
|
40
40
|
const muxer = new WebSocketMuxer(ws);
|
|
41
41
|
connection = { ws, muxer };
|
|
42
|
-
ws.on('error', (err) => log.catch(err));
|
|
43
|
-
ws.on('message', async (data) => {
|
|
42
|
+
ws.on('error', (err: Error) => log.catch(err));
|
|
43
|
+
ws.on('message', async (data: any) => {
|
|
44
44
|
if (String(data) === '__ping__') {
|
|
45
45
|
ws.send('__pong__');
|
|
46
46
|
return;
|