@dxos/edge-client 0.6.13 → 0.6.14-main.1366248
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 +802 -286
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +128 -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 +798 -283
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +158 -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 +994 -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 +129 -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 +30 -26
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +48 -0
- package/dist/types/src/edge-http-client.d.ts.map +1 -0
- package/dist/types/src/edge-identity.d.ts +15 -0
- package/dist/types/src/edge-identity.d.ts.map +1 -0
- package/dist/types/src/edge-ws-connection.d.ts +30 -0
- package/dist/types/src/edge-ws-connection.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 +4 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/persistent-lifecycle.d.ts +7 -5
- package/dist/types/src/persistent-lifecycle.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 +22 -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 +27 -17
- package/src/auth.ts +135 -0
- package/src/defs.ts +2 -3
- package/src/edge-client.test.ts +144 -25
- package/src/edge-client.ts +181 -127
- package/src/edge-http-client.ts +213 -0
- package/src/edge-identity.ts +31 -0
- package/src/edge-ws-connection.ts +148 -0
- package/src/errors.ts +8 -2
- package/src/index.ts +4 -0
- package/src/persistent-lifecycle.test.ts +2 -2
- package/src/persistent-lifecycle.ts +26 -11
- 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 +117 -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/edge-client.ts
CHANGED
|
@@ -2,34 +2,33 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { invariant } from '@dxos/invariant';
|
|
10
|
-
import { log } from '@dxos/log';
|
|
11
|
-
import { buf } from '@dxos/protocols/buf';
|
|
12
|
-
import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
5
|
+
import { Trigger, scheduleMicroTask, TriggerState } from '@dxos/async';
|
|
6
|
+
import { Resource, type Lifecycle } from '@dxos/context';
|
|
7
|
+
import { log, logInfo } from '@dxos/log';
|
|
8
|
+
import { type Message } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
13
9
|
|
|
14
10
|
import { protocol } from './defs';
|
|
15
|
-
import {
|
|
11
|
+
import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
|
|
12
|
+
import { EdgeWsConnection } from './edge-ws-connection';
|
|
13
|
+
import { EdgeConnectionClosedError, EdgeIdentityChangedError } from './errors';
|
|
16
14
|
import { PersistentLifecycle } from './persistent-lifecycle';
|
|
17
|
-
import { type Protocol
|
|
15
|
+
import { type Protocol } from './protocol';
|
|
16
|
+
import { getEdgeUrlWithProtocol } from './utils';
|
|
18
17
|
|
|
19
18
|
const DEFAULT_TIMEOUT = 10_000;
|
|
20
|
-
const SIGNAL_KEEPALIVE_INTERVAL = 5_000;
|
|
21
19
|
|
|
22
|
-
export type MessageListener = (message: Message) => void
|
|
20
|
+
export type MessageListener = (message: Message) => void;
|
|
21
|
+
export type ReconnectListener = () => void;
|
|
23
22
|
|
|
24
23
|
export interface EdgeConnection extends Required<Lifecycle> {
|
|
25
|
-
reconnect: Event;
|
|
26
|
-
|
|
27
24
|
get info(): any;
|
|
28
25
|
get identityKey(): string;
|
|
29
26
|
get peerKey(): string;
|
|
30
27
|
get isOpen(): boolean;
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
get isConnected(): boolean;
|
|
29
|
+
setIdentity(identity: EdgeIdentity): void;
|
|
30
|
+
onMessage(listener: MessageListener): () => void;
|
|
31
|
+
onReconnected(listener: ReconnectListener): () => void;
|
|
33
32
|
send(message: Message): Promise<void>;
|
|
34
33
|
}
|
|
35
34
|
|
|
@@ -37,61 +36,90 @@ export type MessengerConfig = {
|
|
|
37
36
|
socketEndpoint: string;
|
|
38
37
|
timeout?: number;
|
|
39
38
|
protocol?: Protocol;
|
|
39
|
+
disableAuth?: boolean;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Messenger client
|
|
43
|
+
* Messenger client for EDGE:
|
|
44
|
+
* - While open, uses PersistentLifecycle to keep an open EdgeWsConnection, reconnecting on failures.
|
|
45
|
+
* - Manages identity and re-create EdgeWsConnection when identity changes.
|
|
46
|
+
* - Dispatches connection state and message notifications.
|
|
44
47
|
*/
|
|
45
48
|
export class EdgeClient extends Resource implements EdgeConnection {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
stop: async () => this._closeWebSocket(),
|
|
50
|
-
onRestart: async () => this.reconnect.emit(),
|
|
49
|
+
private readonly _persistentLifecycle = new PersistentLifecycle<EdgeWsConnection>({
|
|
50
|
+
start: async () => this._connect(),
|
|
51
|
+
stop: async (state: EdgeWsConnection) => this._disconnect(state),
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
private readonly
|
|
54
|
-
private readonly
|
|
54
|
+
private readonly _messageListeners = new Set<MessageListener>();
|
|
55
|
+
private readonly _reconnectListeners = new Set<ReconnectListener>();
|
|
56
|
+
|
|
57
|
+
private readonly _baseWsUrl: string;
|
|
58
|
+
private readonly _baseHttpUrl: string;
|
|
59
|
+
|
|
60
|
+
private _currentConnection?: EdgeWsConnection = undefined;
|
|
55
61
|
private _ready = new Trigger();
|
|
56
|
-
private _ws?: WebSocket = undefined;
|
|
57
|
-
private _keepaliveCtx?: Context = undefined;
|
|
58
|
-
private _heartBeatContext?: Context = undefined;
|
|
59
62
|
|
|
60
63
|
constructor(
|
|
61
|
-
private
|
|
62
|
-
private _peerKey: string,
|
|
64
|
+
private _identity: EdgeIdentity,
|
|
63
65
|
private readonly _config: MessengerConfig,
|
|
64
66
|
) {
|
|
65
67
|
super();
|
|
66
|
-
this.
|
|
68
|
+
this._baseWsUrl = getEdgeUrlWithProtocol(_config.socketEndpoint, 'ws');
|
|
69
|
+
this._baseHttpUrl = getEdgeUrlWithProtocol(_config.socketEndpoint, 'http');
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
|
|
72
|
+
@logInfo
|
|
70
73
|
public get info() {
|
|
71
74
|
return {
|
|
72
75
|
open: this.isOpen,
|
|
73
|
-
identity: this.
|
|
74
|
-
device: this.
|
|
76
|
+
identity: this._identity.identityKey,
|
|
77
|
+
device: this._identity.peerKey,
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
get isConnected() {
|
|
82
|
+
return Boolean(this._currentConnection) && this._ready.state === TriggerState.RESOLVED;
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
get identityKey() {
|
|
79
|
-
return this.
|
|
86
|
+
return this._identity.identityKey;
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
get peerKey() {
|
|
83
|
-
return this.
|
|
90
|
+
return this._identity.peerKey;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
setIdentity(
|
|
87
|
-
this.
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
setIdentity(identity: EdgeIdentity) {
|
|
94
|
+
if (identity.identityKey !== this._identity.identityKey || identity.peerKey !== this._identity.peerKey) {
|
|
95
|
+
log('Edge identity changed', { identity, oldIdentity: this._identity });
|
|
96
|
+
this._identity = identity;
|
|
97
|
+
this._closeCurrentConnection(new EdgeIdentityChangedError());
|
|
98
|
+
this._persistentLifecycle.scheduleRestart();
|
|
99
|
+
}
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
public
|
|
93
|
-
this.
|
|
94
|
-
return () => this.
|
|
102
|
+
public onMessage(listener: MessageListener): () => void {
|
|
103
|
+
this._messageListeners.add(listener);
|
|
104
|
+
return () => this._messageListeners.delete(listener);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public onReconnected(listener: () => void): () => void {
|
|
108
|
+
this._reconnectListeners.add(listener);
|
|
109
|
+
if (this._ready.state === TriggerState.RESOLVED) {
|
|
110
|
+
// Microtask so that listener is always called asynchronously, no matter the state of the ready trigger
|
|
111
|
+
// at the moment of registration.
|
|
112
|
+
scheduleMicroTask(this._ctx, () => {
|
|
113
|
+
if (this._reconnectListeners.has(listener)) {
|
|
114
|
+
try {
|
|
115
|
+
listener();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
log.catch(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return () => this._reconnectListeners.delete(listener);
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
/**
|
|
@@ -108,86 +136,97 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
108
136
|
* Close connection and free resources.
|
|
109
137
|
*/
|
|
110
138
|
protected override async _close() {
|
|
111
|
-
log('closing...', { peerKey: this.
|
|
139
|
+
log('closing...', { peerKey: this._identity.peerKey });
|
|
140
|
+
this._closeCurrentConnection();
|
|
112
141
|
await this._persistentLifecycle.close();
|
|
113
142
|
}
|
|
114
143
|
|
|
115
|
-
private async
|
|
116
|
-
|
|
117
|
-
|
|
144
|
+
private async _connect(): Promise<EdgeWsConnection | undefined> {
|
|
145
|
+
if (this._ctx.disposed) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
118
148
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
127
|
-
this._ws.onerror = (event) => {
|
|
128
|
-
log.warn('EdgeClient socket error', { error: event.error, info: event.message });
|
|
129
|
-
this._persistentLifecycle.scheduleRestart();
|
|
130
|
-
};
|
|
131
|
-
/**
|
|
132
|
-
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
|
|
133
|
-
*/
|
|
134
|
-
this._ws.onmessage = async (event) => {
|
|
135
|
-
if (event.data === '__pong__') {
|
|
136
|
-
this._onHeartbeat();
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
const data = await toUint8Array(event.data);
|
|
140
|
-
const message = buf.fromBinary(MessageSchema, data);
|
|
141
|
-
log('received', { peerKey: this._peerKey, payload: protocol.getPayloadType(message) });
|
|
142
|
-
if (message) {
|
|
143
|
-
for (const listener of this._listeners) {
|
|
144
|
-
try {
|
|
145
|
-
await listener(message);
|
|
146
|
-
} catch (err) {
|
|
147
|
-
log.error('processing', { err, payload: protocol.getPayloadType(message) });
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
};
|
|
149
|
+
const identity = this._identity;
|
|
150
|
+
const path = `/ws/${identity.identityKey}/${identity.peerKey}`;
|
|
151
|
+
const protocolHeader = this._config.disableAuth ? undefined : await this._createAuthHeader(path);
|
|
152
|
+
if (this._identity !== identity) {
|
|
153
|
+
log('identity changed during auth header request');
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
152
156
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
const restartRequired = new Trigger();
|
|
158
|
+
const url = new URL(path, this._baseWsUrl);
|
|
159
|
+
log('Opening websocket', { url: url.toString(), protocolHeader });
|
|
160
|
+
const connection = new EdgeWsConnection(
|
|
161
|
+
identity,
|
|
162
|
+
{ url, protocolHeader },
|
|
163
|
+
{
|
|
164
|
+
onConnected: () => {
|
|
165
|
+
if (this._isActive(connection)) {
|
|
166
|
+
this._ready.wake();
|
|
167
|
+
this._notifyReconnected();
|
|
168
|
+
} else {
|
|
169
|
+
log.verbose('connected callback ignored, because connection is not active');
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
onRestartRequired: () => {
|
|
173
|
+
if (this._isActive(connection)) {
|
|
174
|
+
this._closeCurrentConnection();
|
|
175
|
+
this._persistentLifecycle.scheduleRestart();
|
|
176
|
+
} else {
|
|
177
|
+
log.verbose('restart requested by inactive connection');
|
|
178
|
+
}
|
|
179
|
+
restartRequired.wake();
|
|
180
|
+
},
|
|
181
|
+
onMessage: (message) => {
|
|
182
|
+
if (this._isActive(connection)) {
|
|
183
|
+
this._notifyMessageReceived(message);
|
|
184
|
+
} else {
|
|
185
|
+
log.verbose('ignored a message on inactive connection', {
|
|
186
|
+
from: message.source,
|
|
187
|
+
type: message.payload?.typeUrl,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
},
|
|
161
191
|
},
|
|
162
|
-
SIGNAL_KEEPALIVE_INTERVAL,
|
|
163
192
|
);
|
|
164
|
-
this.
|
|
165
|
-
|
|
193
|
+
this._currentConnection = connection;
|
|
194
|
+
|
|
195
|
+
await connection.open();
|
|
196
|
+
// Race with restartRequired so that restart is not blocked by _connect execution.
|
|
197
|
+
// Wait on ready to attempt a reconnect if it times out.
|
|
198
|
+
await Promise.race([this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT }), restartRequired]);
|
|
199
|
+
|
|
200
|
+
return connection;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async _disconnect(state: EdgeWsConnection) {
|
|
204
|
+
await state.close();
|
|
166
205
|
}
|
|
167
206
|
|
|
168
|
-
private
|
|
169
|
-
|
|
170
|
-
|
|
207
|
+
private _closeCurrentConnection(error: Error = new EdgeConnectionClosedError()) {
|
|
208
|
+
this._currentConnection = undefined;
|
|
209
|
+
this._ready.throw(error);
|
|
210
|
+
this._ready.reset();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private _notifyReconnected() {
|
|
214
|
+
for (const listener of this._reconnectListeners) {
|
|
215
|
+
try {
|
|
216
|
+
listener();
|
|
217
|
+
} catch (err) {
|
|
218
|
+
log.error('ws reconnect listener failed', { err });
|
|
219
|
+
}
|
|
171
220
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// NOTE: Remove event handlers to avoid scheduling restart.
|
|
181
|
-
this._ws.onopen = () => {};
|
|
182
|
-
this._ws.onclose = () => {};
|
|
183
|
-
this._ws.onerror = () => {};
|
|
184
|
-
this._ws.close();
|
|
185
|
-
this._ws = undefined;
|
|
186
|
-
} catch (err) {
|
|
187
|
-
if (err instanceof Error && err.message.includes('WebSocket is closed before the connection is established.')) {
|
|
188
|
-
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private _notifyMessageReceived(message: Message) {
|
|
224
|
+
for (const listener of this._messageListeners) {
|
|
225
|
+
try {
|
|
226
|
+
listener(message);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
log.error('ws incoming message processing failed', { err, payload: protocol.getPayloadType(message) });
|
|
189
229
|
}
|
|
190
|
-
log.warn('Error closing websocket', { err });
|
|
191
230
|
}
|
|
192
231
|
}
|
|
193
232
|
|
|
@@ -197,26 +236,41 @@ export class EdgeClient extends Resource implements EdgeConnection {
|
|
|
197
236
|
*/
|
|
198
237
|
public async send(message: Message): Promise<void> {
|
|
199
238
|
if (this._ready.state !== TriggerState.RESOLVED) {
|
|
239
|
+
log('waiting for websocket to become ready');
|
|
200
240
|
await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
|
|
201
241
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
242
|
+
|
|
243
|
+
if (!this._currentConnection) {
|
|
244
|
+
throw new EdgeConnectionClosedError();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
message.source &&
|
|
249
|
+
(message.source.peerKey !== this._identity.peerKey || message.source.identityKey !== this.identityKey)
|
|
250
|
+
) {
|
|
251
|
+
throw new EdgeIdentityChangedError();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this._currentConnection.send(message);
|
|
206
255
|
}
|
|
207
256
|
|
|
208
|
-
private
|
|
209
|
-
|
|
210
|
-
|
|
257
|
+
private async _createAuthHeader(path: string): Promise<string | undefined> {
|
|
258
|
+
const httpUrl = new URL(path, this._baseHttpUrl);
|
|
259
|
+
httpUrl.protocol = getEdgeUrlWithProtocol(this._baseWsUrl.toString(), 'http');
|
|
260
|
+
const response = await fetch(httpUrl, { method: 'GET' });
|
|
261
|
+
if (response.status === 401) {
|
|
262
|
+
return encodePresentationWsAuthHeader(await handleAuthChallenge(response, this._identity));
|
|
263
|
+
} else {
|
|
264
|
+
log.warn('no auth challenge from edge', { status: response.status, statusText: response.statusText });
|
|
265
|
+
return undefined;
|
|
211
266
|
}
|
|
212
|
-
void this._heartBeatContext?.dispose();
|
|
213
|
-
this._heartBeatContext = new Context();
|
|
214
|
-
scheduleTask(
|
|
215
|
-
this._heartBeatContext,
|
|
216
|
-
() => {
|
|
217
|
-
this._persistentLifecycle.scheduleRestart();
|
|
218
|
-
},
|
|
219
|
-
2 * SIGNAL_KEEPALIVE_INTERVAL,
|
|
220
|
-
);
|
|
221
267
|
}
|
|
268
|
+
|
|
269
|
+
private _isActive = (connection: EdgeWsConnection) => connection === this._currentConnection;
|
|
222
270
|
}
|
|
271
|
+
|
|
272
|
+
const encodePresentationWsAuthHeader = (encodedPresentation: Uint8Array): string => {
|
|
273
|
+
// = and / characters are not allowed in the WebSocket subprotocol header.
|
|
274
|
+
const encodedToken = Buffer.from(encodedPresentation).toString('base64').replace(/=*$/, '').replaceAll('/', '|');
|
|
275
|
+
return `base64url.bearer.authorization.dxos.org.${encodedToken}`;
|
|
276
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { sleep } from '@dxos/async';
|
|
6
|
+
import { Context } from '@dxos/context';
|
|
7
|
+
import { type PublicKey, 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
|
+
type JoinSpaceRequest,
|
|
15
|
+
type JoinSpaceResponseBody,
|
|
16
|
+
EdgeAuthChallengeError,
|
|
17
|
+
type CreateAgentResponseBody,
|
|
18
|
+
type CreateAgentRequestBody,
|
|
19
|
+
type GetAgentStatusResponseBody,
|
|
20
|
+
type RecoverIdentityRequest,
|
|
21
|
+
type RecoverIdentityResponseBody,
|
|
22
|
+
} from '@dxos/protocols';
|
|
23
|
+
|
|
24
|
+
import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
|
|
25
|
+
import { getEdgeUrlWithProtocol } from './utils';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_RETRY_TIMEOUT = 1500;
|
|
28
|
+
const DEFAULT_RETRY_JITTER = 500;
|
|
29
|
+
const DEFAULT_MAX_RETRIES_COUNT = 3;
|
|
30
|
+
|
|
31
|
+
export class EdgeHttpClient {
|
|
32
|
+
private readonly _baseUrl: string;
|
|
33
|
+
|
|
34
|
+
private _edgeIdentity: EdgeIdentity | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Auth header is cached until receiving the next 401 from EDGE, at which point it gets refreshed.
|
|
37
|
+
*/
|
|
38
|
+
private _authHeader: string | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(baseUrl: string) {
|
|
41
|
+
this._baseUrl = getEdgeUrlWithProtocol(baseUrl, 'http');
|
|
42
|
+
log('created', { url: this._baseUrl });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setIdentity(identity: EdgeIdentity) {
|
|
46
|
+
if (this._edgeIdentity?.identityKey !== identity.identityKey || this._edgeIdentity?.peerKey !== identity.peerKey) {
|
|
47
|
+
this._edgeIdentity = identity;
|
|
48
|
+
this._authHeader = undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public createAgent(body: CreateAgentRequestBody, args?: EdgeHttpGetArgs): Promise<CreateAgentResponseBody> {
|
|
53
|
+
return this._call('/agents/create', { ...args, method: 'POST', body });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public getAgentStatus(
|
|
57
|
+
request: { ownerIdentityKey: PublicKey },
|
|
58
|
+
args?: EdgeHttpGetArgs,
|
|
59
|
+
): Promise<GetAgentStatusResponseBody> {
|
|
60
|
+
return this._call(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, { ...args, method: 'GET' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public getCredentialsForNotarization(spaceId: SpaceId, args?: EdgeHttpGetArgs): Promise<GetNotarizationResponseBody> {
|
|
64
|
+
return this._call(`/spaces/${spaceId}/notarization`, { ...args, method: 'GET' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public async notarizeCredentials(
|
|
68
|
+
spaceId: SpaceId,
|
|
69
|
+
body: PostNotarizationRequestBody,
|
|
70
|
+
args?: EdgeHttpGetArgs,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
await this._call(`/spaces/${spaceId}/notarization`, { ...args, body, method: 'POST' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public async joinSpaceByInvitation(
|
|
76
|
+
spaceId: SpaceId,
|
|
77
|
+
body: JoinSpaceRequest,
|
|
78
|
+
args?: EdgeHttpGetArgs,
|
|
79
|
+
): Promise<JoinSpaceResponseBody> {
|
|
80
|
+
return this._call(`/spaces/${spaceId}/join`, { ...args, body, method: 'POST' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public async recoverIdentity(
|
|
84
|
+
body: RecoverIdentityRequest,
|
|
85
|
+
args?: EdgeHttpGetArgs,
|
|
86
|
+
): Promise<RecoverIdentityResponseBody> {
|
|
87
|
+
return this._call('/identity/recover', { ...args, body, method: 'POST' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async _call<T>(path: string, args: EdgeHttpCallArgs): Promise<T> {
|
|
91
|
+
const requestContext = args.context ?? new Context();
|
|
92
|
+
const shouldRetry = createRetryHandler(args);
|
|
93
|
+
const url = `${this._baseUrl}${path.startsWith('/') ? path.slice(1) : path}`;
|
|
94
|
+
|
|
95
|
+
log.info('call', { method: args.method, path, request: args.body });
|
|
96
|
+
|
|
97
|
+
let handledAuth = false;
|
|
98
|
+
let authHeader = this._authHeader;
|
|
99
|
+
while (true) {
|
|
100
|
+
let processingError: EdgeCallFailedError;
|
|
101
|
+
let retryAfterHeaderValue: number = Number.NaN;
|
|
102
|
+
try {
|
|
103
|
+
const request = createRequest(args, authHeader);
|
|
104
|
+
const response = await fetch(url, request);
|
|
105
|
+
|
|
106
|
+
retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
|
|
107
|
+
|
|
108
|
+
if (response.ok) {
|
|
109
|
+
const body = (await response.json()) as EdgeHttpResponse<T>;
|
|
110
|
+
if (body.success) {
|
|
111
|
+
return body.data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
log.info('unsuccessful edge response', { path, body });
|
|
115
|
+
|
|
116
|
+
if (body.errorData?.type === 'auth_challenge' && typeof body.errorData?.challenge === 'string') {
|
|
117
|
+
processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
|
|
118
|
+
} else {
|
|
119
|
+
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
120
|
+
}
|
|
121
|
+
} else if (response.status === 401 && !handledAuth) {
|
|
122
|
+
authHeader = await this._handleUnauthorized(response);
|
|
123
|
+
handledAuth = true;
|
|
124
|
+
continue;
|
|
125
|
+
} else {
|
|
126
|
+
processingError = EdgeCallFailedError.fromHttpFailure(response);
|
|
127
|
+
}
|
|
128
|
+
} catch (error: any) {
|
|
129
|
+
processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (processingError.isRetryable && (await shouldRetry(requestContext, retryAfterHeaderValue))) {
|
|
133
|
+
log.info('retrying edge request', { path, processingError });
|
|
134
|
+
} else {
|
|
135
|
+
throw processingError;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async _handleUnauthorized(response: Response) {
|
|
141
|
+
if (!this._edgeIdentity) {
|
|
142
|
+
log.warn('edge unauthorized response received before identity was set');
|
|
143
|
+
throw EdgeCallFailedError.fromHttpFailure(response);
|
|
144
|
+
}
|
|
145
|
+
const challenge = await handleAuthChallenge(response, this._edgeIdentity);
|
|
146
|
+
this._authHeader = encodeAuthHeader(challenge);
|
|
147
|
+
log('auth header updated');
|
|
148
|
+
return this._authHeader;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const createRetryHandler = (args: EdgeHttpCallArgs) => {
|
|
153
|
+
if (!args.retry || args.retry.count < 1) {
|
|
154
|
+
return async () => false;
|
|
155
|
+
}
|
|
156
|
+
let retries = 0;
|
|
157
|
+
const maxRetries = args.retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
|
|
158
|
+
const baseTimeout = args.retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
|
|
159
|
+
const jitter = args.retry.jitter ?? DEFAULT_RETRY_JITTER;
|
|
160
|
+
return async (ctx: Context, retryAfter: number) => {
|
|
161
|
+
if (++retries > maxRetries || ctx.disposed) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (retryAfter) {
|
|
166
|
+
await sleep(retryAfter);
|
|
167
|
+
} else {
|
|
168
|
+
const timeout = baseTimeout + Math.random() * jitter;
|
|
169
|
+
await sleep(timeout);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export type RetryConfig = {
|
|
177
|
+
/**
|
|
178
|
+
* A number of call retries, not counting the initial request.
|
|
179
|
+
*/
|
|
180
|
+
count: number;
|
|
181
|
+
/**
|
|
182
|
+
* Delay before retries in ms.
|
|
183
|
+
*/
|
|
184
|
+
timeout?: number;
|
|
185
|
+
/**
|
|
186
|
+
* A random amount of time before retrying to help prevent large bursts of requests.
|
|
187
|
+
*/
|
|
188
|
+
jitter?: number;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export type EdgeHttpGetArgs = { context?: Context; retry?: RetryConfig };
|
|
192
|
+
|
|
193
|
+
export type EdgeHttpPostArgs = { context?: Context; body?: any; retry?: RetryConfig };
|
|
194
|
+
|
|
195
|
+
type EdgeHttpCallArgs = {
|
|
196
|
+
method: string;
|
|
197
|
+
body?: any;
|
|
198
|
+
context?: Context;
|
|
199
|
+
retry?: RetryConfig;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const createRequest = (args: EdgeHttpCallArgs, authHeader: string | undefined): RequestInit => {
|
|
203
|
+
return {
|
|
204
|
+
method: args.method,
|
|
205
|
+
body: args.body && JSON.stringify(args.body),
|
|
206
|
+
headers: authHeader ? { Authorization: authHeader } : undefined,
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const encodeAuthHeader = (challenge: Uint8Array) => {
|
|
211
|
+
const encodedChallenge = Buffer.from(challenge).toString('base64');
|
|
212
|
+
return `VerifiablePresentation pb;base64,${encodedChallenge}`;
|
|
213
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { invariant } from '@dxos/invariant';
|
|
6
|
+
import { schema } from '@dxos/protocols/proto';
|
|
7
|
+
import { type Presentation } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
8
|
+
|
|
9
|
+
export interface EdgeIdentity {
|
|
10
|
+
peerKey: string;
|
|
11
|
+
identityKey: string;
|
|
12
|
+
/**
|
|
13
|
+
* Returns credential presentation issued by the identity key.
|
|
14
|
+
* Presentation must have the provided challenge.
|
|
15
|
+
* Presentation may include ServiceAccess credentials.
|
|
16
|
+
*/
|
|
17
|
+
presentCredentials({ challenge }: { challenge: Uint8Array }): Promise<Presentation>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const handleAuthChallenge = async (failedResponse: Response, identity: EdgeIdentity): Promise<Uint8Array> => {
|
|
21
|
+
invariant(failedResponse.status === 401);
|
|
22
|
+
|
|
23
|
+
const headerValue = failedResponse.headers.get('Www-Authenticate');
|
|
24
|
+
invariant(headerValue?.startsWith('VerifiablePresentation challenge='));
|
|
25
|
+
|
|
26
|
+
const challenge = headerValue?.slice('VerifiablePresentation challenge='.length);
|
|
27
|
+
invariant(challenge);
|
|
28
|
+
|
|
29
|
+
const presentation = await identity.presentCredentials({ challenge: Buffer.from(challenge, 'base64') });
|
|
30
|
+
return schema.getCodecForType('dxos.halo.credentials.Presentation').encode(presentation);
|
|
31
|
+
};
|