@dxos/edge-client 0.6.11 → 0.6.12-main.568932b
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-ZWJXA37R.mjs +113 -0
- package/dist/lib/browser/chunk-ZWJXA37R.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +347 -178
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +122 -0
- package/dist/lib/browser/testing/index.mjs.map +7 -0
- package/dist/lib/node/chunk-ANV2HBEH.cjs +136 -0
- package/dist/lib/node/chunk-ANV2HBEH.cjs.map +7 -0
- package/dist/lib/node/index.cjs +346 -175
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +152 -0
- package/dist/lib/node/testing/index.cjs.map +7 -0
- package/dist/lib/node-esm/chunk-HNVT57AU.mjs +115 -0
- package/dist/lib/node-esm/chunk-HNVT57AU.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +647 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/lib/node-esm/testing/index.mjs +123 -0
- package/dist/lib/node-esm/testing/index.mjs.map +7 -0
- package/dist/types/src/auth.d.ts +22 -0
- package/dist/types/src/auth.d.ts.map +1 -0
- package/dist/types/src/defs.d.ts.map +1 -1
- package/dist/types/src/edge-client.d.ts +23 -13
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +34 -0
- package/dist/types/src/edge-http-client.d.ts.map +1 -0
- package/dist/types/src/errors.d.ts +4 -1
- package/dist/types/src/errors.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/protocol.d.ts +2 -2
- package/dist/types/src/protocol.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/test-utils.d.ts +20 -0
- package/dist/types/src/testing/test-utils.d.ts.map +1 -0
- package/dist/types/src/utils.d.ts +2 -0
- package/dist/types/src/utils.d.ts.map +1 -0
- package/package.json +29 -14
- package/src/auth.ts +135 -0
- package/src/defs.ts +2 -3
- package/src/edge-client.test.ts +50 -18
- package/src/edge-client.ts +76 -23
- package/src/edge-http-client.ts +153 -0
- package/src/errors.ts +8 -2
- package/src/index.ts +3 -0
- package/src/persistent-lifecycle.test.ts +2 -2
- package/src/protocol.test.ts +1 -2
- package/src/protocol.ts +2 -2
- package/src/testing/index.ts +5 -0
- package/src/testing/test-utils.ts +111 -0
- package/src/utils.ts +10 -0
- package/src/websocket.test.ts +5 -4
- package/dist/types/src/test-utils.d.ts +0 -11
- package/dist/types/src/test-utils.d.ts.map +0 -1
- package/src/test-utils.ts +0 -49
package/src/auth.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createCredential, signPresentation } from '@dxos/credentials';
|
|
6
|
+
import { type Signer } from '@dxos/crypto';
|
|
7
|
+
import { Keyring } from '@dxos/keyring';
|
|
8
|
+
import { PublicKey } from '@dxos/keys';
|
|
9
|
+
import { type Chain, type Credential } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
10
|
+
|
|
11
|
+
import type { EdgeIdentity } from './edge-client';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Edge identity backed by a device key without a credential chain.
|
|
15
|
+
*/
|
|
16
|
+
export const createDeviceEdgeIdentity = async (signer: Signer, key: PublicKey): Promise<EdgeIdentity> => {
|
|
17
|
+
return {
|
|
18
|
+
identityKey: key.toHex(),
|
|
19
|
+
peerKey: key.toHex(),
|
|
20
|
+
presentCredentials: async ({ challenge }) => {
|
|
21
|
+
return signPresentation({
|
|
22
|
+
presentation: {
|
|
23
|
+
credentials: [
|
|
24
|
+
// Verifier requires at least one credential in the presentation to establish the subject.
|
|
25
|
+
await createCredential({
|
|
26
|
+
assertion: {
|
|
27
|
+
'@type': 'dxos.halo.credentials.Auth',
|
|
28
|
+
},
|
|
29
|
+
issuer: key,
|
|
30
|
+
subject: key,
|
|
31
|
+
signer,
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
signer,
|
|
36
|
+
signerKey: key,
|
|
37
|
+
nonce: challenge,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Edge identity backed by a chain of credentials.
|
|
45
|
+
*/
|
|
46
|
+
export const createChainEdgeIdentity = async (
|
|
47
|
+
signer: Signer,
|
|
48
|
+
identityKey: PublicKey,
|
|
49
|
+
peerKey: PublicKey,
|
|
50
|
+
chain: Chain,
|
|
51
|
+
credentials: Credential[],
|
|
52
|
+
): Promise<EdgeIdentity> => {
|
|
53
|
+
const credentialsToSign =
|
|
54
|
+
credentials.length > 0
|
|
55
|
+
? credentials
|
|
56
|
+
: [
|
|
57
|
+
await createCredential({
|
|
58
|
+
assertion: {
|
|
59
|
+
'@type': 'dxos.halo.credentials.Auth',
|
|
60
|
+
},
|
|
61
|
+
issuer: identityKey,
|
|
62
|
+
subject: identityKey,
|
|
63
|
+
signer,
|
|
64
|
+
chain,
|
|
65
|
+
signingKey: peerKey,
|
|
66
|
+
}),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
identityKey: identityKey.toHex(),
|
|
71
|
+
peerKey: peerKey.toHex(),
|
|
72
|
+
presentCredentials: async ({ challenge }) => {
|
|
73
|
+
return signPresentation({
|
|
74
|
+
presentation: {
|
|
75
|
+
credentials: credentialsToSign,
|
|
76
|
+
},
|
|
77
|
+
signer,
|
|
78
|
+
nonce: challenge,
|
|
79
|
+
signerKey: peerKey,
|
|
80
|
+
chain,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Edge identity backed by a random ephemeral key without HALO.
|
|
88
|
+
*/
|
|
89
|
+
export const createEphemeralEdgeIdentity = async (): Promise<EdgeIdentity> => {
|
|
90
|
+
const keyring = new Keyring();
|
|
91
|
+
const key = await keyring.createKey();
|
|
92
|
+
return createDeviceEdgeIdentity(keyring, key);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Creates a HALO chain of credentials to act as an edge identity.
|
|
97
|
+
*/
|
|
98
|
+
export const createTestHaloEdgeIdentity = async (
|
|
99
|
+
signer: Signer,
|
|
100
|
+
identityKey: PublicKey,
|
|
101
|
+
deviceKey: PublicKey,
|
|
102
|
+
): Promise<EdgeIdentity> => {
|
|
103
|
+
const deviceAdmission = await createCredential({
|
|
104
|
+
assertion: {
|
|
105
|
+
'@type': 'dxos.halo.credentials.AuthorizedDevice',
|
|
106
|
+
deviceKey,
|
|
107
|
+
identityKey,
|
|
108
|
+
},
|
|
109
|
+
issuer: identityKey,
|
|
110
|
+
subject: deviceKey,
|
|
111
|
+
signer,
|
|
112
|
+
});
|
|
113
|
+
return createChainEdgeIdentity(signer, identityKey, deviceKey, { credential: deviceAdmission }, [
|
|
114
|
+
await createCredential({
|
|
115
|
+
assertion: {
|
|
116
|
+
'@type': 'dxos.halo.credentials.Auth',
|
|
117
|
+
},
|
|
118
|
+
issuer: identityKey,
|
|
119
|
+
subject: identityKey,
|
|
120
|
+
signer,
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const createStubEdgeIdentity = (): EdgeIdentity => {
|
|
126
|
+
const identityKey = PublicKey.random();
|
|
127
|
+
const deviceKey = PublicKey.random();
|
|
128
|
+
return {
|
|
129
|
+
identityKey: identityKey.toHex(),
|
|
130
|
+
peerKey: deviceKey.toHex(),
|
|
131
|
+
presentCredentials: async () => {
|
|
132
|
+
throw new Error('Stub identity does not support authentication.');
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
};
|
package/src/defs.ts
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { bufWkt } from '@dxos/protocols/buf';
|
|
7
6
|
import { SwarmRequestSchema, SwarmResponseSchema, TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
8
7
|
|
|
9
8
|
import { Protocol } from './protocol';
|
|
10
9
|
|
|
11
|
-
export const protocol = new Protocol([SwarmRequestSchema, SwarmResponseSchema, TextMessageSchema, AnySchema]);
|
|
10
|
+
export const protocol = new Protocol([SwarmRequestSchema, SwarmResponseSchema, TextMessageSchema, bufWkt.AnySchema]);
|
package/src/edge-client.test.ts
CHANGED
|
@@ -2,49 +2,81 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import chaiAsPromised from 'chai-as-promised';
|
|
5
|
+
import { describe, expect, onTestFinished, test } from 'vitest';
|
|
7
6
|
|
|
8
|
-
import {
|
|
7
|
+
import { Trigger } from '@dxos/async';
|
|
8
|
+
import { Keyring } from '@dxos/keyring';
|
|
9
9
|
import { TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
10
|
-
import {
|
|
10
|
+
import { openAndClose } from '@dxos/test-utils';
|
|
11
11
|
|
|
12
|
+
import { createEphemeralEdgeIdentity, createTestHaloEdgeIdentity } from './auth';
|
|
12
13
|
import { protocol } from './defs';
|
|
13
14
|
import { EdgeClient } from './edge-client';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
chai.use(chaiAsPromised);
|
|
15
|
+
import { createTestEdgeWsServer } from './testing';
|
|
17
16
|
|
|
18
17
|
describe('EdgeClient', () => {
|
|
19
18
|
const textMessage = (message: string) => protocol.createMessage(TextMessageSchema, { payload: { message } });
|
|
20
19
|
|
|
21
20
|
test('reconnects on error', async () => {
|
|
22
|
-
const {
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const { closeConnection, endpoint, cleanup } = await createTestEdgeWsServer(8001);
|
|
22
|
+
onTestFinished(cleanup);
|
|
23
|
+
|
|
24
|
+
const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: endpoint });
|
|
25
25
|
await openAndClose(client);
|
|
26
26
|
await client.send(textMessage('Hello world 1'));
|
|
27
27
|
expect(client.isOpen).is.true;
|
|
28
28
|
|
|
29
29
|
const reconnected = client.reconnect.waitForCount(1);
|
|
30
|
-
await
|
|
30
|
+
await closeConnection();
|
|
31
31
|
await reconnected;
|
|
32
|
-
await expect(client.send(textMessage('Hello world 2'))).
|
|
32
|
+
await expect(client.send(textMessage('Hello world 2'))).resolves.not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('isConnected', async () => {
|
|
36
|
+
const admitConnection = new Trigger();
|
|
37
|
+
const { closeConnection, endpoint, cleanup } = await createTestEdgeWsServer(8001, { admitConnection });
|
|
38
|
+
onTestFinished(cleanup);
|
|
39
|
+
|
|
40
|
+
const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: endpoint });
|
|
41
|
+
await openAndClose(client);
|
|
42
|
+
|
|
43
|
+
expect(client.isConnected).toBeFalsy();
|
|
44
|
+
admitConnection.wake();
|
|
45
|
+
await expect.poll(() => client.isConnected).toBeTruthy();
|
|
46
|
+
|
|
47
|
+
admitConnection.reset();
|
|
48
|
+
await closeConnection();
|
|
49
|
+
expect(client.isOpen).is.true;
|
|
50
|
+
await expect.poll(() => client.isConnected).toBeFalsy();
|
|
51
|
+
|
|
52
|
+
admitConnection.wake();
|
|
53
|
+
await expect.poll(() => client.isConnected).toBeTruthy();
|
|
33
54
|
});
|
|
34
55
|
|
|
35
56
|
test('set identity reconnects', async () => {
|
|
36
|
-
const { endpoint } = await
|
|
57
|
+
const { endpoint, cleanup } = await createTestEdgeWsServer(8002);
|
|
58
|
+
onTestFinished(cleanup);
|
|
37
59
|
|
|
38
|
-
const
|
|
39
|
-
const client = new EdgeClient(id, id, { socketEndpoint: endpoint });
|
|
60
|
+
const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: endpoint });
|
|
40
61
|
await openAndClose(client);
|
|
41
62
|
await client.send(textMessage('Hello world 1'));
|
|
42
63
|
expect(client.isOpen).is.true;
|
|
43
64
|
|
|
44
|
-
const newId = PublicKey.random().toHex();
|
|
45
65
|
const reconnected = client.reconnect.waitForCount(1);
|
|
46
|
-
client.setIdentity(
|
|
66
|
+
client.setIdentity(await createEphemeralEdgeIdentity());
|
|
47
67
|
await reconnected;
|
|
48
|
-
await expect(client.send(textMessage('Hello world 2'))).
|
|
68
|
+
await expect(client.send(textMessage('Hello world 2'))).resolves.not.toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test.skipIf(!process.env.EDGE_ENDPOINT)('connect to local edge server', async () => {
|
|
72
|
+
// const identity = await createEphemeralEdgeIdentity();
|
|
73
|
+
|
|
74
|
+
const keyring = new Keyring();
|
|
75
|
+
const identity = await createTestHaloEdgeIdentity(keyring, await keyring.createKey(), await keyring.createKey());
|
|
76
|
+
|
|
77
|
+
const client = new EdgeClient(identity, { socketEndpoint: process.env.EDGE_ENDPOINT! });
|
|
78
|
+
await openAndClose(client);
|
|
79
|
+
await client.send(textMessage('Hello world 1'));
|
|
80
|
+
expect(client.isOpen).is.true;
|
|
49
81
|
});
|
|
50
82
|
});
|
package/src/edge-client.ts
CHANGED
|
@@ -6,15 +6,18 @@ import WebSocket from 'isomorphic-ws';
|
|
|
6
6
|
|
|
7
7
|
import { Trigger, Event, scheduleTaskInterval, scheduleTask, TriggerState } from '@dxos/async';
|
|
8
8
|
import { Context, LifecycleState, Resource, type Lifecycle } from '@dxos/context';
|
|
9
|
-
import {
|
|
9
|
+
import { randomBytes } from '@dxos/crypto';
|
|
10
10
|
import { log } from '@dxos/log';
|
|
11
11
|
import { buf } from '@dxos/protocols/buf';
|
|
12
12
|
import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
13
|
+
import { schema } from '@dxos/protocols/proto';
|
|
14
|
+
import { type Presentation } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
13
15
|
|
|
14
16
|
import { protocol } from './defs';
|
|
15
|
-
import {
|
|
17
|
+
import { EdgeConnectionClosedError, EdgeIdentityChangedError } from './errors';
|
|
16
18
|
import { PersistentLifecycle } from './persistent-lifecycle';
|
|
17
19
|
import { type Protocol, toUint8Array } from './protocol';
|
|
20
|
+
import { getEdgeUrlWithProtocol } from './utils';
|
|
18
21
|
|
|
19
22
|
const DEFAULT_TIMEOUT = 10_000;
|
|
20
23
|
const SIGNAL_KEEPALIVE_INTERVAL = 5_000;
|
|
@@ -22,13 +25,15 @@ const SIGNAL_KEEPALIVE_INTERVAL = 5_000;
|
|
|
22
25
|
export type MessageListener = (message: Message) => void | Promise<void>;
|
|
23
26
|
|
|
24
27
|
export interface EdgeConnection extends Required<Lifecycle> {
|
|
28
|
+
connected: Event;
|
|
25
29
|
reconnect: Event;
|
|
26
30
|
|
|
27
31
|
get info(): any;
|
|
28
32
|
get identityKey(): string;
|
|
29
33
|
get peerKey(): string;
|
|
30
34
|
get isOpen(): boolean;
|
|
31
|
-
|
|
35
|
+
get isConnected(): boolean;
|
|
36
|
+
setIdentity(identity: EdgeIdentity): void;
|
|
32
37
|
addListener(listener: MessageListener): () => void;
|
|
33
38
|
send(message: Message): Promise<void>;
|
|
34
39
|
}
|
|
@@ -39,11 +44,25 @@ export type MessengerConfig = {
|
|
|
39
44
|
protocol?: Protocol;
|
|
40
45
|
};
|
|
41
46
|
|
|
47
|
+
export interface EdgeIdentity {
|
|
48
|
+
peerKey: string;
|
|
49
|
+
identityKey: string;
|
|
50
|
+
/**
|
|
51
|
+
* Returns credential presentation issued by the identity key.
|
|
52
|
+
* Presentation must have the provided challenge.
|
|
53
|
+
* Presentation may include ServiceAccess credentials.
|
|
54
|
+
*/
|
|
55
|
+
presentCredentials({ challenge }: { challenge: Uint8Array }): Promise<Presentation>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DISABLE_AUTH = true;
|
|
59
|
+
|
|
42
60
|
/**
|
|
43
61
|
* Messenger client.
|
|
44
62
|
*/
|
|
45
63
|
export class EdgeClient extends Resource implements EdgeConnection {
|
|
46
|
-
public reconnect = new Event();
|
|
64
|
+
public readonly reconnect = new Event();
|
|
65
|
+
public readonly connected = new Event();
|
|
47
66
|
private readonly _persistentLifecycle = new PersistentLifecycle({
|
|
48
67
|
start: async () => this._openWebSocket(),
|
|
49
68
|
stop: async () => this._closeWebSocket(),
|
|
@@ -51,41 +70,44 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
51
70
|
});
|
|
52
71
|
|
|
53
72
|
private readonly _listeners = new Set<MessageListener>();
|
|
54
|
-
private readonly _protocol: Protocol;
|
|
55
73
|
private _ready = new Trigger();
|
|
56
74
|
private _ws?: WebSocket = undefined;
|
|
57
75
|
private _keepaliveCtx?: Context = undefined;
|
|
58
76
|
private _heartBeatContext?: Context = undefined;
|
|
59
77
|
|
|
78
|
+
private _baseUrl: string;
|
|
79
|
+
|
|
60
80
|
constructor(
|
|
61
|
-
private
|
|
62
|
-
private _peerKey: string,
|
|
81
|
+
private _identity: EdgeIdentity,
|
|
63
82
|
private readonly _config: MessengerConfig,
|
|
64
83
|
) {
|
|
65
84
|
super();
|
|
66
|
-
this.
|
|
85
|
+
this._baseUrl = getEdgeUrlWithProtocol(_config.socketEndpoint, 'ws');
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
// TODO(burdon): Attach logging.
|
|
70
89
|
public get info() {
|
|
71
90
|
return {
|
|
72
91
|
open: this.isOpen,
|
|
73
|
-
identity: this.
|
|
74
|
-
device: this.
|
|
92
|
+
identity: this._identity.identityKey,
|
|
93
|
+
device: this._identity.peerKey,
|
|
75
94
|
};
|
|
76
95
|
}
|
|
77
96
|
|
|
97
|
+
get isConnected() {
|
|
98
|
+
return Boolean(this._ws) && this._ready.state === TriggerState.RESOLVED;
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
get identityKey() {
|
|
79
|
-
return this.
|
|
102
|
+
return this._identity.identityKey;
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
get peerKey() {
|
|
83
|
-
return this.
|
|
106
|
+
return this._identity.peerKey;
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
setIdentity(
|
|
87
|
-
this.
|
|
88
|
-
this._identityKey = identityKey;
|
|
109
|
+
setIdentity(identity: EdgeIdentity) {
|
|
110
|
+
this._identity = identity;
|
|
89
111
|
this._persistentLifecycle.scheduleRestart();
|
|
90
112
|
}
|
|
91
113
|
|
|
@@ -108,17 +130,28 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
108
130
|
* Close connection and free resources.
|
|
109
131
|
*/
|
|
110
132
|
protected override async _close() {
|
|
111
|
-
log('closing...', { peerKey: this.
|
|
133
|
+
log('closing...', { peerKey: this._identity.peerKey });
|
|
112
134
|
await this._persistentLifecycle.close();
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
private async _openWebSocket() {
|
|
116
|
-
|
|
117
|
-
|
|
138
|
+
let protocolHeader: string | undefined;
|
|
139
|
+
|
|
140
|
+
if (!DISABLE_AUTH) {
|
|
141
|
+
// TODO(dmaretskyi): Get challenge from the WWW-Authenticate header returned by the endpoint.
|
|
142
|
+
const challenge = randomBytes(32);
|
|
143
|
+
const credential = await this._identity.presentCredentials({ challenge });
|
|
144
|
+
protocolHeader = encodePresentationIntoAuthHeader(credential);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const url = new URL(`/ws/${this._identity.identityKey}/${this._identity.peerKey}`, this._baseUrl);
|
|
148
|
+
log('Opening websocket', { url: url.toString(), protocolHeader });
|
|
149
|
+
this._ws = new WebSocket(url, protocolHeader ? [protocolHeader] : []);
|
|
118
150
|
|
|
119
151
|
this._ws.onopen = () => {
|
|
120
152
|
log('opened', this.info);
|
|
121
153
|
this._ready.wake();
|
|
154
|
+
this.connected.emit();
|
|
122
155
|
};
|
|
123
156
|
this._ws.onclose = () => {
|
|
124
157
|
log('closed', this.info);
|
|
@@ -138,7 +171,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
138
171
|
}
|
|
139
172
|
const data = await toUint8Array(event.data);
|
|
140
173
|
const message = buf.fromBinary(MessageSchema, data);
|
|
141
|
-
log('received', { peerKey: this.
|
|
174
|
+
log('received', { peerKey: this._identity.peerKey, payload: protocol.getPayloadType(message) });
|
|
142
175
|
if (message) {
|
|
143
176
|
for (const listener of this._listeners) {
|
|
144
177
|
try {
|
|
@@ -150,7 +183,10 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
150
183
|
}
|
|
151
184
|
};
|
|
152
185
|
|
|
186
|
+
// TODO(dmaretskyi): Potential race condition here since web socket errors don't resolve this trigger.
|
|
153
187
|
await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
|
|
188
|
+
|
|
189
|
+
// TODO(dmaretskyi): Potential leak: context re-assigned without disposing the previous one.
|
|
154
190
|
this._keepaliveCtx = new Context();
|
|
155
191
|
scheduleTaskInterval(
|
|
156
192
|
this._keepaliveCtx,
|
|
@@ -170,7 +206,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
170
206
|
return;
|
|
171
207
|
}
|
|
172
208
|
try {
|
|
173
|
-
this._ready.throw(new
|
|
209
|
+
this._ready.throw(this.isOpen ? new EdgeIdentityChangedError() : new EdgeConnectionClosedError());
|
|
174
210
|
this._ready.reset();
|
|
175
211
|
void this._keepaliveCtx?.dispose();
|
|
176
212
|
this._keepaliveCtx = undefined;
|
|
@@ -197,11 +233,20 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
197
233
|
*/
|
|
198
234
|
public async send(message: Message): Promise<void> {
|
|
199
235
|
if (this._ready.state !== TriggerState.RESOLVED) {
|
|
236
|
+
log('waiting for websocket to become ready');
|
|
200
237
|
await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
|
|
201
238
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
if (!this._ws) {
|
|
240
|
+
throw new EdgeConnectionClosedError();
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
message.source &&
|
|
244
|
+
(message.source.peerKey !== this._identity.peerKey || message.source.identityKey !== this.identityKey)
|
|
245
|
+
) {
|
|
246
|
+
throw new EdgeIdentityChangedError();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
log('sending...', { peerKey: this._identity.peerKey, payload: protocol.getPayloadType(message) });
|
|
205
250
|
this._ws.send(buf.toBinary(MessageSchema, message));
|
|
206
251
|
}
|
|
207
252
|
|
|
@@ -220,3 +265,11 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
220
265
|
);
|
|
221
266
|
}
|
|
222
267
|
}
|
|
268
|
+
|
|
269
|
+
const encodePresentationIntoAuthHeader = (presentation: Presentation): string => {
|
|
270
|
+
const encoded = schema.getCodecForType('dxos.halo.credentials.Presentation').encode(presentation);
|
|
271
|
+
// = and / characters are not allowed in the WebSocket subprotocol header.
|
|
272
|
+
const encodedToken = Buffer.from(encoded).toString('base64').replace(/=*$/, '').replaceAll('/', '|');
|
|
273
|
+
|
|
274
|
+
return `base64url.bearer.authorization.dxos.org.${encodedToken}`;
|
|
275
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { sleep } from '@dxos/async';
|
|
6
|
+
import { Context } from '@dxos/context';
|
|
7
|
+
import { type SpaceId } from '@dxos/keys';
|
|
8
|
+
import { log } from '@dxos/log';
|
|
9
|
+
import {
|
|
10
|
+
EdgeCallFailedError,
|
|
11
|
+
type EdgeHttpResponse,
|
|
12
|
+
type GetNotarizationResponseBody,
|
|
13
|
+
type PostNotarizationRequestBody,
|
|
14
|
+
} from '@dxos/protocols';
|
|
15
|
+
|
|
16
|
+
import { getEdgeUrlWithProtocol } from './utils';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_RETRY_TIMEOUT = 1500;
|
|
19
|
+
const DEFAULT_RETRY_JITTER = 500;
|
|
20
|
+
const DEFAULT_MAX_RETRIES_COUNT = 3;
|
|
21
|
+
|
|
22
|
+
export class EdgeHttpClient {
|
|
23
|
+
private readonly _baseUrl: string;
|
|
24
|
+
|
|
25
|
+
constructor(baseUrl: string) {
|
|
26
|
+
this._baseUrl = getEdgeUrlWithProtocol(baseUrl, 'http');
|
|
27
|
+
log('created', { url: this._baseUrl });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public getCredentialsForNotarization(spaceId: SpaceId, args?: EdgeHttpGetArgs): Promise<GetNotarizationResponseBody> {
|
|
31
|
+
return this._call(`/spaces/${spaceId}/notarization`, { ...args, method: 'GET' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public async notarizeCredentials(
|
|
35
|
+
spaceId: SpaceId,
|
|
36
|
+
body: PostNotarizationRequestBody,
|
|
37
|
+
args?: EdgeHttpGetArgs,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
await this._call(`/spaces/${spaceId}/notarization`, { ...args, body, method: 'POST' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async _call<T>(path: string, args: EdgeHttpCallArgs): Promise<T> {
|
|
43
|
+
const requestContext = args.context ?? new Context();
|
|
44
|
+
const shouldRetry = createRetryHandler(args);
|
|
45
|
+
const request = createRequest(args);
|
|
46
|
+
const url = `${this._baseUrl}${path.startsWith('/') ? path.slice(1) : path}`;
|
|
47
|
+
|
|
48
|
+
log.info('call', { method: args.method, path });
|
|
49
|
+
|
|
50
|
+
while (true) {
|
|
51
|
+
let processingError: EdgeCallFailedError;
|
|
52
|
+
let retryAfterHeaderValue: number = Number.NaN;
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(url, request);
|
|
55
|
+
|
|
56
|
+
retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
|
|
57
|
+
|
|
58
|
+
if (response.ok) {
|
|
59
|
+
const body = (await response.json()) as EdgeHttpResponse<T>;
|
|
60
|
+
if (body.success) {
|
|
61
|
+
return body.data;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isNonRetryable = body.errorData != null;
|
|
65
|
+
if (isNonRetryable) {
|
|
66
|
+
throw new EdgeCallFailedError(body.reason, body.errorData);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
processingError = new EdgeCallFailedError(body.reason);
|
|
70
|
+
} else {
|
|
71
|
+
processingError = EdgeCallFailedError.fromFailureResponse(response);
|
|
72
|
+
if (!isRetryable(response.status)) {
|
|
73
|
+
throw processingError;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (error: any) {
|
|
77
|
+
processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (await shouldRetry(requestContext, retryAfterHeaderValue)) {
|
|
81
|
+
log.info('retrying edge request', { path, processingError });
|
|
82
|
+
} else {
|
|
83
|
+
throw processingError;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const createRequest = (args: EdgeHttpCallArgs): RequestInit => {
|
|
90
|
+
return {
|
|
91
|
+
method: args.method,
|
|
92
|
+
body: args.body && JSON.stringify(args.body),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const isRetryable = (status: number) => {
|
|
97
|
+
if (status === 501) {
|
|
98
|
+
// Not Implemented
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
// TODO: handle 401 Not Authorized
|
|
102
|
+
return !(status >= 400 && status < 500);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const createRetryHandler = (args: EdgeHttpCallArgs) => {
|
|
106
|
+
if (!args.retry || args.retry.count < 1) {
|
|
107
|
+
return async () => false;
|
|
108
|
+
}
|
|
109
|
+
let retries = 0;
|
|
110
|
+
const maxRetries = args.retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
|
|
111
|
+
const baseTimeout = args.retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
|
|
112
|
+
const jitter = args.retry.jitter ?? DEFAULT_RETRY_JITTER;
|
|
113
|
+
return async (ctx: Context, retryAfter: number) => {
|
|
114
|
+
if (++retries > maxRetries || ctx.disposed) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (retryAfter) {
|
|
119
|
+
await sleep(retryAfter);
|
|
120
|
+
} else {
|
|
121
|
+
const timeout = baseTimeout + Math.random() * jitter;
|
|
122
|
+
await sleep(timeout);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type RetryConfig = {
|
|
130
|
+
/**
|
|
131
|
+
* A number of call retries, not counting the initial request.
|
|
132
|
+
*/
|
|
133
|
+
count: number;
|
|
134
|
+
/**
|
|
135
|
+
* Delay before retries in ms.
|
|
136
|
+
*/
|
|
137
|
+
timeout?: number;
|
|
138
|
+
/**
|
|
139
|
+
* A random amount of time before retrying to help prevent large bursts of requests.
|
|
140
|
+
*/
|
|
141
|
+
jitter?: number;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type EdgeHttpGetArgs = { context?: Context; retry?: RetryConfig };
|
|
145
|
+
|
|
146
|
+
export type EdgeHttpPostArgs = { context?: Context; body?: any; retry?: RetryConfig };
|
|
147
|
+
|
|
148
|
+
type EdgeHttpCallArgs = {
|
|
149
|
+
method: string;
|
|
150
|
+
body?: any;
|
|
151
|
+
context?: Context;
|
|
152
|
+
retry?: RetryConfig;
|
|
153
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
export class
|
|
5
|
+
export class EdgeConnectionClosedError extends Error {
|
|
6
6
|
constructor() {
|
|
7
|
-
super('
|
|
7
|
+
super('Edge connection closed.');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class EdgeIdentityChangedError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super('Edge identity changed.');
|
|
8
14
|
}
|
|
9
15
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { expect } from '
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
6
|
|
|
7
7
|
import { sleep, Trigger } from '@dxos/async';
|
|
8
8
|
import { log } from '@dxos/log';
|
|
9
|
-
import {
|
|
9
|
+
import { openAndClose } from '@dxos/test-utils';
|
|
10
10
|
|
|
11
11
|
import { PersistentLifecycle } from './persistent-lifecycle';
|
|
12
12
|
|
package/src/protocol.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { expect } from '
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
6
|
|
|
7
7
|
import { buf } from '@dxos/protocols/buf';
|
|
8
8
|
import {
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
SwarmRequestSchema,
|
|
12
12
|
SwarmResponseSchema,
|
|
13
13
|
} from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
14
|
-
import { describe, test } from '@dxos/test';
|
|
15
14
|
|
|
16
15
|
import { Protocol } from './protocol';
|
|
17
16
|
|
package/src/protocol.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { invariant } from '@dxos/invariant';
|
|
6
6
|
import { buf, bufWkt } from '@dxos/protocols/buf';
|
|
7
|
-
import { type Message, MessageSchema, type
|
|
7
|
+
import { type Message, MessageSchema, type PeerSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
8
8
|
import { bufferToArray } from '@dxos/util';
|
|
9
9
|
|
|
10
|
-
export type PeerData =
|
|
10
|
+
export type PeerData = buf.MessageInitShape<typeof PeerSchema>;
|
|
11
11
|
|
|
12
12
|
export const getTypename = (typeName: string) => `type.googleapis.com/${typeName}`;
|
|
13
13
|
|