@dxos/edge-client 0.8.4-main.b97322e → 0.8.4-main.bc674ce

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.
Files changed (42) hide show
  1. package/dist/lib/browser/{chunk-LMP5TVOP.mjs → chunk-VESGVCLQ.mjs} +8 -4
  2. package/dist/lib/browser/{chunk-LMP5TVOP.mjs.map → chunk-VESGVCLQ.mjs.map} +3 -3
  3. package/dist/lib/browser/edge-ws-muxer.mjs +1 -1
  4. package/dist/lib/browser/index.mjs +561 -289
  5. package/dist/lib/browser/index.mjs.map +4 -4
  6. package/dist/lib/browser/meta.json +1 -1
  7. package/dist/lib/browser/testing/index.mjs +1 -1
  8. package/dist/lib/browser/testing/index.mjs.map +2 -2
  9. package/dist/lib/node-esm/{chunk-X7J46ISZ.mjs → chunk-JTBFRYNM.mjs} +8 -4
  10. package/dist/lib/node-esm/{chunk-X7J46ISZ.mjs.map → chunk-JTBFRYNM.mjs.map} +3 -3
  11. package/dist/lib/node-esm/edge-ws-muxer.mjs +1 -1
  12. package/dist/lib/node-esm/index.mjs +561 -289
  13. package/dist/lib/node-esm/index.mjs.map +4 -4
  14. package/dist/lib/node-esm/meta.json +1 -1
  15. package/dist/lib/node-esm/testing/index.mjs +1 -1
  16. package/dist/lib/node-esm/testing/index.mjs.map +2 -2
  17. package/dist/types/src/edge-client.d.ts +15 -15
  18. package/dist/types/src/edge-client.d.ts.map +1 -1
  19. package/dist/types/src/edge-http-client.d.ts +29 -4
  20. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  21. package/dist/types/src/edge-ws-connection.d.ts +19 -0
  22. package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
  23. package/dist/types/src/edge-ws-muxer.d.ts.map +1 -1
  24. package/dist/types/src/http-client.d.ts +10 -7
  25. package/dist/types/src/http-client.d.ts.map +1 -1
  26. package/dist/types/src/index.d.ts +4 -3
  27. package/dist/types/src/index.d.ts.map +1 -1
  28. package/dist/types/src/testing/test-utils.d.ts +3 -3
  29. package/dist/types/src/testing/test-utils.d.ts.map +1 -1
  30. package/dist/types/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +25 -17
  32. package/src/edge-client.test.ts +4 -4
  33. package/src/edge-client.ts +73 -42
  34. package/src/edge-http-client.test.ts +1 -1
  35. package/src/edge-http-client.ts +202 -42
  36. package/src/edge-ws-connection.ts +119 -6
  37. package/src/edge-ws-muxer.ts +1 -1
  38. package/src/http-client.test.ts +11 -7
  39. package/src/http-client.ts +18 -8
  40. package/src/index.ts +4 -3
  41. package/src/testing/test-utils.ts +8 -8
  42. package/src/websocket.test.ts +1 -1
@@ -2,47 +2,55 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { FetchHttpClient, HttpClient } from '@effect/platform';
6
- import { Effect, pipe } from 'effect';
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 EdgeHttpResponse,
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 { encodeAuthHeader, HttpConfig, withLogging, withRetryConfig } from './http-client';
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
  //
