@dxos/edge-client 0.8.3 → 0.8.4-main.05e74ebcff

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 (75) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/{browser/chunk-VHS3XEIX.mjs → neutral/chunk-ZIQ5T3A7.mjs} +20 -50
  3. package/dist/lib/{browser/chunk-VHS3XEIX.mjs.map → neutral/chunk-ZIQ5T3A7.mjs.map} +3 -3
  4. package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs +1 -1
  5. package/dist/lib/neutral/index.mjs +1238 -0
  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 +53 -33
  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 +18 -15
  12. package/dist/types/src/edge-client.d.ts.map +1 -1
  13. package/dist/types/src/edge-http-client.d.ts +132 -37
  14. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  15. package/dist/types/src/edge-http-client.test.d.ts +2 -0
  16. package/dist/types/src/edge-http-client.test.d.ts.map +1 -0
  17. package/dist/types/src/edge-identity.d.ts.map +1 -1
  18. package/dist/types/src/edge-ws-connection.d.ts +21 -0
  19. package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
  20. package/dist/types/src/edge-ws-muxer.d.ts.map +1 -1
  21. package/dist/types/src/errors.d.ts.map +1 -1
  22. package/dist/types/src/http-client.d.ts +25 -0
  23. package/dist/types/src/http-client.d.ts.map +1 -0
  24. package/dist/types/src/http-client.test.d.ts +2 -0
  25. package/dist/types/src/http-client.test.d.ts.map +1 -0
  26. package/dist/types/src/index.d.ts +4 -3
  27. package/dist/types/src/index.d.ts.map +1 -1
  28. package/dist/types/src/protocol.d.ts +1 -1
  29. package/dist/types/src/protocol.d.ts.map +1 -1
  30. package/dist/types/src/testing/index.d.ts +1 -0
  31. package/dist/types/src/testing/index.d.ts.map +1 -1
  32. package/dist/types/src/testing/test-server.d.ts +9 -0
  33. package/dist/types/src/testing/test-server.d.ts.map +1 -0
  34. package/dist/types/src/testing/test-utils.d.ts +3 -3
  35. package/dist/types/src/testing/test-utils.d.ts.map +1 -1
  36. package/dist/types/src/utils.d.ts +1 -1
  37. package/dist/types/src/utils.d.ts.map +1 -1
  38. package/dist/types/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +34 -30
  40. package/src/edge-client.test.ts +20 -15
  41. package/src/edge-client.ts +90 -43
  42. package/src/edge-http-client.test.ts +23 -0
  43. package/src/edge-http-client.ts +606 -161
  44. package/src/edge-ws-connection.ts +131 -9
  45. package/src/edge-ws-muxer.ts +1 -1
  46. package/src/http-client.test.ts +58 -0
  47. package/src/http-client.ts +77 -0
  48. package/src/index.ts +4 -3
  49. package/src/testing/index.ts +1 -0
  50. package/src/testing/test-server.ts +45 -0
  51. package/src/testing/test-utils.ts +9 -9
  52. package/src/websocket.test.ts +1 -1
  53. package/dist/lib/browser/index.mjs +0 -1034
  54. package/dist/lib/browser/index.mjs.map +0 -7
  55. package/dist/lib/browser/meta.json +0 -1
  56. package/dist/lib/browser/testing/index.mjs.map +0 -7
  57. package/dist/lib/node/chunk-XNHBUTNB.cjs +0 -317
  58. package/dist/lib/node/chunk-XNHBUTNB.cjs.map +0 -7
  59. package/dist/lib/node/edge-ws-muxer.cjs +0 -33
  60. package/dist/lib/node/edge-ws-muxer.cjs.map +0 -7
  61. package/dist/lib/node/index.cjs +0 -1060
  62. package/dist/lib/node/index.cjs.map +0 -7
  63. package/dist/lib/node/meta.json +0 -1
  64. package/dist/lib/node/testing/index.cjs +0 -169
  65. package/dist/lib/node/testing/index.cjs.map +0 -7
  66. package/dist/lib/node-esm/chunk-HGQUUFIJ.mjs +0 -299
  67. package/dist/lib/node-esm/chunk-HGQUUFIJ.mjs.map +0 -7
  68. package/dist/lib/node-esm/edge-ws-muxer.mjs +0 -12
  69. package/dist/lib/node-esm/edge-ws-muxer.mjs.map +0 -7
  70. package/dist/lib/node-esm/index.mjs +0 -1035
  71. package/dist/lib/node-esm/index.mjs.map +0 -7
  72. package/dist/lib/node-esm/meta.json +0 -1
  73. package/dist/lib/node-esm/testing/index.mjs +0 -141
  74. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  75. /package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs.map +0 -0
