@dxos/edge-client 0.8.4-main.ead640a → 0.8.4-main.f466a3d56e

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 (51) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/{browser/chunk-IKP53CBQ.mjs → neutral/chunk-ZIQ5T3A7.mjs} +20 -83
  3. package/dist/lib/{browser/chunk-IKP53CBQ.mjs.map → neutral/chunk-ZIQ5T3A7.mjs.map} +2 -2
  4. package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs +1 -1
  5. package/dist/lib/{browser → neutral}/index.mjs +318 -436
  6. package/dist/lib/neutral/index.mjs.map +7 -0
  7. package/dist/lib/neutral/meta.json +1 -0
  8. package/dist/lib/{browser → neutral}/testing/index.mjs +6 -31
  9. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  10. package/dist/types/src/auth.d.ts.map +1 -1
  11. package/dist/types/src/edge-client.d.ts +5 -2
  12. package/dist/types/src/edge-client.d.ts.map +1 -1
  13. package/dist/types/src/edge-http-client.d.ts +103 -30
  14. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  15. package/dist/types/src/edge-identity.d.ts.map +1 -1
  16. package/dist/types/src/edge-ws-connection.d.ts +1 -0
  17. package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
  18. package/dist/types/src/edge-ws-muxer.d.ts.map +1 -1
  19. package/dist/types/src/errors.d.ts.map +1 -1
  20. package/dist/types/src/http-client.d.ts +2 -2
  21. package/dist/types/src/http-client.d.ts.map +1 -1
  22. package/dist/types/src/protocol.d.ts +1 -1
  23. package/dist/types/src/protocol.d.ts.map +1 -1
  24. package/dist/types/src/testing/test-server.d.ts.map +1 -1
  25. package/dist/types/src/testing/test-utils.d.ts +2 -2
  26. package/dist/types/src/testing/test-utils.d.ts.map +1 -1
  27. package/dist/types/src/utils.d.ts +1 -1
  28. package/dist/types/src/utils.d.ts.map +1 -1
  29. package/dist/types/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +29 -32
  31. package/src/edge-client.test.ts +16 -11
  32. package/src/edge-client.ts +19 -3
  33. package/src/edge-http-client.test.ts +3 -2
  34. package/src/edge-http-client.ts +328 -75
  35. package/src/edge-ws-connection.ts +2 -1
  36. package/src/http-client.test.ts +3 -2
  37. package/src/http-client.ts +5 -1
  38. package/src/testing/test-utils.ts +4 -4
  39. package/dist/lib/browser/index.mjs.map +0 -7
  40. package/dist/lib/browser/meta.json +0 -1
  41. package/dist/lib/browser/testing/index.mjs.map +0 -7
  42. package/dist/lib/node-esm/chunk-DR5YNW5K.mjs +0 -332
  43. package/dist/lib/node-esm/chunk-DR5YNW5K.mjs.map +0 -7
  44. package/dist/lib/node-esm/edge-ws-muxer.mjs +0 -12
  45. package/dist/lib/node-esm/edge-ws-muxer.mjs.map +0 -7
  46. package/dist/lib/node-esm/index.mjs +0 -1357
  47. package/dist/lib/node-esm/index.mjs.map +0 -7
  48. package/dist/lib/node-esm/meta.json +0 -1
  49. package/dist/lib/node-esm/testing/index.mjs +0 -186
  50. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  51. /package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs.map +0 -0
@@ -8,7 +8,9 @@ import * as Effect from 'effect/Effect';
8
8
  import * as Function from 'effect/Function';
9
9
 
10
10
  import { sleep } from '@dxos/async';
11
- import { Context } from '@dxos/context';
11
+ import { Context, TRACE_SPAN_ATTRIBUTE, type TraceContextData } from '@dxos/context';
12
+ import { runAndForwardErrors } from '@dxos/effect';
13
+ import { invariant } from '@dxos/invariant';
12
14
  import { type PublicKey, type SpaceId } from '@dxos/keys';
13
15
  import { log } from '@dxos/log';
