@dxos/edge-client 0.8.3 → 0.8.4-main.84f28bd

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 (46) hide show
  1. package/dist/lib/browser/{chunk-VHS3XEIX.mjs → chunk-LMP5TVOP.mjs} +8 -8
  2. package/dist/lib/browser/edge-ws-muxer.mjs +1 -1
  3. package/dist/lib/browser/index.mjs +183 -128
  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 +61 -16
  7. package/dist/lib/browser/testing/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/{chunk-HGQUUFIJ.mjs → chunk-X7J46ISZ.mjs} +8 -8
  9. package/dist/lib/node-esm/edge-ws-muxer.mjs +1 -1
  10. package/dist/lib/node-esm/index.mjs +183 -128
  11. package/dist/lib/node-esm/index.mjs.map +4 -4
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +61 -16
  14. package/dist/lib/node-esm/testing/index.mjs.map +4 -4
  15. package/dist/types/src/edge-http-client.d.ts +32 -30
  16. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  17. package/dist/types/src/edge-http-client.test.d.ts +2 -0
  18. package/dist/types/src/edge-http-client.test.d.ts.map +1 -0
  19. package/dist/types/src/http-client.d.ts +22 -0
  20. package/dist/types/src/http-client.d.ts.map +1 -0
  21. package/dist/types/src/http-client.test.d.ts +2 -0
  22. package/dist/types/src/http-client.test.d.ts.map +1 -0
  23. package/dist/types/src/testing/index.d.ts +1 -0
  24. package/dist/types/src/testing/index.d.ts.map +1 -1
  25. package/dist/types/src/testing/test-server.d.ts +9 -0
  26. package/dist/types/src/testing/test-server.d.ts.map +1 -0
  27. package/dist/types/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +18 -14
  29. package/src/edge-http-client.test.ts +22 -0
  30. package/src/edge-http-client.ts +188 -135
  31. package/src/http-client.test.ts +55 -0
  32. package/src/http-client.ts +67 -0
  33. package/src/testing/index.ts +1 -0
  34. package/src/testing/test-server.ts +45 -0
  35. package/src/testing/test-utils.ts +1 -1
  36. package/dist/lib/node/chunk-XNHBUTNB.cjs +0 -317
  37. package/dist/lib/node/chunk-XNHBUTNB.cjs.map +0 -7
  38. package/dist/lib/node/edge-ws-muxer.cjs +0 -33
  39. package/dist/lib/node/edge-ws-muxer.cjs.map +0 -7
  40. package/dist/lib/node/index.cjs +0 -1060
  41. package/dist/lib/node/index.cjs.map +0 -7
  42. package/dist/lib/node/meta.json +0 -1
  43. package/dist/lib/node/testing/index.cjs +0 -169
  44. package/dist/lib/node/testing/index.cjs.map +0 -7
  45. /package/dist/lib/browser/{chunk-VHS3XEIX.mjs.map → chunk-LMP5TVOP.mjs.map} +0 -0
  46. /package/dist/lib/node-esm/{chunk-HGQUUFIJ.mjs.map → chunk-X7J46ISZ.mjs.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/edge-client",
3
- "version": "0.8.3",
3
+ "version": "0.8.4-main.84f28bd",
4
4
  "description": "EDGE Client",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -39,23 +39,27 @@
39
39
  "README.md"
40
40
  ],
41
41
  "dependencies": {
42
+ "@effect/platform": "^0.87.12",
42
43
  "isomorphic-ws": "^5.0.0",
43
44
  "ws": "^8.14.2",
44
- "@dxos/async": "0.8.3",
45
- "@dxos/context": "0.8.3",
46
- "@dxos/credentials": "0.8.3",
47
- "@dxos/debug": "0.8.3",
48
- "@dxos/invariant": "0.8.3",
49
- "@dxos/keys": "0.8.3",
50
- "@dxos/keyring": "0.8.3",
51
- "@dxos/log": "0.8.3",
52
- "@dxos/crypto": "0.8.3",
53
- "@dxos/node-std": "0.8.3",
54
- "@dxos/protocols": "0.8.3",
55
- "@dxos/util": "0.8.3"
45
+ "@dxos/async": "0.8.4-main.84f28bd",
46
+ "@dxos/context": "0.8.4-main.84f28bd",
47
+ "@dxos/credentials": "0.8.4-main.84f28bd",
48
+ "@dxos/debug": "0.8.4-main.84f28bd",
49
+ "@dxos/crypto": "0.8.4-main.84f28bd",
50
+ "@dxos/invariant": "0.8.4-main.84f28bd",
51
+ "@dxos/keyring": "0.8.4-main.84f28bd",
52
+ "@dxos/keys": "0.8.4-main.84f28bd",
53
+ "@dxos/log": "0.8.4-main.84f28bd",
54
+ "@dxos/node-std": "0.8.4-main.84f28bd",
55
+ "@dxos/protocols": "0.8.4-main.84f28bd",
56
+ "@dxos/util": "0.8.4-main.84f28bd"
56
57
  },