@@ -2,44 +2,128 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
6
+ import * as HttpClient from '@effect/platform/HttpClient';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Function from 'effect/Function';
9
+
5
10
  import { sleep } from '@dxos/async';
6
- 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';
7
14
  import { type PublicKey, type SpaceId } from '@dxos/keys';
8
15
  import { log } from '@dxos/log';
9
16
  import {
17
+ type CreateAgentRequestBody,
18
+ type CreateAgentResponseBody,
19
+ type CreateSpaceRequest,
20
+ type CreateSpaceResponseBody,
21
+ EDGE_CLIENT_TAG_HEADER,
22
+ EdgeAuthChallengeError,
10
23
  EdgeCallFailedError,
11
- type EdgeHttpResponse,
24
+ type EdgeFailure,
25
+ type EdgeStatus,
26
+ type ExecuteWorkflowResponseBody,
27
+ type ExportBundleRequest,
28
+ type ExportBundleResponse,
29
+ type FeedProtocol,
30
+ type GetAgentStatusResponseBody,
31
+ type GetPluginVersionsResponseBody,
32
+ type GetPluginsResponseBody,
12
33
  type GetNotarizationResponseBody,
13
- type PostNotarizationRequestBody,
34
+ type ImportBundleRequest,
35
+ type InitiateOAuthFlowRequest,
36
+ type InitiateOAuthFlowResponse,
14
37
  type JoinSpaceRequest,
15
38
  type JoinSpaceResponseBody,
16
- EdgeAuthChallengeError,
17
- type CreateAgentResponseBody,
18
- type CreateAgentRequestBody,
19
- type GetAgentStatusResponseBody,
39
+ type ObjectId,
40
+ type PostNotarizationRequestBody,
20
41
  type RecoverIdentityRequest,
21
42
  type RecoverIdentityResponseBody,
22
43
  type UploadFunctionRequest,
23
44
  type UploadFunctionResponseBody,
24
- type ObjectId,
25
- type ExecuteWorkflowResponseBody,
26
- type QueueQuery,
27
- type QueryResult,
28
- type InitiateOAuthFlowRequest,
29
- type InitiateOAuthFlowResponse,
30
- type CreateSpaceRequest,
31
- type CreateSpaceResponseBody,
32
45
  } from '@dxos/protocols';
46
+ import {
47
+ type QueryRequest as QueryRequestProto,
48
+ type QueryResponse as QueryResponseProto,
49
+ } from '@dxos/protocols/proto/dxos/echo/query';
50
+ import { createUrl } from '@dxos/util';
33
51
 
34
52
  import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
53
+ import { HttpConfig, encodeAuthHeader, withLogging, withRetryConfig } from './http-client';
35
54
  import { getEdgeUrlWithProtocol } from './utils';
36
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
+
37
67
  const DEFAULT_RETRY_TIMEOUT = 1500;
38
68
  const DEFAULT_RETRY_JITTER = 500;
39
69
  const DEFAULT_MAX_RETRIES_COUNT = 3;
