@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.
Files changed (67) hide show
  1. package/dist/lib/browser/chunk-ZWJXA37R.mjs +113 -0
  2. package/dist/lib/browser/chunk-ZWJXA37R.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +802 -286
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +128 -0
  7. package/dist/lib/browser/testing/index.mjs.map +7 -0
  8. package/dist/lib/node/chunk-ANV2HBEH.cjs +136 -0
  9. package/dist/lib/node/chunk-ANV2HBEH.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +798 -283
  11. package/dist/lib/node/index.cjs.map +4 -4
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/testing/index.cjs +158 -0
  14. package/dist/lib/node/testing/index.cjs.map +7 -0
  15. package/dist/lib/node-esm/chunk-HNVT57AU.mjs +115 -0
  16. package/dist/lib/node-esm/chunk-HNVT57AU.mjs.map +7 -0
  17. package/dist/lib/node-esm/index.mjs +994 -0
  18. package/dist/lib/node-esm/index.mjs.map +7 -0
  19. package/dist/lib/node-esm/meta.json +1 -0
  20. package/dist/lib/node-esm/testing/index.mjs +129 -0
  21. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  22. package/dist/types/src/auth.d.ts +22 -0
  23. package/dist/types/src/auth.d.ts.map +1 -0
  24. package/dist/types/src/defs.d.ts.map +1 -1
  25. package/dist/types/src/edge-client.d.ts +30 -26
  26. package/dist/types/src/edge-client.d.ts.map +1 -1
  27. package/dist/types/src/edge-http-client.d.ts +48 -0
  28. package/dist/types/src/edge-http-client.d.ts.map +1 -0
  29. package/dist/types/src/edge-identity.d.ts +15 -0
  30. package/dist/types/src/edge-identity.d.ts.map +1 -0
  31. package/dist/types/src/edge-ws-connection.d.ts +30 -0
  32. package/dist/types/src/edge-ws-connection.d.ts.map +1 -0
  33. package/dist/types/src/errors.d.ts +4 -1
  34. package/dist/types/src/errors.d.ts.map +1 -1
  35. package/dist/types/src/index.d.ts +4 -0
  36. package/dist/types/src/index.d.ts.map +1 -1
  37. package/dist/types/src/persistent-lifecycle.d.ts +7 -5
  38. package/dist/types/src/persistent-lifecycle.d.ts.map +1 -1
  39. package/dist/types/src/protocol.d.ts +2 -2
  40. package/dist/types/src/protocol.d.ts.map +1 -1
  41. package/dist/types/src/testing/index.d.ts +2 -0
  42. package/dist/types/src/testing/index.d.ts.map +1 -0
  43. package/dist/types/src/testing/test-utils.d.ts +22 -0
  44. package/dist/types/src/testing/test-utils.d.ts.map +1 -0
  45. package/dist/types/src/utils.d.ts +2 -0
  46. package/dist/types/src/utils.d.ts.map +1 -0
  47. package/package.json +27 -17
  48. package/src/auth.ts +135 -0
  49. package/src/defs.ts +2 -3
  50. package/src/edge-client.test.ts +144 -25
  51. package/src/edge-client.ts +181 -127
  52. package/src/edge-http-client.ts +213 -0
  53. package/src/edge-identity.ts +31 -0
  54. package/src/edge-ws-connection.ts +148 -0
  55. package/src/errors.ts +8 -2
  56. package/src/index.ts +4 -0
  57. package/src/persistent-lifecycle.test.ts +2 -2
  58. package/src/persistent-lifecycle.ts +26 -11
  59. package/src/protocol.test.ts +1 -2
  60. package/src/protocol.ts +2 -2
  61. package/src/testing/index.ts +5 -0
  62. package/src/testing/test-utils.ts +117 -0
  63. package/src/utils.ts +10 -0
  64. package/src/websocket.test.ts +5 -4
  65. package/dist/types/src/test-utils.d.ts +0 -11
  66. package/dist/types/src/test-utils.d.ts.map +0 -1
  67. package/src/test-utils.ts +0 -49