57
58
  "devDependencies": {
58
- "@dxos/test-utils": "0.8.3"
59
+ "@dxos/test-utils": "0.8.4-main.84f28bd"
60
+ },
61
+ "peerDependencies": {
62
+ "effect": "^3.13.3"
59
63
  },
60
64
  "publishConfig": {
61
65
  "access": "public"
@@ -0,0 +1,22 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, it } from 'vitest';
6
+
7
+ import { createEphemeralEdgeIdentity } from './auth';
8
+ import { EdgeHttpClient } from './edge-http-client';
9
+
10
+ // TODO(burdon): Factor out config.
11
+ const DEV_SERVER = 'https://edge.dxos.workers.dev';
12
+
13
+ describe.skipIf(process.env.CI)('EdgeHttpClient', () => {
14
+ it.only('should get status', async ({ expect }) => {
15
+ const client = new EdgeHttpClient(DEV_SERVER);
16
+ const identity = await createEphemeralEdgeIdentity();
17
+ client.setIdentity(identity);
18
+
19
+ const result = await client.getStatus();
20
+ expect(result).toBeDefined();
21
+ });
22
+ });
@@ -2,42 +2,73 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { FetchHttpClient, HttpClient } from '@effect/platform';
6
+ import { Effect, pipe } from 'effect';
7
+
5
8
  import { sleep } from '@dxos/async';
6
9
  import { Context } from '@dxos/context';
7
10
  import { type PublicKey, type SpaceId } from '@dxos/keys';
8
11
  import { log } from '@dxos/log';
9
12
  import {
13
+ type CreateAgentResponseBody,
14
+ type CreateAgentRequestBody,
15
+ type CreateSpaceRequest,
16
+ type CreateSpaceResponseBody,
17
+ EdgeAuthChallengeError,
10
18
  EdgeCallFailedError,
11
19
  type EdgeHttpResponse,
20
+ type ExecuteWorkflowResponseBody,
21
+ type GetAgentStatusResponseBody,
12
22
  type GetNotarizationResponseBody,
13
- type PostNotarizationRequestBody,
23
+ type InitiateOAuthFlowRequest,
24
+ type InitiateOAuthFlowResponse,
14
25
  type JoinSpaceRequest,
15
26
  type JoinSpaceResponseBody,
16
- EdgeAuthChallengeError,
17
- type CreateAgentResponseBody,
18
- type CreateAgentRequestBody,
19
- type GetAgentStatusResponseBody,
20
27
  type RecoverIdentityRequest,
21
28
  type RecoverIdentityResponseBody,
22
- type UploadFunctionRequest,
23
- type UploadFunctionResponseBody,
24
29
  type ObjectId,
25
- type ExecuteWorkflowResponseBody,
30
+ type PostNotarizationRequestBody,
26
31
  type QueueQuery,
27
32
  type QueryResult,
28
- type InitiateOAuthFlowRequest,
29
- type InitiateOAuthFlowResponse,
30
- type CreateSpaceRequest,
31
- type CreateSpaceResponseBody,
33
+ type UploadFunctionRequest,
34
+ type UploadFunctionResponseBody,
35
+ type EdgeStatus,
32
36
  } from '@dxos/protocols';
37
+ import { createUrl } from '@dxos/util';
33
38
 
34
39
  import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
40
+ import { encodeAuthHeader, HttpConfig, withLogging, withRetryConfig } from './http-client';
35
41
  import { getEdgeUrlWithProtocol } from './utils';
36
42
 
37
43
  const DEFAULT_RETRY_TIMEOUT = 1500;