70
+ const WARNING_BODY_SIZE = 10 * 1024 * 1024; // 10MB
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
+
78
+ export type RetryConfig = {
79
+ /**
80
+ * A number of call retries, not counting the initial request.
81
+ */
82
+ count: number;
83
+ /**
84
+ * Delay before retries in ms.
85
+ */
86
+ timeout?: number;
87
+ /**
88
+ * A random amount of time before retrying to help prevent large bursts of requests.
89
+ */
90
+ jitter?: number;
91
+ };
92
+
93
+ type EdgeHttpRequestArgs = {
94
+ method: string;
95
+ retry?: RetryConfig;
96
+ body?: any;
97
+ /**
98
+ * @default true
99
+ */
100
+ json?: boolean;
101
+
102
+ /**
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.
106
+ */
107
+ auth?: boolean;
108
+ };
109
+
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
+ };
40
123
 
41
124
  export class EdgeHttpClient {
42
125
  private readonly _baseUrl: string;
126
+ private readonly _clientTag: string | undefined;
43
127
 
44
128
  private _edgeIdentity: EdgeIdentity | undefined;
45
129
 
@@ -48,8 +132,9 @@ export class EdgeHttpClient {
48
132
  */
49
133
  private _authHeader: string | undefined;
50
134
 
51
- constructor(baseUrl: string) {
135
+ constructor(baseUrl: string, options?: EdgeHttpClientOptions) {
52
136
  this._baseUrl = getEdgeUrlWithProtocol(baseUrl, 'http');
137
+ this._clientTag = options?.clientTag;
53
138
  log('created', { url: this._baseUrl });
54
139
  }
55
140
 
@@ -64,209 +149,560 @@ export class EdgeHttpClient {
64
149
  }
65
150
  }
66
151
 
67
- public createAgent(body: CreateAgentRequestBody, args?: EdgeHttpGetArgs): Promise<CreateAgentResponseBody> {
68
- return this._call('/agents/create', { ...args, method: 'POST', body });
152
+ //
153
+ // Status
154
+ //
155
+
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 });
158
+ }
159
+
160
+ //
161
+ // Agents
162
+ //
163
+
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 });
69
170
  }
70
171
 
71
172
  public getAgentStatus(
173
+ ctx: Context,
72
174
  request: { ownerIdentityKey: PublicKey },
73
- args?: EdgeHttpGetArgs,
175
+ args?: EdgeHttpCallArgs,
74
176
  ): Promise<GetAgentStatusResponseBody> {
75
- return this._call(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, { ...args, method: 'GET' });
177
+ return this._call(ctx, new URL(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, this.baseUrl), {
178
+ ...args,
179
+ method: 'GET',
180
+ });
76
181
  }
77
182
 
78
- public getCredentialsForNotarization(spaceId: SpaceId, args?: EdgeHttpGetArgs): Promise<GetNotarizationResponseBody> {
79
- return this._call(`/spaces/${spaceId}/notarization`, { ...args, method: 'GET' });
183
+ //
184
+ // Credentials
185
+ //
186
+
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' });
80
193
  }
81
194
 
82
195
  public async notarizeCredentials(
196
+ ctx: Context,
83
197
  spaceId: SpaceId,
84
198
  body: PostNotarizationRequestBody,
85
- args?: EdgeHttpGetArgs,
199
+ args?: EdgeHttpCallArgs,
86
200
  ): Promise<void> {
87
- await this._call(`/spaces/${spaceId}/notarization`, { ...args, body, method: 'POST' });
201
+ await this._call(ctx, new URL(`/spaces/${spaceId}/notarization`, this.baseUrl), { ...args, body, method: 'POST' });
88
202
  }
89
203
 
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
- }
204
+ //
205
+ // Identity
206
+ //
97
207
 
98
208
  public async recoverIdentity(
209
+ ctx: Context,
99
210
  body: RecoverIdentityRequest,
100
- args?: EdgeHttpGetArgs,
211
+ args?: EdgeHttpCallArgs,
101
212
  ): Promise<RecoverIdentityResponseBody> {
102
- return this._call('/identity/recover', { ...args, body, method: 'POST' });
213
+ return this._call(ctx, new URL('/identity/recover', this.baseUrl), { ...args, body, method: 'POST' });
103
214
  }