@@ -164,10 +187,6 @@ export class EdgeHttpClient {
164
187
  // OAuth and credentials
165
188
  //
166
189
 
167
- public async listFunctions(args?: EdgeHttpGetArgs): Promise<any> {
168
- return this._call(new URL('/functions', this.baseUrl), { ...args, method: 'GET' });
169
- }
170
-
171
190
  public async initiateOAuthFlow(
172
191
  body: InitiateOAuthFlowRequest,
173
192
  args?: EdgeHttpGetArgs,
@@ -193,7 +212,8 @@ export class EdgeHttpClient {
193
212
  query: QueueQuery,
194
213
  args?: EdgeHttpGetArgs,
195
214
  ): Promise<QueryResult> {
196
- const { queueId } = query;
215
+ const queueId = query.queueIds?.[0];
216
+ invariant(queueId, 'queueId required');
197
217
  return this._call(
198
218
  createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}/query`, this.baseUrl), {
199
219
  after: query.after,
@@ -250,8 +270,63 @@ export class EdgeHttpClient {
250
270
  body: UploadFunctionRequest,
251
271
  args?: EdgeHttpGetArgs,
252
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
+
253
287
  const path = ['functions', ...(pathParts.functionId ? [pathParts.functionId] : [])].join('/');
254
- return this._call(new URL(path, this.baseUrl), { ...args, body, method: 'PUT' });
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
+ });
255
330
  }
256
331
 
257
332
  //
@@ -271,63 +346,120 @@ export class EdgeHttpClient {
271
346
  });
272
347
  }
273
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
+
274
389
  //
275
390
  // Internal
276
391
  //
277
392
 
278
- private async _fetch<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
279
- return pipe(
393
+ private async _fetch<T>(url: URL, _args: EdgeHttpRequestArgs): Promise<T> {
394
+ return Function.pipe(
280
395
  HttpClient.get(url),
281
396
  withLogging,
282
397
  withRetryConfig,
283
398
  Effect.provide(FetchHttpClient.layer),
284
399
  Effect.provide(HttpConfig.default),
285
400
  Effect.withSpan('EdgeHttpClient'),
286
- Effect.runPromise,
401
+ runAndForwardErrors,
287
402
  ) as T;
288
403
  }
289
404
 
290
405
  // TODO(burdon): Refactor with effect (see edge-http-client.test.ts).
291
406
  private async _call<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
292
407
  const shouldRetry = createRetryHandler(args);
293
- const requestContext = args.context ?? new Context();
408
+ const requestContext = args.context ?? Context.default();
294
409
  log('fetch', { url, request: args.body });
295
410
 
296
411
  let handledAuth = false;
412
+ const tryCount = 1;
297
413
  while (true) {
298
- let processingError: EdgeCallFailedError;
299
- let retryAfterHeaderValue: number = Number.NaN;
414
+ let processingError: EdgeCallFailedError | undefined = undefined;
300
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
+
301
423
  const request = createRequest(args, this._authHeader);
424
+ log('call edge', { url, tryCount, authHeader: !!this._authHeader });
302
425
  const response = await fetch(url, request);
303
- retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
426
+
304
427
  if (response.ok) {
305
- const body = (await response.json()) as EdgeHttpResponse<T>;
428
+ const body = await response.clone().json();
429
+ invariant(body, 'Expected body to be present');
430
+ if (!('success' in body)) {
431
+ return body;
432
+ }
306
433
  if (body.success) {
307
434
  return body.data;
308
435
  }
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
436
  } else if (response.status === 401 && !handledAuth) {
317
437
  this._authHeader = await this._handleUnauthorized(response);
318
438
  handledAuth = true;
319
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);
320
451
  } else {
321
- processingError = EdgeCallFailedError.fromHttpFailure(response);
452
+ invariant(!response.ok, 'Expected response to not be ok.');
453
+ processingError = await EdgeCallFailedError.fromHttpFailure(response);
322
454
  }
323
455
  } catch (error: any) {
324
456
  processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
325
457
  }
326
458
 
327
- if (processingError.isRetryable && (await shouldRetry(requestContext, retryAfterHeaderValue))) {
328
- log('retrying edge request', { url, processingError });
459
+ if (processingError?.isRetryable && (await shouldRetry(requestContext, processingError.retryAfterMs))) {
460
+ log.verbose('retrying edge request', { url, processingError });
329
461
  } else {
330
- throw processingError;
462
+ throw processingError!;
331
463
  }
332
464
  }
333
465
  }
@@ -335,7 +467,7 @@ export class EdgeHttpClient {
335
467
  private async _handleUnauthorized(response: Response): Promise<string> {
336
468
  if (!this._edgeIdentity) {
337
469
  log.warn('unauthorized response received before identity was set');
338
- throw EdgeCallFailedError.fromHttpFailure(response);
470
+ throw await EdgeCallFailedError.fromHttpFailure(response);
339
471
  }
340
472
 
341
473
  const challenge = await handleAuthChallenge(response, this._edgeIdentity);
@@ -343,11 +475,32 @@ export class EdgeHttpClient {
343
475
  }
344
476
  }
345
477
 
346
- const createRequest = ({ method, body }: EdgeHttpRequestArgs, authHeader: string | undefined): RequestInit => {
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
+
347
500
  return {
348
501
  method,
349
- body: body && JSON.stringify(body),
350
- headers: authHeader ? { Authorization: authHeader } : undefined,
502
+ body: requestBody,
503
+ headers,
351
504
  };
352
505
  };
353
506
 
@@ -363,7 +516,7 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
363
516
  const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
364
517
  const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
365
518
  const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
366
- return async (ctx: Context, retryAfter: number) => {
519
+ return async (ctx: Context, retryAfter?: number) => {
367
520
  if (++retries > maxRetries || ctx.disposed) {
368
521
  return false;
369
522
  }
@@ -378,3 +531,10 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
378
531
  return true;
379
532
  };
380
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 { MessageSchema, type Message } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
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 while being open', { code: event.code, reason: event.reason });
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('Error closing websocket', { err });
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
  }
@@ -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 { MessageSchema, type Message } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
8
+ import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
9
9
 
10
10
  import { protocol } from './defs';
11
11
 
@@ -2,10 +2,13 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { FetchHttpClient, HttpClient } from '@effect/platform';
6
- import { Effect, pipe } from 'effect';
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
- Effect.runPromise,
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
- Effect.runPromise,
53
+ runAndForwardErrors,
50
54
  );
51
55
  expect(result).toMatchObject({ success: true, data: { value: 100 } });
52
56
  }