@dxos/edge-client 0.8.4-main.84f28bd → 0.8.4-main.ae835ea

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 (41) 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 +527 -275
  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 +527 -275
  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 +22 -1
  20. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  21. package/dist/types/src/edge-ws-connection.d.ts +20 -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 +1 -1
  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 +18 -15
  32. package/src/edge-client.test.ts +4 -4
  33. package/src/edge-client.ts +73 -42
  34. package/src/edge-http-client.ts +172 -31
  35. package/src/edge-ws-connection.ts +129 -8
  36. package/src/edge-ws-muxer.ts +1 -1
  37. package/src/http-client.test.ts +8 -6
  38. package/src/http-client.ts +13 -7
  39. package/src/index.ts +4 -3
  40. package/src/testing/test-utils.ts +4 -4
  41. package/src/websocket.test.ts +1 -1
@@ -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';
@@ -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 while being open', { code: event.code, reason: event.reason });
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('Error closing websocket', { err });
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
- log.warn('restart due to inactivity timeout');
176
- this._callbacks.onRestartRequired();
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
  }
@@ -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,8 +2,10 @@
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
 
9
11
  import { invariant } from '@dxos/invariant';
@@ -18,19 +20,19 @@ describe('HttpClient', () => {
18
20
  server = await createTestServer(responseHandler((attempt) => (attempt > 2 ? { value: 100 } : false)));
19
21
  });
20
22
 
21
- // eslint-disable-next-line mocha/no-top-level-hooks
22
23
  afterEach(() => {
23
24
  server?.close();
24
25
  server = undefined;
25
26
  });
26
27
 
27
- // TODO(burdon): Auth headers.
28
+ // TODO(burdon): Auth headers/API key for admin.
28
29
  // TODO(burdon): Add request/response schema type checking.
30
+ // TODO(burdon): Test swarm.
29
31
  it.skipIf(process.env.CI)('should retry', async ({ expect }) => {
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,
@@ -2,10 +2,14 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type HttpClient } from '@effect/platform';
6
- import { type HttpClientError } from '@effect/platform/HttpClientError';
7
- import { type HttpClientResponse } from '@effect/platform/HttpClientResponse';
8
- import { Context, Duration, Effect, Layer, Schedule } from 'effect';
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,13 +52,15 @@ export const withRetry = (
48
52
  );
49
53
  };
50
54
 
51
- export const withRetryConfig = (effect: Effect.Effect<HttpClientResponse, HttpClientError, HttpClient.HttpClient>) =>
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>) =>
63
+ export const withLogging = <A extends HttpClientResponse.HttpClientResponse, E, R>(effect: Effect.Effect<A, E, R>) =>
58
64
  effect.pipe(Effect.tap((res) => log.info('response', { status: res.status })));
59
65
 
60
66
  /**
package/src/index.ts CHANGED
@@ -4,11 +4,12 @@
4
4
 
5
5
  export * from '@dxos/protocols/buf/dxos/edge/messenger_pb';
6
6
 
7
- export * from './edge-client';
7
+ export * from './auth';
8
8
  export * from './defs';
9
- export * from './protocol';
9
+ export * from './edge-client';
10
10
  export * from './errors';
11
- export * from './auth';
11
+ export * from './protocol';
12
12
  export * from './edge-http-client';
13
13
  export * from './edge-identity';
14
14
  export * from './edge-ws-muxer';
15
+ export * from './http-client';
@@ -8,7 +8,7 @@ import { Trigger } from '@dxos/async';
8
8
  import { log } from '@dxos/log';
9
9
  import { EdgeWebsocketProtocol } from '@dxos/protocols';
10
10
  import { buf } from '@dxos/protocols/buf';
11
- import { MessageSchema, TextMessageSchema, type Message } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
11
+ import { type Message, MessageSchema, TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
12
12
 
13
13
  import { protocol } from '../defs';
14
14
  import { WebSocketMuxer } from '../edge-ws-muxer';
@@ -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;
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import WebSocket from 'isomorphic-ws';
6
- import { describe, expect, test, onTestFinished } from 'vitest';
6
+ import { describe, expect, onTestFinished, test } from 'vitest';
7
7
 
8
8
  import { Trigger, TriggerState } from '@dxos/async';
9
9