104
215
 
105
- public async executeWorkflow(
216
+ //
217
+ // Invitations
218
+ //
219
+
220
+ public async joinSpaceByInvitation(
221
+ ctx: Context,
106
222
  spaceId: SpaceId,
107
- graphId: ObjectId,
108
- input: any,
109
- args?: EdgeHttpGetArgs,
110
- ): Promise<ExecuteWorkflowResponseBody> {
111
- return this._call(`/workflows/${spaceId}/${graphId}`, { ...args, body: input, method: 'POST' });
223
+ body: JoinSpaceRequest,
224
+ args?: EdgeHttpCallArgs,
225
+ ): Promise<JoinSpaceResponseBody> {
226
+ return this._call(ctx, new URL(`/spaces/${spaceId}/join`, this.baseUrl), { ...args, body, method: 'POST' });
112
227
  }
113
228
 
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
- }
229
+ //
230
+ // OAuth and credentials
231
+ //
122
232
 
123
233
  public async initiateOAuthFlow(
234
+ ctx: Context,
124
235
  body: InitiateOAuthFlowRequest,
125
- args?: EdgeHttpGetArgs,
236
+ args?: EdgeHttpCallArgs,
126
237
  ): Promise<InitiateOAuthFlowResponse> {
127
- return this._call('/oauth/initiate', { ...args, body, method: 'POST' });
238
+ return this._call(ctx, new URL('/oauth/initiate', this.baseUrl), { ...args, body, method: 'POST' });
239
+ }
240
+
241
+ //
242
+ // Spaces
243
+ //
244
+
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' });
128
247
  }
129
248
 