38
44
  const DEFAULT_RETRY_JITTER = 500;
39
45
  const DEFAULT_MAX_RETRIES_COUNT = 3;
40
46
 
47
+ export type RetryConfig = {
48
+ /**
49
+ * A number of call retries, not counting the initial request.
50
+ */
51
+ count: number;
52
+ /**
53
+ * Delay before retries in ms.
54
+ */
55
+ timeout?: number;
56
+ /**
57
+ * A random amount of time before retrying to help prevent large bursts of requests.
58
+ */
59
+ jitter?: number;
60
+ };
61
+
62
+ type EdgeHttpRequestArgs = {
63
+ method: string;
64
+ context?: Context;
65
+ retry?: RetryConfig;
66
+ body?: any;
67
+ };
68
+
69
+ export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry'>;
70
+ export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body'>;
71
+
41
72
  export class EdgeHttpClient {
42
73
  private readonly _baseUrl: string;
43
74
 
@@ -64,19 +95,38 @@ export class EdgeHttpClient {
64
95
  }
65
96
  }
66
97
 
98
+ //
99
+ // Status
100
+ //
101
+
102
+ public async getStatus(args?: EdgeHttpGetArgs): Promise<EdgeStatus> {
103
+ return this._call(new URL('/status', this.baseUrl), { ...args, method: 'GET' });
104
+ }
105
+
106
+ //
107
+ // Agents
108
+ //
109
+
67
110
  public createAgent(body: CreateAgentRequestBody, args?: EdgeHttpGetArgs): Promise<CreateAgentResponseBody> {
68
- return this._call('/agents/create', { ...args, method: 'POST', body });
111
+ return this._call(new URL('/agents/create', this.baseUrl), { ...args, method: 'POST', body });
69
112
  }
70
113
 
71
114
  public getAgentStatus(
72
115
  request: { ownerIdentityKey: PublicKey },
73
116
  args?: EdgeHttpGetArgs,
74
117
  ): Promise<GetAgentStatusResponseBody> {
75
- return this._call(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, { ...args, method: 'GET' });
118
+ return this._call(new URL(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, this.baseUrl), {
119
+ ...args,
120
+ method: 'GET',
121
+ });
76
122
  }
77
123
 
124
+ //
125
+ // Credentials
126
+ //
127
+
78
128
  public getCredentialsForNotarization(spaceId: SpaceId, args?: EdgeHttpGetArgs): Promise<GetNotarizationResponseBody> {
79
- return this._call(`/spaces/${spaceId}/notarization`, { ...args, method: 'GET' });
129
+ return this._call(new URL(`/spaces/${spaceId}/notarization`, this.baseUrl), { ...args, method: 'GET' });
80
130
  }
81
131
 