14
16
  import {
@@ -16,14 +18,18 @@ import {
16
18
  type CreateAgentResponseBody,
17
19
  type CreateSpaceRequest,
18
20
  type CreateSpaceResponseBody,
21
+ EDGE_CLIENT_TAG_HEADER,
19
22
  EdgeAuthChallengeError,
20
23
  EdgeCallFailedError,
21
- type EdgeHttpResponse,
24
+ type EdgeFailure,
22
25
  type EdgeStatus,
23
26
  type ExecuteWorkflowResponseBody,
24
27
  type ExportBundleRequest,
25
28
  type ExportBundleResponse,
29
+ type FeedProtocol,
26
30
  type GetAgentStatusResponseBody,
31
+ type GetPluginVersionsResponseBody,
32
+ type GetPluginsResponseBody,
27
33
  type GetNotarizationResponseBody,
28
34
  type ImportBundleRequest,
29
35
  type InitiateOAuthFlowRequest,
@@ -32,24 +38,43 @@ import {
32
38
  type JoinSpaceResponseBody,
33
39
  type ObjectId,
34
40
  type PostNotarizationRequestBody,
35
- type QueryResult,
36
- type QueueQuery,
37
41
  type RecoverIdentityRequest,
38
42
  type RecoverIdentityResponseBody,
39
43
  type UploadFunctionRequest,
40
44
  type UploadFunctionResponseBody,
41
45
  } from '@dxos/protocols';
46
+ import {
47
+ type QueryRequest as QueryRequestProto,
48
+ type QueryResponse as QueryResponseProto,
49
+ } from '@dxos/protocols/proto/dxos/echo/query';
42
50
  import { createUrl } from '@dxos/util';
43
51
 
44
52
  import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
45
53
  import { HttpConfig, encodeAuthHeader, withLogging, withRetryConfig } from './http-client';
46
54
  import { getEdgeUrlWithProtocol } from './utils';
47
55
 
56
+ /**
57
+ * HTTP wire shape returned by `/queue/.../query`. Unlike `FeedProtocol.QueryResult`
58
+ * (the RPC proto type, which transports object payloads as JSON strings), the edge
59
+ * HTTP endpoint embeds each object directly in the response JSON.
60
+ */
61
+ export type EdgeQueryQueueResponse = {
62
+ objects?: unknown[];
63
+ nextCursor?: string;
64
+ prevCursor?: string;
65
+ };
66
+
48
67
  const DEFAULT_RETRY_TIMEOUT = 1500;
49
68
  const DEFAULT_RETRY_JITTER = 500;
50
69
  const DEFAULT_MAX_RETRIES_COUNT = 3;
51
70
  const WARNING_BODY_SIZE = 10 * 1024 * 1024; // 10MB
52
71
 
72
+ // TEMPORARY: legacy standalone CORS proxy used by `proxyFetch` until the
73
+ // authenticated `/proxy/*` route on the main edge worker ships
74
+ // (https://github.com/dxos/edge/pull/576). Delete this constant when the
75
+ // commented-out authenticated branch in `proxyFetch` is restored.
76
+ const LEGACY_CORS_PROXY_URL = 'https://cors-proxy.dxos.workers.dev';
77
+
53
78
  export type RetryConfig = {
54
79
  /**
55
80
  * A number of call retries, not counting the initial request.
@@ -67,7 +92,6 @@ export type RetryConfig = {
67
92
 
68
93
  type EdgeHttpRequestArgs = {
69
94
  method: string;
70
- context?: Context;
71
95
  retry?: RetryConfig;
72
96
  body?: any;
73
97
  /**
@@ -76,16 +100,30 @@ type EdgeHttpRequestArgs = {
76
100
  json?: boolean;
77
101
 
78
102
  /**
79
- * Do not expect a standard EDGE JSON response with a `success` field.
103
+ * Force authentication.
104
+ * This should be used for requests with large bodies to avoid sending the body twice.
105
+ * The client will call /auth endpoint to generate the auth header.
80
106
  */
81
- rawResponse?: boolean;
107
+ auth?: boolean;
82
108
  };
83
109
 
84
- export type EdgeHttpGetArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry'>;
85
- export type EdgeHttpPostArgs = Pick<EdgeHttpRequestArgs, 'context' | 'retry' | 'body'>;
110
+ export type EdgeHttpCallArgs = Pick<EdgeHttpRequestArgs, 'retry' | 'auth'>;
111
+
112
+ export type GetCronTriggersResponse = {
113
+ cronIds: string[];
114
+ };
115
+
116
+ export type EdgeHttpClientOptions = {
117
+ /**
118
+ * Tag included in the {@link EDGE_CLIENT_TAG_HEADER} header on every request.
119
+ * Used on Edge to classify traffic for metering (e.g. `ci-e2e`).
120
+ */
121
+ clientTag?: string;
122
+ };
86
123
 
87
124
  export class EdgeHttpClient {
88
125
  private readonly _baseUrl: string;
126
+ private readonly _clientTag: string | undefined;
89
127
 
90
128
  private _edgeIdentity: EdgeIdentity | undefined;
91
129
 
@@ -94,8 +132,9 @@ export class EdgeHttpClient {
94
132
  */
95
133
  private _authHeader: string | undefined;
96
134
 
97
- constructor(baseUrl: string) {
135
+ constructor(baseUrl: string, options?: EdgeHttpClientOptions) {
98
136
  this._baseUrl = getEdgeUrlWithProtocol(baseUrl, 'http');
137
+ this._clientTag = options?.clientTag;
99
138
  log('created', { url: this._baseUrl });
100
139
  }
101
140
 
@@ -114,23 +153,28 @@ export class EdgeHttpClient {
114
153
  // Status
115
154
  //
116
155
 
117
- public async getStatus(args?: EdgeHttpGetArgs): Promise<EdgeStatus> {
118
- return this._call(new URL('/status', this.baseUrl), { ...args, method: 'GET' });
156
+ public async getStatus(ctx: Context, args?: EdgeHttpCallArgs): Promise<EdgeStatus> {
157
+ return this._call(ctx, new URL('/status', this.baseUrl), { ...args, method: 'GET', auth: true });
119
158
  }
120
159
 
121
160
  //
122
161
  // Agents
123
162
  //
124
163
 
125
- public createAgent(body: CreateAgentRequestBody, args?: EdgeHttpGetArgs): Promise<CreateAgentResponseBody> {
126
- return this._call(new URL('/agents/create', this.baseUrl), { ...args, method: 'POST', body });
164
+ public createAgent(
165
+ ctx: Context,
166
+ body: CreateAgentRequestBody,
167
+ args?: EdgeHttpCallArgs,
168
+ ): Promise<CreateAgentResponseBody> {
169
+ return this._call(ctx, new URL('/agents/create', this.baseUrl), { ...args, method: 'POST', body });
127
170
  }
128
171
 
129
172
  public getAgentStatus(
173
+ ctx: Context,
130
174
  request: { ownerIdentityKey: PublicKey },
131
- args?: EdgeHttpGetArgs,
175
+ args?: EdgeHttpCallArgs,
132
176
  ): Promise<GetAgentStatusResponseBody> {
133
- return this._call(new URL(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, this.baseUrl), {
177
+ return this._call(ctx, new URL(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, this.baseUrl), {
134
178
  ...args,
135
179
  method: 'GET',
136
180
  });
@@ -140,16 +184,21 @@ export class EdgeHttpClient {
140
184
  // Credentials
141
185
  //
142
186
 
143
- public getCredentialsForNotarization(spaceId: SpaceId, args?: EdgeHttpGetArgs): Promise<GetNotarizationResponseBody> {
144
- return this._call(new URL(`/spaces/${spaceId}/notarization`, this.baseUrl), { ...args, method: 'GET' });
187
+ public getCredentialsForNotarization(
188
+ ctx: Context,
189
+ spaceId: SpaceId,
190
+ args?: EdgeHttpCallArgs,
191
+ ): Promise<GetNotarizationResponseBody> {
192
+ return this._call(ctx, new URL(`/spaces/${spaceId}/notarization`, this.baseUrl), { ...args, method: 'GET' });
145
193
  }
146
194
 
147
195
  public async notarizeCredentials(
196
+ ctx: Context,
148
197
  spaceId: SpaceId,
149
198
  body: PostNotarizationRequestBody,
150
- args?: EdgeHttpGetArgs,
199
+ args?: EdgeHttpCallArgs,
151
200
  ): Promise<void> {
152
- await this._call(new URL(`/spaces/${spaceId}/notarization`, this.baseUrl), { ...args, body, method: 'POST' });
201
+ await this._call(ctx, new URL(`/spaces/${spaceId}/notarization`, this.baseUrl), { ...args, body, method: 'POST' });
153
202
  }
154
203
 
155
204
  //
@@ -157,10 +206,11 @@ export class EdgeHttpClient {
157
206
  //
158
207
 
159
208
  public async recoverIdentity(
209
+ ctx: Context,
160
210
  body: RecoverIdentityRequest,
161
- args?: EdgeHttpGetArgs,
211
+ args?: EdgeHttpCallArgs,
162
212
  ): Promise<RecoverIdentityResponseBody> {
163
- return this._call(new URL('/identity/recover', this.baseUrl), { ...args, body, method: 'POST' });
213
+ return this._call(ctx, new URL('/identity/recover', this.baseUrl), { ...args, body, method: 'POST' });
164
214
  }
165
215
 
166
216
  //
@@ -168,11 +218,12 @@ export class EdgeHttpClient {
168
218
  //
169
219
 
170
220
  public async joinSpaceByInvitation(
221
+ ctx: Context,
171
222
  spaceId: SpaceId,
172
223
  body: JoinSpaceRequest,
173
- args?: EdgeHttpGetArgs,
224
+ args?: EdgeHttpCallArgs,
174
225
  ): Promise<JoinSpaceResponseBody> {
175
- return this._call(new URL(`/spaces/${spaceId}/join`, this.baseUrl), { ...args, body, method: 'POST' });
226
+ return this._call(ctx, new URL(`/spaces/${spaceId}/join`, this.baseUrl), { ...args, body, method: 'POST' });
176
227
  }
177
228
 
178
229
  //
@@ -180,18 +231,19 @@ export class EdgeHttpClient {
180
231
  //
181
232
 
182
233
  public async initiateOAuthFlow(
234
+ ctx: Context,
183
235
  body: InitiateOAuthFlowRequest,
184
- args?: EdgeHttpGetArgs,
236
+ args?: EdgeHttpCallArgs,
185
237
  ): Promise<InitiateOAuthFlowResponse> {
186
- return this._call(new URL('/oauth/initiate', this.baseUrl), { ...args, body, method: 'POST' });
238
+ return this._call(ctx, new URL('/oauth/initiate', this.baseUrl), { ...args, body, method: 'POST' });
187
239
  }
188
240
 
189
241
  //
190
242
  // Spaces
191
243
  //
192
244
 
193
- async createSpace(body: CreateSpaceRequest, args?: EdgeHttpGetArgs): Promise<CreateSpaceResponseBody> {
194
- return this._call(new URL('/spaces/create', this.baseUrl), { ...args, body, method: 'POST' });
245
+ async createSpace(ctx: Context, body: CreateSpaceRequest, args?: EdgeHttpCallArgs): Promise<CreateSpaceResponseBody> {
246
+ return this._call(ctx, new URL('/spaces/create', this.baseUrl), { ...args, body, method: 'POST' });
195
247
  }
196
248
 
197
249
  //
@@ -199,13 +251,16 @@ export class EdgeHttpClient {
199
251
  //
200
252
 
201
253
  public async queryQueue(
254
+ ctx: Context,
202
255
  subspaceTag: string,
203
256
  spaceId: SpaceId,
204
- query: QueueQuery,
205
- args?: EdgeHttpGetArgs,
206
- ): Promise<QueryResult> {
207
- const { queueId } = query;
257
+ query: FeedProtocol.QueueQuery,
258
+ args?: EdgeHttpCallArgs,
259
+ ): Promise<EdgeQueryQueueResponse> {
260
+ const queueId = query.queueIds?.[0];
261
+ invariant(queueId, 'queueId required');
208
262
  return this._call(
263
+ ctx,
209
264
  createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}/query`, this.baseUrl), {
210
265
  after: query.after,
211
266
  before: query.before,
@@ -221,13 +276,14 @@ export class EdgeHttpClient {
221
276
  }
222
277
 
223
278
  public async insertIntoQueue(
279
+ ctx: Context,
224
280
  subspaceTag: string,
225
281
  spaceId: SpaceId,
226
282
  queueId: ObjectId,
227
283
  objects: unknown[],
228
- args?: EdgeHttpGetArgs,
284
+ args?: EdgeHttpCallArgs,
229
285
  ): Promise<void> {
230
- return this._call(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, this.baseUrl), {
286
+ return this._call(ctx, new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, this.baseUrl), {
231
287
  ...args,
232
288
  body: { objects },
233
289
  method: 'POST',
@@ -235,13 +291,15 @@ export class EdgeHttpClient {
235
291
  }
236
292
 
237
293
  public async deleteFromQueue(
294
+ ctx: Context,
238
295
  subspaceTag: string,
239
296
  spaceId: SpaceId,
240
297
  queueId: ObjectId,
241
298
  objectIds: ObjectId[],
242
- args?: EdgeHttpGetArgs,
299
+ args?: EdgeHttpCallArgs,
243
300
  ): Promise<void> {
244
301
  return this._call(
302
+ ctx,
245
303
  createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, this.baseUrl), {
246
304
  ids: objectIds.join(','),
247
305
  }),
@@ -257,15 +315,17 @@ export class EdgeHttpClient {
257
315
  //
258
316
 
259
317
  public async uploadFunction(
318
+ ctx: Context,
260
319
  pathParts: { functionId?: string },
261
320
  body: UploadFunctionRequest,
262
- args?: EdgeHttpGetArgs,
321
+ args?: EdgeHttpCallArgs,
263
322
  ): Promise<UploadFunctionResponseBody> {
264
323
  const formData = new FormData();
265
324
  formData.append('name', body.name ?? '');
266
325
  formData.append('version', body.version);
267
326
  formData.append('ownerPublicKey', body.ownerPublicKey);
268
327
  formData.append('entryPoint', body.entryPoint);
328
+ body.runtime && formData.append('runtime', body.runtime);
269
329
  for (const [filename, content] of Object.entries(body.assets)) {
270
330
  formData.append(
271
331
  'assets',
@@ -275,7 +335,7 @@ export class EdgeHttpClient {
275
335
  }
276
336
 
277
337
  const path = ['functions', ...(pathParts.functionId ? [pathParts.functionId] : [])].join('/');
278
- return this._call(new URL(path, this.baseUrl), {
338
+ return this._call(ctx, new URL(path, this.baseUrl), {
279
339
  ...args,
280
340
  body: formData,
281
341
  method: 'PUT',
@@ -283,11 +343,12 @@ export class EdgeHttpClient {
283
343
  });
284
344
  }
285
345
 
286
- public async listFunctions(args?: EdgeHttpGetArgs): Promise<any> {
287
- return this._call(new URL('/functions', this.baseUrl), { ...args, method: 'GET' });
346
+ public async listFunctions(ctx: Context, args?: EdgeHttpCallArgs): Promise<any> {
347
+ return this._call(ctx, new URL('/functions', this.baseUrl), { ...args, method: 'GET' });
288
348
  }
289
349
 
290
350
  public async invokeFunction(
351
+ ctx: Context,
291
352
  params: {
292
353
  functionId: string;
293
354
  version?: string;
@@ -296,7 +357,7 @@ export class EdgeHttpClient {
296
357
  subrequestsLimit?: number;
297
358
  },
298
359
  input: unknown,
299
- args?: EdgeHttpGetArgs,
360
+ args?: EdgeHttpCallArgs,
300
361
  ): Promise<any> {
301
362
  const url = new URL(`/functions/${params.functionId}`, this.baseUrl);
302
363
  if (params.version) {
@@ -312,11 +373,10 @@ export class EdgeHttpClient {
312
373
  url.searchParams.set('subrequestsLimit', params.subrequestsLimit.toString());
313
374
  }
314
375
 
315
- return this._call(url, {
376
+ return this._call(ctx, url, {
316
377
  ...args,
317
378
  body: input,
318
379
  method: 'POST',
319
- rawResponse: true,
320
380
  });
321
381
  }
322
382
 
@@ -325,12 +385,13 @@ export class EdgeHttpClient {
325
385
  //
326
386
 
327
387
  public async executeWorkflow(
388
+ ctx: Context,
328
389
  spaceId: SpaceId,
329
390
  graphId: ObjectId,
330
391
  input: any,
331
- args?: EdgeHttpGetArgs,
392
+ args?: EdgeHttpCallArgs,
332
393
  ): Promise<ExecuteWorkflowResponseBody> {
333
- return this._call(new URL(`/workflows/${spaceId}/${graphId}`, this.baseUrl), {
394
+ return this._call(ctx, new URL(`/workflows/${spaceId}/${graphId}`, this.baseUrl), {
334
395
  ...args,
335
396
  body: input,
336
397
  method: 'POST',
@@ -341,8 +402,62 @@ export class EdgeHttpClient {
341
402
  // Triggers
342
403
  //
343
404
 
344
- public async getCronTriggers(spaceId: SpaceId) {
345
- return this._call(new URL(`/test/functions/${spaceId}/triggers/crons`, this.baseUrl), { method: 'GET' });
405
+ public async getCronTriggers(ctx: Context, spaceId: SpaceId): Promise<GetCronTriggersResponse> {
406
+ return this._call<GetCronTriggersResponse>(ctx, new URL(`/functions/${spaceId}/triggers/crons`, this.baseUrl), {
407
+ method: 'GET',
408
+ });
409
+ }
410
+
411
+ public async forceRunCronTrigger(ctx: Context, spaceId: SpaceId, triggerId: ObjectId) {
412
+ return this._call(ctx, new URL(`/functions/${spaceId}/triggers/crons/${triggerId}/run`, this.baseUrl), {
413
+ method: 'POST',
414
+ });
415
+ }
416
+
417
+ //
418
+ // Query
419
+ //
420
+
421
+ /**
422
+ * Execute a QueryAST query against a space.
423
+ */
424
+ public async execQuery(
425
+ ctx: Context,
426
+ spaceId: SpaceId,
427
+ body: QueryRequestProto,
428
+ args?: EdgeHttpCallArgs,
429
+ ): Promise<QueryResponseProto> {
430
+ return this._call(ctx, new URL(`/spaces/${spaceId}/exec-query`, this.baseUrl), { ...args, body, method: 'POST' });
431
+ }
432
+
433
+ //
434
+ // Registry
435
+ //
436
+
437
+ /**
438
+ * Fetches the hydrated plugin directory from the Edge registry service.
439
+ * Unauthenticated; safe to call without an identity.
440
+ */
441
+ public async getRegistryPlugins(ctx: Context, args?: EdgeHttpCallArgs): Promise<GetPluginsResponseBody> {
442
+ return this._call(ctx, new URL('/registry/plugins', this.baseUrl), { ...args, method: 'GET' });
443
+ }
444
+
445
+ /**
446
+ * Fetches the available release versions for a given plugin repo. `repo` is the
447
+ * GitHub `owner/name` form; this method takes care of URL-encoding before issuing
448
+ * the request. Unauthenticated; same surface area as {@link getRegistryPlugins}.
449
+ *
450
+ * Versions are returned newest first, suitable for direct rendering in a picker.
451
+ */
452
+ public async getRegistryPluginVersions(
453
+ ctx: Context,
454
+ repo: string,
455
+ args?: EdgeHttpCallArgs,
456
+ ): Promise<GetPluginVersionsResponseBody> {
457
+ return this._call(ctx, new URL(`/registry/plugins/${encodeURIComponent(repo)}/versions`, this.baseUrl), {
458
+ ...args,
459
+ method: 'GET',
460
+ });
346
461
  }
347
462
 
348
463
  //
@@ -350,30 +465,91 @@ export class EdgeHttpClient {
350
465
  //
351
466
 
352
467
  public async importBundle(
353
- spaceId: SpaceId, //
468
+ ctx: Context,
469
+ spaceId: SpaceId,
354
470
  body: ImportBundleRequest,
355
- args?: EdgeHttpGetArgs,
471
+ args?: EdgeHttpCallArgs,
356
472
  ): Promise<void> {
357
- return this._call(new URL(`/spaces/${spaceId}/import`, this.baseUrl), { ...args, body, method: 'PUT' });
473
+ return this._call(ctx, new URL(`/spaces/${spaceId}/import`, this.baseUrl), { ...args, body, method: 'PUT' });
358
474
  }
359
475
 
360
476
  public async exportBundle(
477
+ ctx: Context,
361
478
  spaceId: SpaceId,
362
479
  body: ExportBundleRequest,
363
- args?: EdgeHttpGetArgs,
480
+ args?: EdgeHttpCallArgs,
364
481
  ): Promise<ExportBundleResponse> {
365
- return this._call(new URL(`/spaces/${spaceId}/export`, this.baseUrl), {
482
+ return this._call(ctx, new URL(`/spaces/${spaceId}/export`, this.baseUrl), {
366
483
  ...args,
367
484
  body,
368
485
  method: 'POST',
369
486
  });
370
487
  }
371
488
 
489
+ //
490
+ // Integration proxy.
491
+ //
492
+
493
+ /**
494
+ * Fetch through the edge proxy, used by integration plugins (Discord, ...)
495
+ * to call third-party REST APIs that don't set permissive CORS headers.
496
+ *
497
+ * `init.headers.Authorization` (caller-supplied) is preserved by prefixing
498
+ * with `X-Cors-Proxy-Authorization`, since the proxy strips `Authorization`
499
+ * on forwarding to avoid leaking the DXOS presentation upstream — the
500
+ * prefix carries the upstream's bot token / token through.
501
+ *
502
+ * TEMPORARY: routed through the legacy standalone proxy at
503
+ * `cors-proxy.dxos.workers.dev` (open, unauthenticated, path
504
+ * `/<host>/<path>`) so that integration plugins can be tested before the
505
+ * authenticated `/proxy/*` route on the main edge worker ships
506
+ * (https://github.com/dxos/edge/pull/576). When that PR deploys, restore
507
+ * the commented-out block below — it rewrites the target under
508
+ * `${this.baseUrl}/proxy/...` and signs the request with the cached
509
+ * verifiable presentation. The header-remap and `x-cors-proxy-*` override
510
+ * conventions are unchanged between the two paths.
511
+ */
512
+ public async proxyFetch(target: URL, init: RequestInit = {}): Promise<Response> {
513
+ return proxyFetchLegacy(target, init, this._clientTag);
514
+
515
+ //
516
+ // Restore once the authenticated route on the main edge worker is deployed:
517
+ //
518
+ // const proxyUrl = new URL(`/proxy/${target.host}${target.pathname}${target.search}`, this.baseUrl);
519
+ // if (target.protocol === 'http:') {
520
+ // proxyUrl.searchParams.set('scheme', 'http');
521
+ // }
522
+ // const headers = remapAuthorizationForProxy(new Headers(init.headers ?? undefined));
523
+ // let handledAuth = false;
524
+ // while (true) {
525
+ // if (!this._authHeader) {
526
+ // const authResponse = await fetch(new URL('/auth', this.baseUrl));
527
+ // if (authResponse.status === 401) {
528
+ // this._authHeader = await this._handleUnauthorized(authResponse);
529
+ // }
530
+ // }
531
+ // const requestHeaders = new Headers(headers);
532
+ // if (this._authHeader) {
533
+ // requestHeaders.set('Authorization', this._authHeader);
534
+ // }
535
+ // if (this._clientTag) {
536
+ // requestHeaders.set(EDGE_CLIENT_TAG_HEADER, this._clientTag);
537
+ // }
538
+ // const response = await fetch(proxyUrl, { ...init, headers: requestHeaders });
539
+ // if (response.status === 401 && !handledAuth) {
540
+ // this._authHeader = await this._handleUnauthorized(response);
541
+ // handledAuth = true;
542
+ // continue;
543
+ // }
544
+ // return response;
545
+ // }
546
+ }
547
+
372
548
  //
373
549
  // Internal
374
550
  //
375
551
 
376
- private async _fetch<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
552
+ private async _fetch<T>(url: URL, _args: EdgeHttpRequestArgs): Promise<T> {
377
553
  return Function.pipe(
378
554
  HttpClient.get(url),
379
555
  withLogging,
@@ -381,58 +557,67 @@ export class EdgeHttpClient {
381
557
  Effect.provide(FetchHttpClient.layer),
382
558
  Effect.provide(HttpConfig.default),
383
559
  Effect.withSpan('EdgeHttpClient'),
384
- Effect.runPromise,
560
+ runAndForwardErrors,
385
561
  ) as T;
386
562
  }
387
563
 
388
564
  // TODO(burdon): Refactor with effect (see edge-http-client.test.ts).
389
- private async _call<T>(url: URL, args: EdgeHttpRequestArgs): Promise<T> {
565
+ private async _call<T>(ctx: Context, url: URL, args: EdgeHttpRequestArgs): Promise<T> {
390
566
  const shouldRetry = createRetryHandler(args);
391
- const requestContext = args.context ?? new Context();
392
567
  log('fetch', { url, request: args.body });
393
568
 
569
+ const traceHeaders = getTraceHeaders(ctx);
570
+
394
571
  let handledAuth = false;
572
+ const tryCount = 1;
395
573
  while (true) {
396
574
  let processingError: EdgeCallFailedError | undefined = undefined;
397
- let retryAfterHeaderValue: number = Number.NaN;
398
575
  try {
399
- const request = createRequest(args, this._authHeader);
400
- const response = await fetch(url, request);
401
- retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
402
- if (response.ok) {
403
- const body = (await response.json()) as EdgeHttpResponse<T>;
404
-
405
- if (args.rawResponse) {
406
- return body as any;
576
+ if (!this._authHeader && args.auth) {
577
+ const response = await fetch(new URL(`/auth`, this.baseUrl));
578
+ if (response.status === 401) {
579
+ this._authHeader = await this._handleUnauthorized(response);
407
580
  }
581
+ }
408
582
 
583
+ const request = createRequest(args, this._authHeader, traceHeaders, this._clientTag);
584
+ log('call edge', { url, tryCount, authHeader: !!this._authHeader });
585
+ const response = await fetch(url, request);
586
+
587
+ if (response.ok) {
588
+ const body = await response.clone().json();
589
+ invariant(body, 'Expected body to be present');
409
590
  if (!('success' in body)) {
410
591
  return body;
411
592
  }
412
-
413
593
  if (body.success) {
414
594
  return body.data;
415
595
  }
416
-
417
- log.warn('unsuccessful edge response', { url, body });
418
- if (body.errorData?.type === 'auth_challenge' && typeof body.errorData?.challenge === 'string') {
419
- processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
420
- } else if (body.errorData) {
421
- processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
422
- }
423
596
  } else if (response.status === 401 && !handledAuth) {
424
597
  this._authHeader = await this._handleUnauthorized(response);
425
598
  handledAuth = true;
426
599
  continue;
600
+ }
601
+
602
+ const body: EdgeFailure =
603
+ response.headers.get('Content-Type') === 'application/json' ? await response.clone().json() : undefined;
604
+
605
+ invariant(!body?.success, 'Expected body to not be a failure response or undefined.');
606
+
607
+ if (body?.data?.type === 'auth_challenge' && typeof body?.data?.challenge === 'string') {
608
+ processingError = new EdgeAuthChallengeError(body.data.challenge, body.data);
609
+ } else if (body?.success === false) {
610
+ processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
427
611
  } else {
612
+ invariant(!response.ok, 'Expected response to not be ok.');
428
613
  processingError = await EdgeCallFailedError.fromHttpFailure(response);
429
614
  }
430
615
  } catch (error: any) {
431
616
  processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
432
617
  }
433
618
 
434
- if (processingError?.isRetryable && (await shouldRetry(requestContext, retryAfterHeaderValue))) {
435
- log('retrying edge request', { url, processingError });
619
+ if (processingError?.isRetryable && (await shouldRetry(ctx, processingError.retryAfterMs))) {
620
+ log.verbose('retrying edge request', { url, processingError });
436
621
  } else {
437
622
  throw processingError!;
438
623
  }
@@ -453,6 +638,8 @@ export class EdgeHttpClient {
453
638
  const createRequest = (
454
639
  { method, body, json = true }: EdgeHttpRequestArgs,
455
640
  authHeader: string | undefined,
641
+ traceHeaders?: Record<string, string>,
642
+ clientTag?: string,
456
643
  ): RequestInit => {
457
644
  let requestBody: BodyInit | undefined;
458
645
  const headers: HeadersInit = {};
@@ -472,6 +659,14 @@ const createRequest = (
472
659
  headers['Authorization'] = authHeader;
473
660
  }
474
661
 
662
+ if (traceHeaders) {
663
+ Object.assign(headers, traceHeaders);
664
+ }
665
+
666
+ if (clientTag) {
667
+ headers[EDGE_CLIENT_TAG_HEADER] = clientTag;
668
+ }
669
+
475
670
  return {
476
671
  method,
477
672
  body: requestBody,
@@ -479,6 +674,22 @@ const createRequest = (
479
674
  };
480
675
  };
481
676
 
677
+ /**
678
+ * Extract W3C Trace Context headers (traceparent/tracestate) from a DXOS Context.
679
+ */
680
+ const getTraceHeaders = (ctx: Context): Record<string, string> | undefined => {
681
+ const traceCtx = ctx.getAttribute(TRACE_SPAN_ATTRIBUTE) as TraceContextData | undefined;
682
+ if (!traceCtx) {
683
+ return undefined;
684
+ }
685
+
686
+ const headers: Record<string, string> = { traceparent: traceCtx.traceparent };
687
+ if (traceCtx.tracestate) {
688
+ headers.tracestate = traceCtx.tracestate;
689
+ }
690
+ return headers;
691
+ };
692
+
482
693
  /**
483
694
  * @deprecated
484
695
  */
@@ -491,7 +702,7 @@ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
491
702
  const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
492
703
  const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
493
704
  const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
494
- return async (ctx: Context, retryAfter: number) => {
705
+ return async (ctx: Context, retryAfter?: number) => {
495
706
  if (++retries > maxRetries || ctx.disposed) {
496
707
  return false;
497
708
  }
@@ -513,3 +724,45 @@ const getFileMimeType = (filename: string) =>
513
724
  : filename.endsWith('.wasm')
514
725
  ? 'application/wasm'
515
726
  : 'application/octet-stream';
727
+
728
+ /**
729
+ * Move any caller-supplied `Authorization` header to `X-Cors-Proxy-Authorization`
730
+ * so it survives the proxy hop. The edge proxy strips the top-level
731
+ * `Authorization` (it carries the DXOS presentation, never to be leaked
732
+ * upstream) and applies any `x-cors-proxy-*` override prefix as the actual
733
+ * upstream header — which is exactly the channel we want for forwarding bot
734
+ * tokens, OAuth tokens, etc.
735
+ */
736
+ const remapAuthorizationForProxy = (headers: Headers): Headers => {
737
+ const callerAuth = headers.get('Authorization');
738
+ if (callerAuth !== null) {
739
+ headers.delete('Authorization');
740
+ headers.set('X-Cors-Proxy-Authorization', callerAuth);
741
+ }
742
+ return headers;
743
+ };
744
+
745
+ /**
746
+ * Fetch through the legacy standalone open proxy at `cors-proxy.dxos.workers.dev`.
747
+ *
748
+ * No DXOS auth, no `EdgeHttpClient` instance required — pure URL rewrite +
749
+ * header remap + `fetch`. Used by integration plugins from contexts that
750
+ * don't have an `EdgeHttpClient` in scope (e.g. plugin-integration's
751
+ * `credentialForm.onSubmit` and `onTokenCreated`, which run inside the
752
+ * coordinator's runtime that does not provide `Capability.Service`).
753
+ *
754
+ * TEMPORARY — see `LEGACY_CORS_PROXY_URL`. When the authenticated `/proxy/*`
755
+ * route on edge ships (https://github.com/dxos/edge/pull/576), delete this
756
+ * function and route everything through `EdgeHttpClient.proxyFetch` again.
757
+ */
758
+ export const proxyFetchLegacy = (target: URL, init: RequestInit = {}, clientTag?: string): Promise<Response> => {
759
+ const proxyUrl = new URL(`/${target.host}${target.pathname}${target.search}`, LEGACY_CORS_PROXY_URL);
760
+ if (target.protocol === 'http:') {
761
+ proxyUrl.searchParams.set('scheme', 'http');
762
+ }
763
+ const requestHeaders = remapAuthorizationForProxy(new Headers(init.headers ?? undefined));
764
+ if (clientTag) {
765
+ requestHeaders.set(EDGE_CLIENT_TAG_HEADER, clientTag);
766
+ }
767
+ return fetch(proxyUrl, { ...init, headers: requestHeaders });
768
+ };