249
+ //
250
+ // Queues
251
+ //
252
+
130
253
  public async queryQueue(
254
+ ctx: Context,
131
255
  subspaceTag: string,
132
256
  spaceId: SpaceId,
133
- query: QueueQuery,
134
- args?: EdgeHttpGetArgs,
135
- ): Promise<QueryResult> {
136
- 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
- });
257
+ query: FeedProtocol.QueueQuery,
258
+ args?: EdgeHttpCallArgs,
259
+ ): Promise<EdgeQueryQueueResponse> {
260
+ const queueId = query.queueIds?.[0];
261
+ invariant(queueId, 'queueId required');
262
+ return this._call(
263
+ ctx,
264
+ createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}/query`, this.baseUrl), {
265
+ after: query.after,
266
+ before: query.before,
267
+ limit: query.limit,
268
+ reverse: query.reverse,
269
+ objectIds: query.objectIds?.join(','),
270
+ }),
271
+ {
272
+ ...args,
273
+ method: 'GET',
274
+ },
275
+ );
157
276
  }
158
277
 
159
278
  public async insertIntoQueue(
279
+ ctx: Context,
160
280
  subspaceTag: string,
161
281
  spaceId: SpaceId,
162
282
  queueId: ObjectId,
163
283
  objects: unknown[],
164
- args?: EdgeHttpGetArgs,
284
+ args?: EdgeHttpCallArgs,
165
285
  ): Promise<void> {
166
- return this._call(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, {
286
+ return this._call(ctx, new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, this.baseUrl), {
167
287
  ...args,
168
288
  body: { objects },
169
289
  method: 'POST',
170
290
  });
171
291
  }
172
292
 
173
- async deleteFromQueue(
293
+ public async deleteFromQueue(
294
+ ctx: Context,
174
295
  subspaceTag: string,
175
296
  spaceId: SpaceId,
176
297
  queueId: ObjectId,
177
298
  objectIds: ObjectId[],
178
- args?: EdgeHttpGetArgs,
299
+ args?: EdgeHttpCallArgs,
179
300
  ): Promise<void> {
180
- return this._call(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, {
301
+ return this._call(
302
+ ctx,
303
+ createUrl(new URL(`/spaces/${subspaceTag}/${spaceId}/queue/${queueId}`, this.baseUrl), {
304
+ ids: objectIds.join(','),
305
+ }),
306
+ {
307
+ ...args,
308
+ method: 'DELETE',
309
+ },
310
+ );
311
+ }
312
+
313
+ //
314
+ // Functions
315
+ //
316
+
317
+ public async uploadFunction(
318
+ ctx: Context,
319
+ pathParts: { functionId?: string },
320
+ body: UploadFunctionRequest,
321
+ args?: EdgeHttpCallArgs,
322
+ ): Promise<UploadFunctionResponseBody> {
323
+ const formData = new FormData();
324
+ formData.append('name', body.name ?? '');
325
+ formData.append('version', body.version);
326
+ formData.append('ownerPublicKey', body.ownerPublicKey);
327
+ formData.append('entryPoint', body.entryPoint);
328
+ body.runtime && formData.append('runtime', body.runtime);
329
+ for (const [filename, content] of Object.entries(body.assets)) {
330
+ formData.append(
331
+ 'assets',
332
+ new Blob([content as Uint8Array<ArrayBuffer>], { type: getFileMimeType(filename) }),
333
+ filename,
334
+ );
335
+ }
336
+
337
+ const path = ['functions', ...(pathParts.functionId ? [pathParts.functionId] : [])].join('/');
338
+ return this._call(ctx, new URL(path, this.baseUrl), {
181
339
  ...args,
182
- query: { ids: objectIds.join(',') },
183
- method: 'DELETE',
340
+ body: formData,
341
+ method: 'PUT',
342
+ json: false,
184
343
  });
185
344
  }
186
345
 
187
- async createSpace(body: CreateSpaceRequest, args?: EdgeHttpGetArgs): Promise<CreateSpaceResponseBody> {
188
- return this._call('/spaces/create', { ...args, body, method: 'POST' });
346
+ public async listFunctions(ctx: Context, args?: EdgeHttpCallArgs): Promise<any> {
347
+ return this._call(ctx, new URL('/functions', this.baseUrl), { ...args, method: 'GET' });
189
348
  }
190
349
 
191
- private async _call<T>(path: string, args: EdgeHttpCallArgs): Promise<T> {
192
- const requestContext = args.context ?? new Context();
193
- 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()}`;
350
+ public async invokeFunction(
351
+ ctx: Context,
352
+ params: {
353
+ functionId: string;
354
+ version?: string;
355
+ spaceId?: SpaceId;
356
+ cpuTimeLimit?: number;
357
+ subrequestsLimit?: number;
358
+ },
359
+ input: unknown,
360
+ args?: EdgeHttpCallArgs,
361
+ ): Promise<any> {
362
+ const url = new URL(`/functions/${params.functionId}`, this.baseUrl);
363
+ if (params.version) {
364
+ url.searchParams.set('version', params.version);
202
365
  }
366
+ if (params.spaceId) {
367
+ url.searchParams.set('spaceId', params.spaceId.toString());
368
+ }
369
+ if (params.cpuTimeLimit) {
370
+ url.searchParams.set('cpuTimeLimit', params.cpuTimeLimit.toString());
371
+ }
372
+ if (params.subrequestsLimit) {
373
+ url.searchParams.set('subrequestsLimit', params.subrequestsLimit.toString());
374
+ }
375
+
376
+ return this._call(ctx, url, {
377
+ ...args,
378
+ body: input,
379
+ method: 'POST',
380
+ });
381
+ }
382
+
383
+ //
384
+ // Workflows
385
+ //
386
+
387
+ public async executeWorkflow(
388
+ ctx: Context,
389
+ spaceId: SpaceId,
390
+ graphId: ObjectId,
391
+ input: any,
392
+ args?: EdgeHttpCallArgs,
393
+ ): Promise<ExecuteWorkflowResponseBody> {
394
+ return this._call(ctx, new URL(`/workflows/${spaceId}/${graphId}`, this.baseUrl), {
395
+ ...args,
396
+ body: input,
397
+ method: 'POST',
398
+ });
399
+ }
400
+
401
+ //
402
+ // Triggers
403
+ //
404
+
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
+ });
461
+ }
462
+
463
+ //
464
+ // Import/Export space.
465
+ //
466
+
467
+ public async importBundle(
468
+ ctx: Context,
469
+ spaceId: SpaceId,
470
+ body: ImportBundleRequest,
471
+ args?: EdgeHttpCallArgs,
472
+ ): Promise<void> {
473
+ return this._call(ctx, new URL(`/spaces/${spaceId}/import`, this.baseUrl), { ...args, body, method: 'PUT' });
474
+ }
475
+
476
+ public async exportBundle(
477
+ ctx: Context,
478
+ spaceId: SpaceId,
479
+ body: ExportBundleRequest,
480
+ args?: EdgeHttpCallArgs,
481
+ ): Promise<ExportBundleResponse> {
482
+ return this._call(ctx, new URL(`/spaces/${spaceId}/export`, this.baseUrl), {
483
+ ...args,
484
+ body,
485
+ method: 'POST',
486
+ });
487
+ }
203
488
 