@@ -2,34 +2,33 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import WebSocket from 'isomorphic-ws';
6
-
7
- import { Trigger, Event, scheduleTaskInterval, scheduleTask, TriggerState } from '@dxos/async';
8
- import { Context, LifecycleState, Resource, type Lifecycle } from '@dxos/context';
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 { WebsocketClosedError } from './errors';
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, toUint8Array } from './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 | Promise<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
- setIdentity(params: { peerKey: string; identityKey: string }): void;
32
- addListener(listener: MessageListener): () => void;
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
- public reconnect = new Event();
47
- private readonly _persistentLifecycle = new PersistentLifecycle({
48
- start: async () => this._openWebSocket(),
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 _listeners = new Set<MessageListener>();
54
- private readonly _protocol: Protocol;
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 _identityKey: string,
62
- private _peerKey: string,
64
+ private _identity: EdgeIdentity,
63
65
  private readonly _config: MessengerConfig,
64
66
  ) {
65
67
  super();
66
- this._protocol = this._config.protocol ?? protocol;
68
+ this._baseWsUrl = getEdgeUrlWithProtocol(_config.socketEndpoint, 'ws');
69
+ this._baseHttpUrl = getEdgeUrlWithProtocol(_config.socketEndpoint, 'http');
67
70
  }
68
71
 
69
- // TODO(burdon): Attach logging.
72
+ @logInfo
70
73
  public get info() {
71
74
  return {
72
75
  open: this.isOpen,
73
- identity: this._identityKey,
74
- device: this._peerKey,
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._identityKey;
86
+ return this._identity.identityKey;
80
87
  }
81
88
 
82
89
  get peerKey() {
83
- return this._peerKey;
90
+ return this._identity.peerKey;
84
91
  }
85
92
 
86
- setIdentity({ peerKey, identityKey }: { peerKey: string; identityKey: string }) {
87
- this._peerKey = peerKey;
88
- this._identityKey = identityKey;
89
- this._persistentLifecycle.scheduleRestart();
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 addListener(listener: MessageListener): () => void {
93
- this._listeners.add(listener);
94
- return () => this._listeners.delete(listener);
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._peerKey });
139
+ log('closing...', { peerKey: this._identity.peerKey });
140
+ this._closeCurrentConnection();
112
141
  await this._persistentLifecycle.close();
113
142
  }
114
143
 
115
- private async _openWebSocket() {
116
- const url = new URL(`/ws/${this._identityKey}/${this._peerKey}`, this._config.socketEndpoint);
117
- this._ws = new WebSocket(url);
144
+ private async _connect(): Promise<EdgeWsConnection | undefined> {
145
+ if (this._ctx.disposed) {
146
+ return undefined;
147
+ }
118
148
 
119
- this._ws.onopen = () => {
120
- log('opened', this.info);
121
- this._ready.wake();
122
- };
123
- this._ws.onclose = () => {
124
- log('closed', this.info);
125
- this._persistentLifecycle.scheduleRestart();
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
- await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
154
- this._keepaliveCtx = new Context();
155
- scheduleTaskInterval(
156
- this._keepaliveCtx,
157
- async () => {
158
- // TODO(mykola): use RFC6455 ping/pong once implemented in the browser?
159
- // Cloudflare's worker responds to this `without interrupting hibernation`. https://developers.cloudflare.com/durable-objects/api/websockets/#setwebsocketautoresponse
160
- this._ws?.send('__ping__');
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._ws.send('__ping__');
165
- this._onHeartbeat();
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 async _closeWebSocket() {
169
- if (!this._ws) {
170
- return;
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
- try {
173
- this._ready.throw(new WebsocketClosedError());
174
- this._ready.reset();
175
- void this._keepaliveCtx?.dispose();
176
- this._keepaliveCtx = undefined;
177
- void this._heartBeatContext?.dispose();
178
- this._heartBeatContext = undefined;
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
- invariant(this._ws);
203
- invariant(!message.source || message.source.peerKey === this._peerKey);
204
- log('sending...', { peerKey: this._peerKey, payload: protocol.getPayloadType(message) });
205
- this._ws.send(buf.toBinary(MessageSchema, message));
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 _onHeartbeat() {
209
- if (this._lifecycleState !== LifecycleState.OPEN) {
210
- return;
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
+ };