82
132
  public async notarizeCredentials(
@@ -84,49 +134,55 @@ export class EdgeHttpClient {
84
134
  body: PostNotarizationRequestBody,
85
135
  args?: EdgeHttpGetArgs,
86
136
  ): Promise<void> {
87
- await this._call(`/spaces/${spaceId}/notarization`, { ...args, body, method: 'POST' });
137
+ await this._call(new URL(`/spaces/${spaceId}/notarization`, this.baseUrl), { ...args, body, method: 'POST' });
88
138
  }
89
139
 
90
- public async joinSpaceByInvitation(
91
- spaceId: SpaceId,
92
- body: JoinSpaceRequest,
93
- args?: EdgeHttpGetArgs,
94
- ): Promise<JoinSpaceResponseBody> {
95
- return this._call(`/spaces/${spaceId}/join`, { ...args, body, method: 'POST' });
96
- }
140
+ //
141
+ // Identity
142
+ //
97
143
 
98
144
  public async recoverIdentity(
99
145
  body: RecoverIdentityRequest,
100
146
  args?: EdgeHttpGetArgs,
101
147
  ): Promise<RecoverIdentityResponseBody> {
102
- return this._call('/identity/recover', { ...args, body, method: 'POST' });
148
+ return this._call(new URL('/identity/recover', this.baseUrl), { ...args, body, method: 'POST' });
103
149
  }
104
150
 
105
- public async executeWorkflow(
151
+ //
152
+ // Invitations
153
+ //
154
+
155
+ public async joinSpaceByInvitation(
106
156
  spaceId: SpaceId,
107
- graphId: ObjectId,
108
- input: any,
157
+ body: JoinSpaceRequest,
109
158
  args?: EdgeHttpGetArgs,
110
- ): Promise<ExecuteWorkflowResponseBody> {
111
- return this._call(`/workflows/${spaceId}/${graphId}`, { ...args, body: input, method: 'POST' });
159
+ ): Promise<JoinSpaceResponseBody> {
160
+ return this._call(new URL(`/spaces/${spaceId}/join`, this.baseUrl), { ...args, body, method: 'POST' });
112
161
  }
113
162
 
114
- public async uploadFunction(
115
- pathParts: { functionId?: string },
116
- body: UploadFunctionRequest,
117
- args?: EdgeHttpGetArgs,
118
- ): Promise<UploadFunctionResponseBody> {
119
- const path = ['functions', ...(pathParts.functionId ? [pathParts.functionId] : [])].join('/');
120
- return this._call(path, { ...args, body, method: 'PUT' });
121
- }
163
+ //
164
+ // OAuth and credentials
165
+ //
122
166
 
123
167
  public async initiateOAuthFlow(
124
168
  body: InitiateOAuthFlowRequest,
125
169
  args?: EdgeHttpGetArgs,
126
170
  ): Promise<InitiateOAuthFlowResponse> {
127
- return this._call('/oauth/initiate', { ...args, body, method: 'POST' });
171
+ return this._call(new URL('/oauth/initiate', this.baseUrl), { ...args, body, method: 'POST' });
128
172
  }
129
173
 
174
+ //
175
+ // Spaces
176
+ //
177
+
178
+ async createSpace(body: CreateSpaceRequest, args?: EdgeHttpGetArgs): Promise<CreateSpaceResponseBody> {
179
+ return this._call(new URL('/spaces/create', this.baseUrl), { ...args, body, method: 'POST' });
180
+ }
181
+
182
+ //
183
+ // Queues
184
+ //
185
+
130
186
  public async queryQueue(
131
187
  subspaceTag: string,
132
188
  spaceId: SpaceId,
@@ -134,26 +190,19 @@ export class EdgeHttpClient {
134
190
  args?: EdgeHttpGetArgs,
135
191
  ): Promise<QueryResult> {
136
192
  const { queueId } = query;
137
- const queryParams = new URLSearchParams();
138
- if (query.after != null) {
139
- queryParams.set('after', query.after);
140
- }
141
- if (query.before != null) {
142
- queryParams.set('before', query.before);
143
- }
144
- if (query.limit != null) {
145
- queryParams.set('limit', query.limit.toString());
146
- }
147
- if (query.reverse != null) {
148
- queryParams.set('reverse', query.reverse.toString());
149
- }
150
- if (query.objectIds != null) {
151
- queryParams.set('objectIds', query.objectIds.join(','));
152
- }
153
- return this._call(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}/query?${queryParams.toString()}`, {
154
- ...args,
155
- method: 'GET',
156
- });
193
+ return this._call(
194
+ createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}/query`, this.baseUrl), {
195
+ after: query.after,
196
+ before: query.before,
197
+ limit: query.limit,
198
+ reverse: query.reverse,
199
+ objectIds: query.objectIds?.join(','),
200
+ }),
201
+ {
202
+ ...args,
203
+ method: 'GET',
204
+ },
205
+ );
157
206
  }
158
207
 