204
- log('call', { method: args.method, path, request: args.body });
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
+
548
+ //
549
+ // Internal
550
+ //
551
+
552
+ private async _fetch<T>(url: URL, _args: EdgeHttpRequestArgs): Promise<T> {
553
+ return Function.pipe(
554
+ HttpClient.get(url),
555
+ withLogging,
556
+ withRetryConfig,
557
+ Effect.provide(FetchHttpClient.layer),
558
+ Effect.provide(HttpConfig.default),
559
+ Effect.withSpan('EdgeHttpClient'),
560
+ runAndForwardErrors,
561
+ ) as T;
562
+ }
563
+
564
+ // TODO(burdon): Refactor with effect (see edge-http-client.test.ts).
565
+ private async _call<T>(ctx: Context, url: URL, args: EdgeHttpRequestArgs): Promise<T> {
566
+ const shouldRetry = createRetryHandler(args);
567
+ log('fetch', { url, request: args.body });
568
+
569
+ const traceHeaders = getTraceHeaders(ctx);
205
570
 
206
571
  let handledAuth = false;
207
- let authHeader = this._authHeader;
572
+ const tryCount = 1;
208
573
  while (true) {
209
- let processingError: EdgeCallFailedError;
210
- let retryAfterHeaderValue: number = Number.NaN;
574
+ let processingError: EdgeCallFailedError | undefined = undefined;
211
575
  try {
212
- const request = createRequest(args, authHeader);
213
- const response = await fetch(url, request);
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);
580
+ }
581
+ }
214
582
 
215
- retryAfterHeaderValue = Number(response.headers.get('Retry-After'));
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);
216
586
 
217
587
  if (response.ok) {
218
- const body = (await response.json()) as EdgeHttpResponse<T>;
588
+ const body = await response.clone().json();
589
+ invariant(body, 'Expected body to be present');
590
+ if (!('success' in body)) {
591
+ return body;
592
+ }
219
593
  if (body.success) {
220
594
  return body.data;
221
595
  }
222
-
223
- log('unsuccessful edge response', { path, body });
224
-
225
- if (body.errorData?.type === 'auth_challenge' && typeof body.errorData?.challenge === 'string') {
226
- processingError = new EdgeAuthChallengeError(body.errorData.challenge, body.errorData);
227
- } else {
228
- processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
229
- }
230
596
  } else if (response.status === 401 && !handledAuth) {
231
- authHeader = await this._handleUnauthorized(response);
597
+ this._authHeader = await this._handleUnauthorized(response);
232
598
  handledAuth = true;
233
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);
234
611
  } else {
235
- processingError = EdgeCallFailedError.fromHttpFailure(response);
612
+ invariant(!response.ok, 'Expected response to not be ok.');
613
+ processingError = await EdgeCallFailedError.fromHttpFailure(response);
236
614
  }
237
615
  } catch (error: any) {
238
616
  processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
239
617
  }
