@dxos/edge-client 0.8.4-main.c4373fc → 0.8.4-main.c85a9c8dae
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 → neutral/chunk-VESGVCLQ.mjs} +15 -44
- package/dist/lib/{browser/chunk-IKP53CBQ.mjs.map → neutral/chunk-VESGVCLQ.mjs.map} +2 -2
- package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs +1 -1
- package/dist/lib/{browser → neutral}/index.mjs +292 -161
- 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 +1 -1
- package/dist/lib/{browser → neutral}/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 +18 -7
- 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.map +1 -1
- package/dist/types/src/testing/test-utils.d.ts +2 -2
- package/dist/types/src/testing/test-utils.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +26 -24
- 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 +79 -36
- package/src/edge-ws-connection.ts +117 -4
- package/src/http-client.test.ts +3 -2
- package/src/http-client.ts +5 -1
- package/src/testing/test-utils.ts +7 -7
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/chunk-DR5YNW5K.mjs +0 -332
- package/dist/lib/node-esm/chunk-DR5YNW5K.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 -1257
- 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 -186
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- /package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs.map +0 -0
package/package.json
CHANGED
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/edge-client",
|
|
3
|
-
"version": "0.8.4-main.
|
|
3
|
+
"version": "0.8.4-main.c85a9c8dae",
|
|
4
4
|
"description": "EDGE Client",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dxos/dxos"
|
|
10
|
+
},
|
|
7
11
|
"license": "MIT",
|
|
8
12
|
"author": "DXOS.org",
|
|
9
|
-
"sideEffects":
|
|
13
|
+
"sideEffects": false,
|
|
10
14
|
"type": "module",
|
|
11
15
|
"exports": {
|
|
12
16
|
".": {
|
|
13
17
|
"source": "./src/index.ts",
|
|
14
18
|
"types": "./dist/types/src/index.d.ts",
|
|
15
|
-
"
|
|
16
|
-
"node": "./dist/lib/node-esm/index.mjs"
|
|
19
|
+
"default": "./dist/lib/neutral/index.mjs"
|
|
17
20
|
},
|
|
18
21
|
"./muxer": {
|
|
19
22
|
"source": "./src/edge-ws-muxer.ts",
|
|
20
23
|
"types": "./dist/types/src/edge-ws-muxer.d.ts",
|
|
21
|
-
"
|
|
22
|
-
"node": "./dist/lib/node-esm/edge-ws-muxer.mjs"
|
|
24
|
+
"default": "./dist/lib/neutral/edge-ws-muxer.mjs"
|
|
23
25
|
},
|
|
24
26
|
"./testing": {
|
|
25
27
|
"source": "./src/testing/index.ts",
|
|
26
28
|
"types": "./dist/types/src/testing/index.d.ts",
|
|
27
|
-
"
|
|
28
|
-
"node": "./dist/lib/node-esm/testing/index.mjs"
|
|
29
|
+
"default": "./dist/lib/neutral/testing/index.mjs"
|
|
29
30
|
}
|
|
30
31
|
},
|
|
31
32
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -42,27 +43,28 @@
|
|
|
42
43
|
"README.md"
|
|
43
44
|
],
|
|
44
45
|
"dependencies": {
|
|
45
|
-
"@effect/platform": "
|
|
46
|
+
"@effect/platform": "0.94.4",
|
|
46
47
|
"isomorphic-ws": "^5.0.0",
|
|
47
|
-
"ws": "^8.
|
|
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/log": "0.8.4-main.
|
|
57
|
-
"@dxos/
|
|
58
|
-
"@dxos/
|
|
59
|
-
"@dxos/protocols": "0.8.4-main.
|
|
48
|
+
"ws": "^8.17.1",
|
|
49
|
+
"@dxos/async": "0.8.4-main.c85a9c8dae",
|
|
50
|
+
"@dxos/context": "0.8.4-main.c85a9c8dae",
|
|
51
|
+
"@dxos/credentials": "0.8.4-main.c85a9c8dae",
|
|
52
|
+
"@dxos/debug": "0.8.4-main.c85a9c8dae",
|
|
53
|
+
"@dxos/crypto": "0.8.4-main.c85a9c8dae",
|
|
54
|
+
"@dxos/effect": "0.8.4-main.c85a9c8dae",
|
|
55
|
+
"@dxos/invariant": "0.8.4-main.c85a9c8dae",
|
|
56
|
+
"@dxos/keyring": "0.8.4-main.c85a9c8dae",
|
|
57
|
+
"@dxos/log": "0.8.4-main.c85a9c8dae",
|
|
58
|
+
"@dxos/keys": "0.8.4-main.c85a9c8dae",
|
|
59
|
+
"@dxos/node-std": "0.8.4-main.c85a9c8dae",
|
|
60
|
+
"@dxos/protocols": "0.8.4-main.c85a9c8dae",
|
|
61
|
+
"@dxos/util": "0.8.4-main.c85a9c8dae"
|
|
60
62
|
},
|
|
61
63
|
"devDependencies": {
|
|
62
|
-
"@dxos/test-utils": "0.8.4-main.
|
|
64
|
+
"@dxos/test-utils": "0.8.4-main.c85a9c8dae"
|
|
63
65
|
},
|
|
64
66
|
"peerDependencies": {
|
|
65
|
-
"effect": "
|
|
67
|
+
"effect": "3.19.16"
|
|
66
68
|
},
|
|
67
69
|
"publishConfig": {
|
|
68
70
|
"access": "public"
|
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
|
@@ -9,6 +9,8 @@ import * as Function from 'effect/Function';
|
|
|
9
9
|
|
|
10
10
|
import { sleep } from '@dxos/async';
|
|
11
11
|
import { Context } from '@dxos/context';
|
|
12
|
+
import { runAndForwardErrors } from '@dxos/effect';
|
|
13
|
+
import { invariant } from '@dxos/invariant';
|
|
12
14
|
import { type PublicKey, type SpaceId } from '@dxos/keys';
|
|
13
15
|
import { log } from '@dxos/log';
|
|
14
16
|
import {
|
|
@@ -18,11 +20,12 @@ import {
|
|
|
18
20
|
type CreateSpaceResponseBody,
|
|
19
21
|
EdgeAuthChallengeError,
|
|
20
22
|
EdgeCallFailedError,
|
|
21
|
-
type
|
|
23
|
+
type EdgeFailure,
|
|
22
24
|
type EdgeStatus,
|
|
23
25
|
type ExecuteWorkflowResponseBody,
|
|
24
26
|
type ExportBundleRequest,
|
|
25
27
|
type ExportBundleResponse,
|
|
28
|
+
type FeedProtocol,
|
|
26
29
|
type GetAgentStatusResponseBody,
|
|
27
30
|
type GetNotarizationResponseBody,
|
|
28
31
|
type ImportBundleRequest,
|
|
@@ -32,13 +35,15 @@ import {
|
|
|
32
35
|
type JoinSpaceResponseBody,
|
|
33
36
|
type ObjectId,
|
|
34
37
|
type PostNotarizationRequestBody,
|
|
35
|
-
type QueryResult,
|
|
36
|
-
type QueueQuery,
|
|
37
38
|
type RecoverIdentityRequest,
|
|
38
39
|
type RecoverIdentityResponseBody,
|
|
39
40
|
type UploadFunctionRequest,
|
|
40
41
|
type UploadFunctionResponseBody,
|
|
41
42
|
} from '@dxos/protocols';
|
|
43
|
+
import {
|
|
44
|
+
type QueryRequest as QueryRequestProto,
|
|
45
|
+
type QueryResponse as QueryResponseProto,
|
|
46
|
+
} from '@dxos/protocols/proto/dxos/echo/query';
|
|
42
47
|
import { createUrl } from '@dxos/util';
|
|
43
48
|
|
|
44
49
|
import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
|
|
@@ -76,13 +81,19 @@ type EdgeHttpRequestArgs = {
|
|
|
76
81
|
json?: boolean;
|
|
77
82
|
|
|
78
83
|
/**
|
|
79
|
-
*
|
|
84
|
+
* Force authentication.
|
|
85
|
+
* This should be used for requests with large bodies to avoid sending the body twice.
|
|
86
|
+
* The client will call /auth endpoint to generate the auth header.
|
|
80
87
|
*/
|
|
81
|
-
|
|
88
|
+
auth?: boolean;
|
|
82
89
|
};
|
|
83
90
|
|
|
84
|
-
export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry'>;
|
|
85
|
-
export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body'>;
|
|
91
|
+
export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'auth'>;
|
|
92
|
+
export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body' | 'auth'>;
|
|
93
|
+
|
|
94
|
+
export type GetCronTriggersResponse = {
|
|
95
|
+
cronIds: string[];
|
|
96
|
+
};
|
|
86
97
|
|
|
87
98
|
export class EdgeHttpClient {
|
|
88
99
|
private readonly _baseUrl: string;
|
|
@@ -115,7 +126,7 @@ export class EdgeHttpClient {
|
|
|
115
126
|
//
|
|
116
127
|
|
|
117
128
|
public async getStatus(args?: EdgeHttpGetArgs): Promise<EdgeStatus> {
|
|
118
|
-
return this._call(new URL('/status', this.baseUrl), { ...args, method: 'GET' });
|
|
129
|
+
return this._call(new URL('/status', this.baseUrl), { ...args, method: 'GET', auth: true });
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
//
|
|
@@ -201,10 +212,11 @@ export class EdgeHttpClient {
|
|
|
201
212
|
public async queryQueue(
|
|
202
213
|
subspaceTag: string,
|
|
203
214
|
spaceId: SpaceId,
|
|
204
|
-
query: QueueQuery,
|
|
215
|
+
query: FeedProtocol.QueueQuery,
|
|
205
216
|
args?: EdgeHttpGetArgs,
|
|
206
|
-
): Promise<QueryResult> {
|
|
207
|
-
const
|
|
217
|
+
): Promise<FeedProtocol.QueryResult> {
|
|
218
|
+
const queueId = query.queueIds?.[0];
|
|
219
|
+
invariant(queueId, 'queueId required');
|
|
208
220
|
return this._call(
|
|
209
221
|
createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}/query`, this.baseUrl), {
|
|
210
222
|
after: query.after,
|
|
@@ -266,6 +278,7 @@ export class EdgeHttpClient {
|
|
|
266
278
|
formData.append('version', body.version);
|
|
267
279
|
formData.append('ownerPublicKey', body.ownerPublicKey);
|
|
268
280
|
formData.append('entryPoint', body.entryPoint);
|
|
281
|
+
body.runtime && formData.append('runtime', body.runtime);
|
|
269
282
|
for (const [filename, content] of Object.entries(body.assets)) {
|
|
270
283
|
formData.append(
|
|
271
284
|
'assets',
|
|
@@ -316,7 +329,6 @@ export class EdgeHttpClient {
|
|
|
316
329
|
...args,
|
|
317
330
|
body: input,
|
|
318
331
|
method: 'POST',
|
|
319
|
-
rawResponse: true,
|
|
320
332
|
});
|
|
321
333
|
}
|
|
322
334
|
|
|
@@ -341,8 +353,31 @@ export class EdgeHttpClient {
|
|
|
341
353
|
// Triggers
|
|
342
354
|
//
|
|
343
355
|
|
|
344
|
-
public async getCronTriggers(spaceId: SpaceId) {
|
|
345
|
-
return this._call(new URL(`/test/functions/${spaceId}/triggers/crons`, this.baseUrl), {
|
|
356
|
+
public async getCronTriggers(spaceId: SpaceId): Promise<GetCronTriggersResponse> {
|
|
357
|
+
return this._call<GetCronTriggersResponse>(new URL(`/test/functions/${spaceId}/triggers/crons`, this.baseUrl), {
|
|
358
|
+
method: 'GET',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
public async forceRunCronTrigger(spaceId: SpaceId, triggerId: ObjectId) {
|
|
363
|
+
return this._call(new URL(`/test/functions/${spaceId}/triggers/crons/${triggerId}/run`, this.baseUrl), {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
//
|
|
369
|
+
// Query
|
|
370
|
+
//
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Execute a QueryAST query against a space.
|
|
374
|
+
*/
|
|
375
|
+
public async execQuery(
|
|
376
|
+
spaceId: SpaceId,
|
|
377
|
+
body: QueryRequestProto,
|
|
378
|
+
args?: EdgeHttpGetArgs,
|
|
379
|
+
): Promise<QueryResponseProto> {
|
|
380
|
+
return this._call(new URL(`/spaces/${spaceId}/exec-query`, this.baseUrl), { ...args, body, method: 'POST' });
|
|
346
381
|
}
|
|
347
382
|
|
|
348
383
|
//
|
|
@@ -373,7 +408,7 @@ export class EdgeHttpClient {
|
|
|
373
408
|
// Internal
|
|
374
409
|
//
|
|
375
410
|
|
|
376
|
-
private async _fetch<T>(url: URL,
|
|
411
|
+
private async _fetch<T>(url: URL, _args: EdgeHttpRequestArgs): Promise<T> {
|
|
377
412
|
return Function.pipe(
|
|
378
413
|
HttpClient.get(url),
|
|
379
414
|
withLogging,
|
|
@@ -381,58 +416,66 @@ export class EdgeHttpClient {
|
|
|
381
416
|
Effect.provide(FetchHttpClient.layer),
|
|
382
417
|
Effect.provide(HttpConfig.default),
|
|
383
418
|
Effect.withSpan('EdgeHttpClient'),
|
|
384
|
-
|
|
419
|
+
runAndForwardErrors,
|
|
385
420
|
) as T;
|
|
386
421
|
}
|
|
387
422
|
|
|
388
423
|
// TODO(burdon): Refactor with effect (see edge-http-client.test.ts).
|
|
389
424
|
private async _call<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
|
|
390
425
|
const shouldRetry = createRetryHandler(args);
|
|
391
|
-
const requestContext = args.context ??
|
|
426
|
+
const requestContext = args.context ?? Context.default();
|
|
392
427
|
log('fetch', { url, request: args.body });
|
|
393
428
|
|
|
394
429
|
let handledAuth = false;
|
|
430
|
+
const tryCount = 1;
|
|
395
431
|
while (true) {
|
|
396
432
|
let processingError: EdgeCallFailedError | undefined = undefined;
|
|
397
|
-
let retryAfterHeaderValue: number = Number.NaN;
|
|
398
433
|
try {
|
|
434
|
+
if (!this._authHeader && args.auth) {
|
|
435
|
+
const response = await fetch(new URL(`/auth`, this.baseUrl));
|
|
436
|
+
if (response.status === 401) {
|
|
437
|
+
this._authHeader = await this._handleUnauthorized(response);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
399
441
|
const request = createRequest(args, this._authHeader);
|
|
442
|
+
log('call edge', { url, tryCount, authHeader: !!this._authHeader });
|
|
400
443
|
const response = await fetch(url, request);
|
|
401
|
-
retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
|
|
402
|
-
if (response.ok) {
|
|
403
|
-
const body = (await response.json()) as EdgeHttpResponse<T>;
|
|
404
|
-
|
|
405
|
-
if (args.rawResponse) {
|
|
406
|
-
return body as any;
|
|
407
|
-
}
|
|
408
444
|
|
|
445
|
+
if (response.ok) {
|
|
446
|
+
const body = await response.clone().json();
|
|
447
|
+
invariant(body, 'Expected body to be present');
|
|
409
448
|
if (!('success' in body)) {
|
|
410
449
|
return body;
|
|
411
450
|
}
|
|
412
|
-
|
|
413
451
|
if (body.success) {
|
|
414
452
|
return body.data;
|
|
415
453
|
}
|
|
416
|
-
|
|
417
|
-
log.warn('unsuccessful edge response', { url, body });
|
|
418
|
-
if (body.errorData?.type === 'auth_challenge' && typeof body.errorData?.challenge === 'string') {
|
|
419
|
-
processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
|
|
420
|
-
} else if (body.errorData) {
|
|
421
|
-
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
422
|
-
}
|
|
423
454
|
} else if (response.status === 401 && !handledAuth) {
|
|
424
455
|
this._authHeader = await this._handleUnauthorized(response);
|
|
425
456
|
handledAuth = true;
|
|
426
457
|
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const body: EdgeFailure =
|
|
461
|
+
response.headers.get('Content-Type') === 'application/json' ? await response.clone().json() : undefined;
|
|
462
|
+
|
|
463
|
+
invariant(!body?.success, 'Expected body to not be a failure response or undefined.');
|
|
464
|
+
|
|
465
|
+
if (body?.data?.type === 'auth_challenge' && typeof body?.data?.challenge === 'string') {
|
|
466
|
+
processingError = new EdgeAuthChallengeError(body.data.challenge, body.data);
|
|
467
|
+
} else if (body?.success === false) {
|
|
468
|
+
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
427
469
|
} else {
|
|
470
|
+
invariant(!response.ok, 'Expected response to not be ok.');
|
|
428
471
|
processingError = await EdgeCallFailedError.fromHttpFailure(response);
|
|
429
472
|
}
|
|
430
473
|
} catch (error: any) {
|
|
431
474
|
processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
|
|
432
475
|
}
|
|
433
476
|
|
|
434
|
-
if (processingError?.isRetryable && (await shouldRetry(requestContext,
|
|
435
|
-
log('retrying edge request', { url, processingError });
|
|
477
|
+
if (processingError?.isRetryable && (await shouldRetry(requestContext, processingError.retryAfterMs))) {
|
|
478
|
+
log.verbose('retrying edge request', { url, processingError });
|
|
436
479
|
} else {
|
|
437
480
|
throw processingError!;
|
|
438
481
|
}
|
|
@@ -491,7 +534,7 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
|
|
|
491
534
|
const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
|
|
492
535
|
const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
|
|
493
536
|
const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
|
|
494
|
-
return async (ctx: Context, retryAfter
|
|
537
|
+
return async (ctx: Context, retryAfter?: number) => {
|
|
495
538
|
if (++retries > maxRetries || ctx.disposed) {
|
|
496
539
|
return false;
|
|
497
540
|
}
|
|
@@ -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);
|
|
@@ -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
|
@@ -8,6 +8,7 @@ import * as Effect from 'effect/Effect';
|
|
|
8
8
|
import * as Function from 'effect/Function';
|
|
9
9
|
import { afterEach, beforeEach, describe, it } from 'vitest';
|
|
10
10
|
|
|
11
|
+
import { runAndForwardErrors } from '@dxos/effect';
|
|
11
12
|
import { invariant } from '@dxos/invariant';
|
|
12
13
|
|
|
13
14
|
import { HttpConfig, withLogging, withRetry, withRetryConfig } from './http-client';
|
|
@@ -36,7 +37,7 @@ describe('HttpClient', () => {
|
|
|
36
37
|
withRetry(HttpClient.get(server.url)),
|
|
37
38
|
Effect.provide(FetchHttpClient.layer),
|
|
38
39
|
Effect.withSpan('EdgeHttpClient'),
|
|
39
|
-
|
|
40
|
+
runAndForwardErrors,
|
|
40
41
|
);
|
|
41
42
|
expect(result).toMatchObject({ success: true, data: { value: 100 } });
|
|
42
43
|
}
|
|
@@ -49,7 +50,7 @@ describe('HttpClient', () => {
|
|
|
49
50
|
Effect.provide(FetchHttpClient.layer),
|
|
50
51
|
Effect.provide(HttpConfig.default), // TODO(burdon): Swap out to mock.
|
|
51
52
|
Effect.withSpan('EdgeHttpClient'), // TODO(burdon): OTEL.
|
|
52
|
-
|
|
53
|
+
runAndForwardErrors,
|
|
53
54
|
);
|
|
54
55
|
expect(result).toMatchObject({ success: true, data: { value: 100 } });
|
|
55
56
|
}
|
package/src/http-client.ts
CHANGED
|
@@ -61,7 +61,11 @@ export const withRetryConfig = (
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
export const withLogging = <A extends HttpClientResponse.HttpClientResponse, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
64
|
-
effect.pipe(
|
|
64
|
+
effect.pipe(
|
|
65
|
+
Effect.tap((res) => {
|
|
66
|
+
log.info('response', { status: res.status });
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
65
69
|
|
|
66
70
|
/**
|
|
67
71
|
*
|