@dxos/edge-client 0.8.3 → 0.8.4-main.1c7ec43d41
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-VHS3XEIX.mjs → neutral/chunk-ZIQ5T3A7.mjs} +20 -50
- package/dist/lib/{browser/chunk-VHS3XEIX.mjs.map → neutral/chunk-ZIQ5T3A7.mjs.map} +3 -3
- package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs +1 -1
- package/dist/lib/neutral/index.mjs +1189 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser → neutral}/testing/index.mjs +53 -33
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/auth.d.ts.map +1 -1
- package/dist/types/src/edge-client.d.ts +18 -15
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +98 -37
- package/dist/types/src/edge-http-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.test.d.ts +2 -0
- package/dist/types/src/edge-http-client.test.d.ts.map +1 -0
- package/dist/types/src/edge-identity.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.d.ts +21 -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/errors.d.ts.map +1 -1
- package/dist/types/src/http-client.d.ts +25 -0
- package/dist/types/src/http-client.d.ts.map +1 -0
- package/dist/types/src/http-client.test.d.ts +2 -0
- package/dist/types/src/http-client.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/protocol.d.ts +1 -1
- package/dist/types/src/protocol.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +1 -0
- package/dist/types/src/testing/index.d.ts.map +1 -1
- package/dist/types/src/testing/test-server.d.ts +9 -0
- package/dist/types/src/testing/test-server.d.ts.map +1 -0
- 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/src/utils.d.ts +1 -1
- package/dist/types/src/utils.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +33 -29
- package/src/edge-client.test.ts +20 -15
- package/src/edge-client.ts +90 -43
- package/src/edge-http-client.test.ts +23 -0
- package/src/edge-http-client.ts +502 -164
- package/src/edge-ws-connection.ts +131 -9
- package/src/edge-ws-muxer.ts +1 -1
- package/src/http-client.test.ts +58 -0
- package/src/http-client.ts +77 -0
- package/src/index.ts +4 -3
- package/src/testing/index.ts +1 -0
- package/src/testing/test-server.ts +45 -0
- package/src/testing/test-utils.ts +9 -9
- package/src/websocket.test.ts +1 -1
- package/dist/lib/browser/index.mjs +0 -1034
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/node/chunk-XNHBUTNB.cjs +0 -317
- package/dist/lib/node/chunk-XNHBUTNB.cjs.map +0 -7
- package/dist/lib/node/edge-ws-muxer.cjs +0 -33
- package/dist/lib/node/edge-ws-muxer.cjs.map +0 -7
- package/dist/lib/node/index.cjs +0 -1060
- package/dist/lib/node/index.cjs.map +0 -7
- package/dist/lib/node/meta.json +0 -1
- package/dist/lib/node/testing/index.cjs +0 -169
- package/dist/lib/node/testing/index.cjs.map +0 -7
- package/dist/lib/node-esm/chunk-HGQUUFIJ.mjs +0 -299
- package/dist/lib/node-esm/chunk-HGQUUFIJ.mjs.map +0 -7
- package/dist/lib/node-esm/edge-ws-muxer.mjs +0 -12
- package/dist/lib/node-esm/edge-ws-muxer.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -1035
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/testing/index.mjs +0 -141
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- /package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs.map +0 -0
|
@@ -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,10 +30,27 @@ 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,
|
|
36
|
-
private readonly _connectionInfo: { url: URL; protocolHeader?: string },
|
|
53
|
+
private readonly _connectionInfo: { url: URL; protocolHeader?: string; headers?: Record<string, string> },
|
|
37
54
|
private readonly _callbacks: EdgeWsConnectionCallbacks,
|
|
38
55
|
) {
|
|
39
56
|
super();
|
|
@@ -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
|
}
|
|
@@ -75,6 +121,7 @@ export class EdgeWsConnection extends Resource {
|
|
|
75
121
|
this._connectionInfo.protocolHeader
|
|
76
122
|
? [...baseProtocols, this._connectionInfo.protocolHeader]
|
|
77
123
|
: [...baseProtocols],
|
|
124
|
+
this._connectionInfo.headers ? { headers: this._connectionInfo.headers } : undefined,
|
|
78
125
|
);
|
|
79
126
|
const muxer = new WebSocketMuxer(this._ws);
|
|
80
127
|
this._wsMuxer = muxer;
|
|
@@ -82,20 +129,22 @@ export class EdgeWsConnection extends Resource {
|
|
|
82
129
|
this._ws.onopen = () => {
|
|
83
130
|
if (this.isOpen) {
|
|
84
131
|
log('connected');
|
|
132
|
+
this._openTimestamp = Date.now();
|
|
85
133
|
this._callbacks.onConnected();
|
|
86
134
|
this._scheduleHeartbeats();
|
|
135
|
+
this._scheduleRateCalculation();
|
|
87
136
|
} else {
|
|
88
137
|
log.verbose('connected after becoming inactive', { currentIdentity: this._identity });
|
|
89
138
|
}
|
|
90
139
|
};
|
|
91
|
-
this._ws.onclose = (event) => {
|
|
140
|
+
this._ws.onclose = (event: WebSocket.CloseEvent) => {
|
|
92
141
|
if (this.isOpen) {
|
|
93
|
-
log.warn('disconnected
|
|
142
|
+
log.warn('server disconnected', { code: event.code, reason: event.reason });
|
|
94
143
|
this._callbacks.onRestartRequired();
|
|
95
144
|
muxer.destroy();
|
|
96
145
|
}
|
|
97
146
|
};
|
|
98
|
-
this._ws.onerror = (event) => {
|
|
147
|
+
this._ws.onerror = (event: WebSocket.ErrorEvent) => {
|
|
99
148
|
if (this.isOpen) {
|
|
100
149
|
log.warn('edge connection socket error', { error: event.error, info: event.message });
|
|
101
150
|
this._callbacks.onRestartRequired();
|
|
@@ -106,20 +155,29 @@ export class EdgeWsConnection extends Resource {
|
|
|
106
155
|
/**
|
|
107
156
|
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
|
|
108
157
|
*/
|
|
109
|
-
this._ws.onmessage = async (event) => {
|
|
158
|
+
this._ws.onmessage = async (event: WebSocket.MessageEvent) => {
|
|
110
159
|
if (!this.isOpen) {
|
|
111
160
|
log.verbose('message ignored on closed connection', { event: event.type });
|
|
112
161
|
return;
|
|
113
162
|
}
|
|
163
|
+
this._lastReceivedMessageTimestamp = Date.now();
|
|
114
164
|
if (event.data === '__pong__') {
|
|
165
|
+
// Calculate latency.
|
|
166
|
+
if (this._pingTimestamp) {
|
|
167
|
+
this._rtt = Date.now() - this._pingTimestamp;
|
|
168
|
+
this._pingTimestamp = undefined;
|
|
169
|
+
}
|
|
115
170
|
this._rescheduleHeartbeatTimeout();
|
|
116
171
|
return;
|
|
117
172
|
}
|
|
118
173
|
const bytes = await toUint8Array(event.data);
|
|
174
|
+
this._recordBytes(0, bytes.byteLength);
|
|
119
175
|
if (!this.isOpen) {
|
|
120
176
|
return;
|
|
121
177
|
}
|
|
122
178
|
|
|
179
|
+
this._messagesReceived++;
|
|
180
|
+
|
|
123
181
|
const message = this._ws?.protocol?.includes(EdgeWebsocketProtocol.V0)
|
|
124
182
|
? buf.fromBinary(MessageSchema, bytes)
|
|
125
183
|
: muxer.receiveData(bytes);
|
|
@@ -143,7 +201,7 @@ export class EdgeWsConnection extends Resource {
|
|
|
143
201
|
if (err instanceof Error && err.message.includes('WebSocket is closed before the connection is established.')) {
|
|
144
202
|
return;
|
|
145
203
|
}
|
|
146
|
-
log.warn('
|
|
204
|
+
log.warn('error closing websocket', { err });
|
|
147
205
|
}
|
|
148
206
|
}
|
|
149
207
|
|
|
@@ -154,10 +212,12 @@ export class EdgeWsConnection extends Resource {
|
|
|
154
212
|
async () => {
|
|
155
213
|
// TODO(mykola): use RFC6455 ping/pong once implemented in the browser?
|
|
156
214
|
// Cloudflare's worker responds to this `without interrupting hibernation`. https://developers.cloudflare.com/durable-objects/api/websockets/#setwebsocketautoresponse
|
|
215
|
+
this._pingTimestamp = Date.now();
|
|
157
216
|
this._ws?.send('__ping__');
|
|
158
217
|
},
|
|
159
218
|
SIGNAL_KEEPALIVE_INTERVAL,
|
|
160
219
|
);
|
|
220
|
+
this._pingTimestamp = Date.now();
|
|
161
221
|
this._ws.send('__ping__');
|
|
162
222
|
this._rescheduleHeartbeatTimeout();
|
|
163
223
|
}
|
|
@@ -172,11 +232,73 @@ export class EdgeWsConnection extends Resource {
|
|
|
172
232
|
this._inactivityTimeoutCtx,
|
|
173
233
|
() => {
|
|
174
234
|
if (this.isOpen) {
|
|
175
|
-
|
|
176
|
-
|
|
235
|
+
if (Date.now() - this._lastReceivedMessageTimestamp > SIGNAL_KEEPALIVE_TIMEOUT) {
|
|
236
|
+
log.warn('restart due to inactivity timeout', {
|
|
237
|
+
lastReceivedMessageTimestamp: this._lastReceivedMessageTimestamp,
|
|
238
|
+
});
|
|
239
|
+
this._callbacks.onRestartRequired();
|
|
240
|
+
} else {
|
|
241
|
+
this._rescheduleHeartbeatTimeout();
|
|
242
|
+
}
|
|
177
243
|
}
|
|
178
244
|
},
|
|
179
245
|
SIGNAL_KEEPALIVE_TIMEOUT,
|
|
180
246
|
);
|
|
181
247
|
}
|
|
248
|
+
|
|
249
|
+
private _recordBytes(sent: number, received: number): void {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
|
|
252
|
+
// Find if we have a sample for the current second.
|
|
253
|
+
const currentSecond = Math.floor(now / 1000) * 1000;
|
|
254
|
+
const existingSample = this._bytesSamples.find((s) => Math.floor(s.timestamp / 1000) * 1000 === currentSecond);
|
|
255
|
+
|
|
256
|
+
if (existingSample) {
|
|
257
|
+
existingSample.sent += sent;
|
|
258
|
+
existingSample.received += received;
|
|
259
|
+
} else {
|
|
260
|
+
this._bytesSamples.push({ timestamp: now, sent, received });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private _scheduleRateCalculation(): void {
|
|
265
|
+
scheduleTaskInterval(
|
|
266
|
+
this._ctx,
|
|
267
|
+
async () => {
|
|
268
|
+
this._calculateRates();
|
|
269
|
+
},
|
|
270
|
+
this._rateUpdateInterval,
|
|
271
|
+
);
|
|
272
|
+
// Calculate initial rates.
|
|
273
|
+
this._calculateRates();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private _calculateRates(): void {
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
const cutoff = now - this._rateWindow;
|
|
279
|
+
|
|
280
|
+
// Remove old samples.
|
|
281
|
+
this._bytesSamples = this._bytesSamples.filter((s) => s.timestamp > cutoff);
|
|
282
|
+
|
|
283
|
+
if (this._bytesSamples.length === 0) {
|
|
284
|
+
this._uploadRate = 0;
|
|
285
|
+
this._downloadRate = 0;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Calculate total bytes and time span.
|
|
290
|
+
let totalSent = 0;
|
|
291
|
+
let totalReceived = 0;
|
|
292
|
+
const oldestTimestamp = Math.min(...this._bytesSamples.map((s) => s.timestamp));
|
|
293
|
+
const timeSpan = (now - oldestTimestamp) / 1000; // Convert to seconds.
|
|
294
|
+
|
|
295
|
+
for (const sample of this._bytesSamples) {
|
|
296
|
+
totalSent += sample.sent;
|
|
297
|
+
totalReceived += sample.received;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Calculate rates (bytes per second).
|
|
301
|
+
this._uploadRate = timeSpan > 0 ? Math.round(totalSent / timeSpan) : 0;
|
|
302
|
+
this._downloadRate = timeSpan > 0 ? Math.round(totalReceived / timeSpan) : 0;
|
|
303
|
+
}
|
|
182
304
|
}
|
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
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
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';
|
|
9
|
+
import { afterEach, beforeEach, describe, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { runAndForwardErrors } from '@dxos/effect';
|
|
12
|
+
import { invariant } from '@dxos/invariant';
|
|
13
|
+
|
|
14
|
+
import { HttpConfig, withLogging, withRetry, withRetryConfig } from './http-client';
|
|
15
|
+
import { type TestServer, createTestServer, responseHandler } from './testing';
|
|
16
|
+
|
|
17
|
+
describe('HttpClient', () => {
|
|
18
|
+
let server: TestServer | undefined;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
server = await createTestServer(responseHandler((attempt) => (attempt > 2 ? { value: 100 } : false)));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
server?.close();
|
|
26
|
+
server = undefined;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// TODO(burdon): Auth headers/API key for admin.
|
|
30
|
+
// TODO(burdon): Add request/response schema type checking.
|
|
31
|
+
// TODO(burdon): Test swarm.
|
|
32
|
+
it.skipIf(process.env.CI)('should retry', async ({ expect }) => {
|
|
33
|
+
invariant(server);
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
const result = await Function.pipe(
|
|
37
|
+
withRetry(HttpClient.get(server.url)),
|
|
38
|
+
Effect.provide(FetchHttpClient.layer),
|
|
39
|
+
Effect.withSpan('EdgeHttpClient'),
|
|
40
|
+
runAndForwardErrors,
|
|
41
|
+
);
|
|
42
|
+
expect(result).toMatchObject({ success: true, data: { value: 100 } });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
const result = await Function.pipe(
|
|
47
|
+
HttpClient.get(server.url),
|
|
48
|
+
withLogging,
|
|
49
|
+
withRetryConfig,
|
|
50
|
+
Effect.provide(FetchHttpClient.layer),
|
|
51
|
+
Effect.provide(HttpConfig.default), // TODO(burdon): Swap out to mock.
|
|
52
|
+
Effect.withSpan('EdgeHttpClient'), // TODO(burdon): OTEL.
|
|
53
|
+
runAndForwardErrors,
|
|
54
|
+
);
|
|
55
|
+
expect(result).toMatchObject({ success: true, data: { value: 100 } });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
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';
|
|
13
|
+
|
|
14
|
+
import { log } from '@dxos/log';
|
|
15
|
+
|
|
16
|
+
// TODO(burdon): Factor out.
|
|
17
|
+
|
|
18
|
+
export type RetryOptions = {
|
|
19
|
+
timeout: Duration.Duration;
|
|
20
|
+
retryTimes: number;
|
|
21
|
+
retryBaseDelay: Duration.Duration;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Layer pattern.
|
|
25
|
+
export class HttpConfig extends Context.Tag('HttpConfig')<HttpConfig, RetryOptions>() {
|
|
26
|
+
static default = Layer.succeed(HttpConfig, {
|
|
27
|
+
timeout: Duration.millis(1_000),
|
|
28
|
+
retryTimes: 3,
|
|
29
|
+
retryBaseDelay: Duration.millis(1_000),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// HOC pattern.
|
|
34
|
+
export const withRetry = (
|
|
35
|
+
effect: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError, HttpClient.HttpClient>,
|
|
36
|
+
{
|
|
37
|
+
timeout = Duration.millis(1_000),
|
|
38
|
+
retryBaseDelay = Duration.millis(1_000),
|
|
39
|
+
retryTimes = 3,
|
|
40
|
+
}: Partial<RetryOptions> = {},
|
|
41
|
+
) => {
|
|
42
|
+
return effect.pipe(
|
|
43
|
+
Effect.flatMap((res) =>
|
|
44
|
+
// Treat 500 errors as retryable?
|
|
45
|
+
res.status === 500 ? Effect.fail(new Error(res.status.toString())) : res.json,
|
|
46
|
+
),
|
|
47
|
+
Effect.timeout(timeout),
|
|
48
|
+
Effect.retry({
|
|
49
|
+
schedule: Schedule.exponential(retryBaseDelay).pipe(Schedule.jittered),
|
|
50
|
+
times: retryTimes,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const withRetryConfig = (
|
|
56
|
+
effect: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError, HttpClient.HttpClient>,
|
|
57
|
+
) =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const config = yield* HttpConfig;
|
|
60
|
+
return yield* withRetry(effect, config);
|
|
61
|
+
});
|
|
62
|
+
|
|
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
|
+
);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
*
|
|
72
|
+
*/
|
|
73
|
+
// TODO(burdon): Document.
|
|
74
|
+
export const encodeAuthHeader = (challenge: Uint8Array) => {
|
|
75
|
+
const encodedChallenge = Buffer.from(challenge).toString('base64');
|
|
76
|
+
return `VerifiablePresentation pb;base64,${encodedChallenge}`;
|
|
77
|
+
};
|
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 './
|
|
7
|
+
export * from './auth';
|
|
8
8
|
export * from './defs';
|
|
9
|
-
export * from './
|
|
9
|
+
export * from './edge-client';
|
|
10
10
|
export * from './errors';
|
|
11
|
-
export * from './
|
|
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';
|
package/src/testing/index.ts
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
|
|
9
|
+
export type TestServer = {
|
|
10
|
+
url: string;
|
|
11
|
+
close: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ResponseHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
15
|
+
|
|
16
|
+
export const createTestServer = (responseHandler: ResponseHandler) => {
|
|
17
|
+
const server = http.createServer(responseHandler);
|
|
18
|
+
|
|
19
|
+
return new Promise<TestServer>((resolve) => {
|
|
20
|
+
server.listen(0, () => {
|
|
21
|
+
const address = server.address();
|
|
22
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
23
|
+
resolve({
|
|
24
|
+
url: `http://localhost:${port}`,
|
|
25
|
+
close: () => server.close(),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const responseHandler = (cb: (attempt: number) => false | object): ResponseHandler => {
|
|
32
|
+
let attempt = 0;
|
|
33
|
+
return (req, res) => {
|
|
34
|
+
const data = cb(++attempt) ?? {};
|
|
35
|
+
if (data === false) {
|
|
36
|
+
log('simulating failure', { attempt });
|
|
37
|
+
res.statusCode = 500;
|
|
38
|
+
res.statusMessage = 'Simulating failure';
|
|
39
|
+
res.end('');
|
|
40
|
+
} else {
|
|
41
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
42
|
+
res.end(JSON.stringify({ success: true, data }));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -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
|
|
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';
|
|
@@ -16,17 +16,17 @@ import { toUint8Array } from '../protocol';
|
|
|
16
16
|
|
|
17
17
|
export const DEFAULT_PORT = 8080;
|
|
18
18
|
|
|
19
|
-
type
|
|
19
|
+
type TestEdgeWsServerProps = {
|
|
20
20
|
admitConnection?: Trigger;
|
|
21
21
|
payloadDecoder?: (payload: Uint8Array) => any;
|
|
22
22
|
messageHandler?: (payload: any) => Promise<Uint8Array | undefined>;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
export const createTestEdgeWsServer = async (port = DEFAULT_PORT, params?:
|
|
25
|
+
export const createTestEdgeWsServer = async (port = DEFAULT_PORT, params?: TestEdgeWsServerProps) => {
|
|
26
26
|
const wsServer = new WebSocket.Server({
|
|
27
27
|
port,
|
|
28
28
|
verifyClient: createConnectionDelayHandler(params),
|
|
29
|
-
handleProtocols: () =>
|
|
29
|
+
handleProtocols: () => EdgeWebsocketProtocol.V1,
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
let connection: { ws: WebSocket; muxer: WebSocketMuxer } | undefined;
|
|
@@ -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;
|
|
@@ -86,7 +86,7 @@ export const createTestEdgeWsServer = async (port = DEFAULT_PORT, params?: TestE
|
|
|
86
86
|
};
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
-
const createConnectionDelayHandler = (params:
|
|
89
|
+
const createConnectionDelayHandler = (params: TestEdgeWsServerProps | undefined) => {
|
|
90
90
|
return (_: any, callback: (admit: boolean) => void) => {
|
|
91
91
|
if (params?.admitConnection) {
|
|
92
92
|
log('delaying edge connection admission');
|
|
@@ -116,7 +116,7 @@ const createResponseSender = (connection: () => WebSocketMuxer) => {
|
|
|
116
116
|
};
|
|
117
117
|
};
|
|
118
118
|
|
|
119
|
-
const decodePayload = async (request: Message, params:
|
|
119
|
+
const decodePayload = async (request: Message, params: TestEdgeWsServerProps | undefined) => {
|
|
120
120
|
const requestPayload = params?.payloadDecoder
|
|
121
121
|
? params.payloadDecoder(request.payload!.value!)
|
|
122
122
|
: protocol.getPayload(request, TextMessageSchema);
|
package/src/websocket.test.ts
CHANGED