159
208
  public async insertIntoQueue(
@@ -163,72 +212,105 @@ export class EdgeHttpClient {
163
212
  objects: unknown[],
164
213
  args?: EdgeHttpGetArgs,
165
214
  ): Promise<void> {
166
- return this._call(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, {
215
+ return this._call(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, this.baseUrl), {
167
216
  ...args,
168
217
  body: { objects },
169
218
  method: 'POST',
170
219
  });
171
220
  }
172
221
 
173
- async deleteFromQueue(
222
+ public async deleteFromQueue(
174
223
  subspaceTag: string,
175
224
  spaceId: SpaceId,
176
225
  queueId: ObjectId,
177
226
  objectIds: ObjectId[],
178
227
  args?: EdgeHttpGetArgs,
179
228
  ): Promise<void> {
180
- return this._call(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, {
229
+ return this._call(
230
+ createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, this.baseUrl), {
231
+ ids: objectIds.join(','),
232
+ }),
233
+ {
234
+ ...args,
235
+ method: 'DELETE',
236
+ },
237
+ );
238
+ }
239
+
240
+ //
241
+ // Functions
242
+ //
243
+
244
+ public async uploadFunction(
245
+ pathParts: { functionId?: string },
246
+ body: UploadFunctionRequest,
247
+ args?: EdgeHttpGetArgs,
248
+ ): Promise<UploadFunctionResponseBody> {
249
+ const path = ['functions', ...(pathParts.functionId ? [pathParts.functionId] : [])].join('/');
250
+ return this._call(new URL(path, this.baseUrl), { ...args, body, method: 'PUT' });
251
+ }
252
+
253
+ //
254
+ // Workflows
255
+ //
256
+
257
+ public async executeWorkflow(
258
+ spaceId: SpaceId,
259
+ graphId: ObjectId,
260
+ input: any,
261
+ args?: EdgeHttpGetArgs,
262
+ ): Promise<ExecuteWorkflowResponseBody> {
263
+ return this._call(new URL(`/workflows/${spaceId}/${graphId}`, this.baseUrl), {
181
264
  ...args,
182
- query: { ids: objectIds.join(',') },
183
- method: 'DELETE',
265
+ body: input,
266
+ method: 'POST',
184
267
  });
185
268
  }
186
269
 
187
- async createSpace(body: CreateSpaceRequest, args?: EdgeHttpGetArgs): Promise<CreateSpaceResponseBody> {
188
- return this._call('/spaces/create', { ...args, body, method: 'POST' });
270
+ //
271
+ // Internal
272
+ //
273
+
274
+ private async _fetch<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
275
+ return pipe(
276
+ HttpClient.get(url),
277
+ withLogging,
278
+ withRetryConfig,
279
+ Effect.provide(FetchHttpClient.layer),
280
+ Effect.provide(HttpConfig.default),
281
+ Effect.withSpan('EdgeHttpClient'),
282
+ Effect.runPromise,
283
+ ) as T;
189
284
  }
190
285
 
191
- private async _call<T>(path: string, args: EdgeHttpCallArgs): Promise<T> {
192
- const requestContext = args.context ?? new Context();
286
+ // TODO(burdon): Refactor with effect (see edge-http-client.test.ts).
287
+ private async _call<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
193
288
  const shouldRetry = createRetryHandler(args);
194
- let url = `${this._baseUrl}${path.startsWith('/') ? path.slice(1) : path}`;
195
-
196
- if (args.query) {
197
- const queryParams = new URLSearchParams();
198
- for (const [key, value] of Object.entries(args.query)) {
199
- queryParams.set(key, value.toString());
200
- }
201
- url += `?${queryParams.toString()}`;
202
- }
203
-
204
- log('call', { method: args.method, path, request: args.body });
289
+ const requestContext = args.context ?? new Context();
290
+ log('fetch', { url, request: args.body });
205
291
 
206
292
  let handledAuth = false;
207
- let authHeader = this._authHeader;
208
293
  while (true) {
209
294
  let processingError: EdgeCallFailedError;
210
295
  let retryAfterHeaderValue: number = Number.NaN;
211
296
  try {
212
- const request = createRequest(args, authHeader);
297
+ const request = createRequest(args, this._authHeader);
213
298
  const response = await fetch(url, request);
214
-
215
299
  retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
216
-
217
300
  if (response.ok) {
218
301
  const body = (await response.json()) as EdgeHttpResponse<T>;
219
302
  if (body.success) {
220
303
  return body.data;
221
304
  }
222
305
 
223
- log('unsuccessful edge response', { path, body });
224
-
306
+ log.warn('unsuccessful edge response', { url, body });
225
307
  if (body.errorData?.type === 'auth_challenge' && typeof body.errorData?.challenge === 'string') {
226
308
  processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
227
309
  } else {
228
310
  processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
229
311
  }
230
312
  } else if (response.status === 401 && !handledAuth) {
231
- authHeader = await this._handleUnauthorized(response);
313
+ this._authHeader = await this._handleUnauthorized(response);
232
314
  handledAuth = true;
233
315
  continue;
234
316
  } else {
@@ -239,7 +321,7 @@ export class EdgeHttpClient {
239
321
  }
240
322
 
241
323
  if (processingError.isRetryable && (await shouldRetry(requestContext, retryAfterHeaderValue))) {
242
- log('retrying edge request', { path, processingError });
324
+ log('retrying edge request', { url, processingError });
243
325
  } else {
244
326
  throw processingError;
245
327
  }
@@ -248,24 +330,35 @@ export class EdgeHttpClient {
248
330
 
249
331
  private async _handleUnauthorized(response: Response): Promise<string> {
250
332
  if (!this._edgeIdentity) {
251
- log.warn('edge unauthorized response received before identity was set');
333
+ log.warn('unauthorized response received before identity was set');
252
334
  throw EdgeCallFailedError.fromHttpFailure(response);
253
335
  }
336
+
254
337
  const challenge = await handleAuthChallenge(response, this._edgeIdentity);
255
- this._authHeader = encodeAuthHeader(challenge);
256
- log('auth header updated');
257
- return this._authHeader;
338
+ return encodeAuthHeader(challenge);
258
339
  }
259
340
  }
260
341
 
261
- const createRetryHandler = (args: EdgeHttpCallArgs) => {
262
- if (!args.retry || args.retry.count < 1) {
342
+ const createRequest = ({ method, body }: EdgeHttpRequestArgs, authHeader: string | undefined): RequestInit => {
343
+ return {
344
+ method,
345
+ body: body && JSON.stringify(body),
346
+ headers: authHeader ? { Authorization: authHeader } : undefined,
347
+ };
348
+ };
349
+
350
+ /**
351
+ * @deprecated
352
+ */
353
+ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
354
+ if (!retry || retry.count < 1) {
263
355
  return async () => false;
264
356
  }
357
+
265
358
  let retries = 0;
266
- const maxRetries = args.retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
267
- const baseTimeout = args.retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
268
- const jitter = args.retry.jitter ?? DEFAULT_RETRY_JITTER;
359
+ const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
360
+ const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
361
+ const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
269
362
  return async (ctx: Context, retryAfter: number) => {
270
363
  if (++retries > maxRetries || ctx.disposed) {
271
364
  return false;
@@ -281,43 +374,3 @@ const createRetryHandler = (args: EdgeHttpCallArgs) => {
281
374
  return true;
282
375
  };
283
376
  };
284
-
285
- export type RetryConfig = {
286
- /**
287
- * A number of call retries, not counting the initial request.
288
- */
289
- count: number;
290
- /**
291
- * Delay before retries in ms.
292
- */
293
- timeout?: number;
294
- /**
295
- * A random amount of time before retrying to help prevent large bursts of requests.
296
- */
297
- jitter?: number;
298
- };
299
-
300
- export type EdgeHttpGetArgs = { context?: Context; retry?: RetryConfig };
301
-
302
- export type EdgeHttpPostArgs = { context?: Context; body?: any; retry?: RetryConfig };
303
-
304
- type EdgeHttpCallArgs = {
305
- method: string;
306
- body?: any;
307
- context?: Context;
308
- retry?: RetryConfig;
309
- query?: Record<string, string>;
310
- };
311
-
312
- const createRequest = (args: EdgeHttpCallArgs, authHeader: string | undefined): RequestInit => {
313
- return {
314
- method: args.method,
315
- body: args.body && JSON.stringify(args.body),
316
- headers: authHeader ? { Authorization: authHeader } : undefined,
317
- };
318
- };
319
-
320
- const encodeAuthHeader = (challenge: Uint8Array) => {
321
- const encodedChallenge = Buffer.from(challenge).toString('base64');
322
- return `VerifiablePresentation pb;base64,${encodedChallenge}`;
323
- };
@@ -0,0 +1,55 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { FetchHttpClient, HttpClient } from '@effect/platform';
6
+ import { Effect, pipe } from 'effect';
7
+ import { afterEach, beforeEach, describe, it } from 'vitest';
8
+
9
+ import { invariant } from '@dxos/invariant';
10
+
11
+ import { HttpConfig, withLogging, withRetry, withRetryConfig } from './http-client';
12
+ import { type TestServer, createTestServer, responseHandler } from './testing';
13
+
14
+ describe('HttpClient', () => {
15
+ let server: TestServer | undefined;
16
+
17
+ beforeEach(async () => {
18
+ server = await createTestServer(responseHandler((attempt) => (attempt > 2 ? { value: 100 } : false)));
19
+ });
20
+
21
+ // eslint-disable-next-line mocha/no-top-level-hooks
22
+ afterEach(() => {
23
+ server?.close();
24
+ server = undefined;
25
+ });
26
+
27
+ // TODO(burdon): Auth headers.
28
+ // TODO(burdon): Add request/response schema type checking.
29
+ it.skipIf(process.env.CI)('should retry', async ({ expect }) => {
30
+ invariant(server);
31
+
32
+ {
33
+ const result = await pipe(
34
+ withRetry(HttpClient.get(server.url)),
35
+ Effect.provide(FetchHttpClient.layer),
36
+ Effect.withSpan('EdgeHttpClient'),
37
+ Effect.runPromise,
38
+ );
39
+ expect(result).toMatchObject({ success: true, data: { value: 100 } });
40
+ }
41
+
42
+ {
43
+ const result = await pipe(
44
+ HttpClient.get(server.url),
45
+ withLogging,
46
+ withRetryConfig,
47
+ Effect.provide(FetchHttpClient.layer),
48
+ Effect.provide(HttpConfig.default), // TODO(burdon): Swap out to mock.
49
+ Effect.withSpan('EdgeHttpClient'), // TODO(burdon): OTEL.
50
+ Effect.runPromise,
51
+ );
52
+ expect(result).toMatchObject({ success: true, data: { value: 100 } });
53
+ }
54
+ });
55
+ });
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type HttpClient } from '@effect/platform';
6
+ import { type HttpClientError } from '@effect/platform/HttpClientError';
7
+ import { type HttpClientResponse } from '@effect/platform/HttpClientResponse';
8
+ import { Context, Duration, Effect, Layer, Schedule } from 'effect';
9
+
10
+ import { log } from '@dxos/log';
11
+
12
+ // TODO(burdon): Factor out.
13
+
14
+ export type RetryOptions = {
15
+ timeout: Duration.Duration;
16
+ retryTimes: number;
17
+ retryBaseDelay: Duration.Duration;
18
+ };
19
+
20
+ // Layer pattern.
21
+ export class HttpConfig extends Context.Tag('HttpConfig')<HttpConfig, RetryOptions>() {
22
+ static default = Layer.succeed(HttpConfig, {
23
+ timeout: Duration.millis(1_000),
24
+ retryTimes: 3,
25
+ retryBaseDelay: Duration.millis(1_000),
26
+ });
27
+ }
28
+
29
+ // HOC pattern.
30
+ export const withRetry = (
31
+ effect: Effect.Effect<HttpClientResponse, HttpClientError, HttpClient.HttpClient>,
32
+ {
33
+ timeout = Duration.millis(1_000),
34
+ retryBaseDelay = Duration.millis(1_000),
35
+ retryTimes = 3,
36
+ }: Partial<RetryOptions> = {},
37
+ ) => {
38
+ return effect.pipe(
39
+ Effect.flatMap((res) =>
40
+ // Treat 500 errors as retryable?
41
+ res.status === 500 ? Effect.fail(new Error(res.status.toString())) : res.json,
42
+ ),
43
+ Effect.timeout(timeout),
44
+ Effect.retry({
45
+ schedule: Schedule.exponential(retryBaseDelay).pipe(Schedule.jittered),
46
+ times: retryTimes,
47
+ }),
48
+ );
49
+ };
50
+
51
+ export const withRetryConfig = (effect: Effect.Effect<HttpClientResponse, HttpClientError, HttpClient.HttpClient>) =>
52
+ Effect.gen(function* () {
53
+ const config = yield* HttpConfig;
54
+ return yield* withRetry(effect, config);
55
+ });
56
+
57
+ export const withLogging = <A extends HttpClientResponse, E, R>(effect: Effect.Effect<A, E, R>) =>
58
+ effect.pipe(Effect.tap((res) => log.info('response', { status: res.status })));
59
+
60
+ /**
61
+ *
62
+ */
63
+ // TODO(burdon): Document.
64
+ export const encodeAuthHeader = (challenge: Uint8Array) => {
65
+ const encodedChallenge = Buffer.from(challenge).toString('base64');
66
+ return `VerifiablePresentation pb;base64,${encodedChallenge}`;
67
+ };
@@ -2,4 +2,5 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ export * from './test-server';
5
6
  export * from './test-utils';