240
618
 
241
- if (processingError.isRetryable && (await shouldRetry(requestContext, retryAfterHeaderValue))) {
242
- log('retrying edge request', { path, processingError });
619
+ if (processingError?.isRetryable && (await shouldRetry(ctx, processingError.retryAfterMs))) {
620
+ log.verbose('retrying edge request', { url, processingError });
243
621
  } else {
244
- throw processingError;
622
+ throw processingError!;
245
623
  }
246
624
  }
247
625
  }
248
626
 
249
627
  private async _handleUnauthorized(response: Response): Promise<string> {
250
628
  if (!this._edgeIdentity) {
251
- log.warn('edge unauthorized response received before identity was set');
252
- throw EdgeCallFailedError.fromHttpFailure(response);
629
+ log.warn('unauthorized response received before identity was set');
630
+ throw await EdgeCallFailedError.fromHttpFailure(response);
253
631
  }
632
+
254
633
  const challenge = await handleAuthChallenge(response, this._edgeIdentity);
255
- this._authHeader = encodeAuthHeader(challenge);
256
- log('auth header updated');
257
- return this._authHeader;
634
+ return encodeAuthHeader(challenge);
258
635
  }
259
636
  }
260
637
 
261
- const createRetryHandler = (args: EdgeHttpCallArgs) => {
262
- if (!args.retry || args.retry.count < 1) {
638
+ const createRequest = (
639
+ { method, body, json = true }: EdgeHttpRequestArgs,
640
+ authHeader: string | undefined,
641
+ traceHeaders?: Record<string, string>,
642
+ clientTag?: string,
643
+ ): RequestInit => {
644
+ let requestBody: BodyInit | undefined;
645
+ const headers: HeadersInit = {};
646
+
647
+ if (json) {
648
+ requestBody = body && JSON.stringify(body);
649
+ headers['Content-Type'] = 'application/json';
650
+ } else {
651
+ requestBody = body;
652
+ }
653
+
654
+ if (typeof requestBody === 'string' && requestBody.length > WARNING_BODY_SIZE) {
655
+ log.warn('Request with large body', { bodySize: requestBody.length });
656
+ }
657
+
658
+ if (authHeader) {
659
+ headers['Authorization'] = authHeader;
660
+ }
661
+
662
+ if (traceHeaders) {
663
+ Object.assign(headers, traceHeaders);
664
+ }
665
+
666
+ if (clientTag) {
667
+ headers[EDGE_CLIENT_TAG_HEADER] = clientTag;
668
+ }
669
+
670
+ return {
671
+ method,
672
+ body: requestBody,
673
+ headers,
674
+ };
675
+ };
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
+
693
+ /**
694
+ * @deprecated
695
+ */
696
+ const createRetryHandler = ({ retry }: EdgeHttpRequestArgs) => {
697
+ if (!retry || retry.count < 1) {
263
698
  return async () => false;
264
699
  }
700
+
265
701
  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;
269
- return async (ctx: Context, retryAfter: number) => {
702
+ const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
703
+ const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
704
+ const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
705
+ return async (ctx: Context, retryAfter?: number) => {
270
706
  if (++retries > maxRetries || ctx.disposed) {
271
707
  return false;
272
708
  }
@@ -282,42 +718,51 @@ const createRetryHandler = (args: EdgeHttpCallArgs) => {
282
718
  };
283
719
  };
284
720
 
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
- };
721
+ const getFileMimeType = (filename: string) =>
722
+ ['.js', '.mjs'].some((codeExtension) => filename.endsWith(codeExtension))
723
+ ? 'application/javascript+module'
724
+ : filename.endsWith('.wasm')
725
+ ? 'application/wasm'
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;
318
743
  };
319
744
 
320
- const encodeAuthHeader = (challenge: Uint8Array) => {
321
- const encodedChallenge = Buffer.from(challenge).toString('base64');
322
- return `VerifiablePresentation pb;base64,${encodedChallenge}`;
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 });
323
768
  };