@dxos/edge-client 0.10.0 → 0.11.0

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 (57) hide show
  1. package/dist/lib/chunk-edge-ws-muxer.mjs +322 -0
  2. package/dist/lib/chunk-edge-ws-muxer.mjs.map +1 -0
  3. package/dist/lib/cors-proxy.mjs +29 -0
  4. package/dist/lib/cors-proxy.mjs.map +1 -0
  5. package/dist/lib/edge-ws-muxer.mjs +2 -0
  6. package/dist/lib/index.mjs +1769 -0
  7. package/dist/lib/index.mjs.map +1 -0
  8. package/dist/lib/service.mjs +138 -0
  9. package/dist/lib/service.mjs.map +1 -0
  10. package/dist/lib/testing.mjs +160 -0
  11. package/dist/lib/testing.mjs.map +1 -0
  12. package/dist/types/src/base-http-client.d.ts +18 -0
  13. package/dist/types/src/base-http-client.d.ts.map +1 -1
  14. package/dist/types/src/browser-rendering.d.ts +0 -7
  15. package/dist/types/src/browser-rendering.d.ts.map +1 -1
  16. package/dist/types/src/edge-client.d.ts +22 -2
  17. package/dist/types/src/edge-client.d.ts.map +1 -1
  18. package/dist/types/src/edge-http-client.d.ts +86 -2
  19. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  20. package/dist/types/src/edge-ws-connection.d.ts +19 -0
  21. package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
  22. package/dist/types/src/edge-ws-connection.test.d.ts +2 -0
  23. package/dist/types/src/edge-ws-connection.test.d.ts.map +1 -0
  24. package/dist/types/src/index.d.ts +1 -0
  25. package/dist/types/src/index.d.ts.map +1 -1
  26. package/dist/types/src/protocol.d.ts +2 -1
  27. package/dist/types/src/protocol.d.ts.map +1 -1
  28. package/dist/types/src/testing/test-utils.d.ts.map +1 -1
  29. package/dist/types/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +20 -20
  31. package/src/base-http-client.ts +71 -3
  32. package/src/browser-rendering.ts +0 -9
  33. package/src/edge-client.ts +21 -3
  34. package/src/edge-http-client.test.ts +121 -0
  35. package/src/edge-http-client.ts +156 -2
  36. package/src/edge-ws-connection.test.ts +219 -0
  37. package/src/edge-ws-connection.ts +97 -28
  38. package/src/index.ts +1 -0
  39. package/src/protocol.ts +11 -2
  40. package/src/testing/test-utils.ts +5 -1
  41. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
  42. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
  43. package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs +0 -310
  44. package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs.map +0 -7
  45. package/dist/lib/neutral/chunk-WQKMEZJR.mjs +0 -30
  46. package/dist/lib/neutral/chunk-WQKMEZJR.mjs.map +0 -7
  47. package/dist/lib/neutral/cors-proxy.mjs +0 -8
  48. package/dist/lib/neutral/cors-proxy.mjs.map +0 -7
  49. package/dist/lib/neutral/edge-ws-muxer.mjs +0 -12
  50. package/dist/lib/neutral/edge-ws-muxer.mjs.map +0 -7
  51. package/dist/lib/neutral/index.mjs +0 -1524
  52. package/dist/lib/neutral/index.mjs.map +0 -7
  53. package/dist/lib/neutral/meta.json +0 -1
  54. package/dist/lib/neutral/service/index.mjs +0 -134
  55. package/dist/lib/neutral/service/index.mjs.map +0 -7
  56. package/dist/lib/neutral/testing/index.mjs +0 -161
  57. package/dist/lib/neutral/testing/index.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/edge-client",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "EDGE Client",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -16,27 +16,27 @@
16
16
  ".": {
17
17
  "source": "./src/index.ts",
18
18
  "types": "./dist/types/src/index.d.ts",
19
- "default": "./dist/lib/neutral/index.mjs"
19
+ "import": "./dist/lib/index.mjs"
20
20
  },
21
21
  "./cors-proxy": {
22
22
  "source": "./src/cors-proxy.ts",
23
23
  "types": "./dist/types/src/cors-proxy.d.ts",
24
- "default": "./dist/lib/neutral/cors-proxy.mjs"
24
+ "import": "./dist/lib/cors-proxy.mjs"
25
25
  },
26
26
  "./muxer": {
27
27
  "source": "./src/edge-ws-muxer.ts",
28
28
  "types": "./dist/types/src/edge-ws-muxer.d.ts",
29
- "default": "./dist/lib/neutral/edge-ws-muxer.mjs"
29
+ "import": "./dist/lib/edge-ws-muxer.mjs"
30
30
  },
31
31
  "./service": {
32
32
  "source": "./src/service/index.ts",
33
33
  "types": "./dist/types/src/service/index.d.ts",
34
- "default": "./dist/lib/neutral/service/index.mjs"
34
+ "import": "./dist/lib/service.mjs"
35
35
  },
36
36
  "./testing": {
37
37
  "source": "./src/testing/index.ts",
38
38
  "types": "./dist/types/src/testing/index.d.ts",
39
- "default": "./dist/lib/neutral/testing/index.mjs"
39
+ "import": "./dist/lib/testing.mjs"
40
40
  }
41
41
  },
42
42
  "types": "dist/types/src/index.d.ts",
@@ -50,22 +50,22 @@
50
50
  "@opentelemetry/api": "^1.9.1",
51
51
  "isomorphic-ws": "^5.0.0",
52
52
  "ws": "^8.17.1",
53
- "@dxos/async": "0.10.0",
54
- "@dxos/effect": "0.10.0",
55
- "@dxos/context": "0.10.0",
56
- "@dxos/errors": "0.10.0",
57
- "@dxos/crypto": "0.10.0",
58
- "@dxos/invariant": "0.10.0",
59
- "@dxos/keyring": "0.10.0",
60
- "@dxos/keys": "0.10.0",
61
- "@dxos/node-std": "0.10.0",
62
- "@dxos/protocols": "0.10.0",
63
- "@dxos/log": "0.10.0",
64
- "@dxos/util": "0.10.0",
65
- "@dxos/credentials": "0.10.0"
53
+ "@dxos/async": "0.11.0",
54
+ "@dxos/context": "0.11.0",
55
+ "@dxos/credentials": "0.11.0",
56
+ "@dxos/crypto": "0.11.0",
57
+ "@dxos/errors": "0.11.0",
58
+ "@dxos/effect": "0.11.0",
59
+ "@dxos/invariant": "0.11.0",
60
+ "@dxos/keyring": "0.11.0",
61
+ "@dxos/keys": "0.11.0",
62
+ "@dxos/log": "0.11.0",
63
+ "@dxos/protocols": "0.11.0",
64
+ "@dxos/node-std": "0.11.0",
65
+ "@dxos/util": "0.11.0"
66
66
  },
67
67
  "devDependencies": {
68
- "@dxos/test-utils": "0.10.0"
68
+ "@dxos/test-utils": "0.11.0"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "effect": "3.21.4"
@@ -53,6 +53,14 @@ type HttpRequestArgs = {
53
53
  auth?: boolean;
54
54
  };
55
55
 
56
+ export type RawHttpRequestArgs = {
57
+ method: string;
58
+ body?: BodyInit;
59
+ headers?: Record<string, string>;
60
+ retry?: RetryConfig;
61
+ auth?: boolean;
62
+ };
63
+
56
64
  export abstract class BaseHttpClient {
57
65
  protected readonly _baseUrl: string;
58
66
  protected readonly _clientTag: string | undefined;
@@ -77,9 +85,6 @@ export abstract class BaseHttpClient {
77
85
  }
78
86
  }
79
87
 
80
- // TODO(mykola): Extend `_call` to support streaming/raw `Response` returns so
81
- // `EdgeHttpClient.anthropicAiRequest` can be absorbed here and the auth/retry loop
82
- // stops being duplicated across the two paths.
83
88
  protected async _call<T>(ctx: Context, url: URL, args: HttpRequestArgs): Promise<T> {
84
89
  const shouldRetry = createRetryHandler(args);
85
90
  // Log presence/size only — never log raw body contents which may contain PII.
@@ -162,6 +167,69 @@ export abstract class BaseHttpClient {
162
167
  }
163
168
  }
164
169
 
170
+ /**
171
+ * Like {@link _call} but returns the raw `Response` instead of parsing a JSON envelope — for
172
+ * endpoints with binary or absent response bodies (e.g. blob storage). A 404 is returned to the
173
+ * caller rather than thrown, since "not found" is an expected outcome for lookups; all other
174
+ * non-ok, non-retryable statuses throw `EdgeCallFailedError`, mirroring `_call`.
175
+ *
176
+ * NOTE: Duplicates `_call`'s auth/retry loop rather than sharing it, to avoid touching `_call`'s
177
+ * broadly-depended-on JSON-envelope behavior. `EdgeHttpClient.anthropicAiRequest`'s separate
178
+ * duplicate loop is a follow-up candidate for consolidating onto this method.
179
+ */
180
+ protected async _callRaw(ctx: Context, url: URL, args: RawHttpRequestArgs): Promise<Response> {
181
+ const shouldRetry = createRetryHandler(args);
182
+ log('fetch', { url, hasBody: args.body !== undefined });
183
+
184
+ const traceHeaders = getTraceHeaders(ctx);
185
+
186
+ let handledAuth = false;
187
+ while (true) {
188
+ let processingError: EdgeCallFailedError | undefined;
189
+ try {
190
+ if (!this._authHeader && args.auth) {
191
+ const response = await fetch(new URL('/auth', this._baseUrl));
192
+ if (response.status === 401) {
193
+ this._authHeader = await this._handleUnauthorized(response);
194
+ }
195
+ }
196
+
197
+ const headers: Record<string, string> = { ...args.headers };
198
+ if (this._authHeader) {
199
+ headers['Authorization'] = this._authHeader;
200
+ }
201
+ if (traceHeaders) {
202
+ Object.assign(headers, traceHeaders);
203
+ }
204
+ if (this._clientTag) {
205
+ headers[EDGE_CLIENT_TAG_HEADER] = this._clientTag;
206
+ }
207
+
208
+ const response = await fetch(url, { method: args.method, body: args.body, headers });
209
+
210
+ if (response.ok || response.status === 404) {
211
+ return response;
212
+ }
213
+
214
+ if (response.status === 401 && response.headers.get('WWW-Authenticate') !== null && !handledAuth) {
215
+ this._authHeader = await this._handleUnauthorized(response);
216
+ handledAuth = true;
217
+ continue;
218
+ }
219
+
220
+ processingError = await EdgeCallFailedError.fromHttpFailure(response);
221
+ } catch (error: any) {
222
+ processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
223
+ }
224
+
225
+ if (processingError?.isRetryable && (await shouldRetry(ctx, processingError.retryAfterMs))) {
226
+ log.verbose('retrying raw request', { url, processingError });
227
+ } else {
228
+ throw processingError!;
229
+ }
230
+ }
231
+ }
232
+
165
233
  protected async _handleUnauthorized(response: Response): Promise<string> {
166
234
  if (!this._edgeIdentity) {
167
235
  log.warn('unauthorized response received before identity was set');
@@ -2,11 +2,8 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import * as Context from 'effect/Context';
6
5
  import * as Schema from 'effect/Schema';
7
6
 
8
- import { type EdgeHttpClient } from './edge-http-client';
9
-
10
7
  /** Request body for EDGE `/ai/browser-rendering/markdown` (ai-service Browser Run markdown quick action). */
11
8
  export const MarkdownRequest = Schema.Struct({
12
9
  url: Schema.optional(Schema.String),
@@ -31,9 +28,3 @@ export const MarkdownResponse = Schema.Struct({
31
28
  });
32
29
 
33
30
  export type MarkdownResponse = Schema.Schema.Type<typeof MarkdownResponse>;
34
-
35
- /** Authenticated EDGE HTTP client for operation handlers (e.g. browser markdown fetch). */
36
- export class EdgeHttpClientService extends Context.Tag('@dxos/edge-client/EdgeHttpClientService')<
37
- EdgeHttpClientService,
38
- EdgeHttpClient
39
- >() {}
@@ -2,6 +2,8 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import * as EffectContext from 'effect/Context';
6
+
5
7
  import {
6
8
  Event,
7
9
  PersistentLifecycle,
@@ -51,9 +53,25 @@ export interface EdgeConnection extends Required<Lifecycle> {
51
53
  setIdentity(identity: EdgeIdentity): void;
52
54
  send(ctx: Context, message: Message): Promise<void>;
53
55
  onMessage(listener: MessageListener): () => void;
54
- onReconnected(listener: ReconnectListener): () => void;
56
+ /**
57
+ * Subscribe to connection (re-)establishment.
58
+ *
59
+ * By default the listener also fires once immediately when the connection is already ready at
60
+ * subscription time — a current-state notification for late subscribers. Pass
61
+ * `emitCurrentState: false` for reconnect-reaction logic (e.g. restarting a session): reacting
62
+ * to the subscribe-time firing there causes restart loops, since each restart re-subscribes.
63
+ */
64
+ onReconnected(listener: ReconnectListener, opts?: { emitCurrentState?: boolean }): () => void;
55
65
  }
56
66
 
67
+ /**
68
+ * Effect service tag for {@link EdgeConnection}.
69
+ */
70
+ export class EdgeConnectionService extends EffectContext.Tag('@dxos/edge-client/EdgeConnection')<
71
+ EdgeConnectionService,
72
+ EdgeConnection
73
+ >() {}
74
+
57
75
  /**
58
76
  * Messenger client for EDGE:
59
77
  * - While open, uses PersistentLifecycle to keep an open EdgeWsConnection, reconnecting on failures.
@@ -165,9 +183,9 @@ export class EdgeClient extends Resource implements EdgeConnection {
165
183
  return () => this._messageListeners.delete(listener);
166
184
  }
167
185
 
168
- public onReconnected(listener: ReconnectListener) {
186
+ public onReconnected(listener: ReconnectListener, opts?: { emitCurrentState?: boolean }) {
169
187
  this._reconnectListeners.add(listener);
170
- if (this._ready.state === TriggerState.RESOLVED) {
188
+ if ((opts?.emitCurrentState ?? true) && this._ready.state === TriggerState.RESOLVED) {
171
189
  // Microtask so that listener is always called asynchronously, no matter the state of the ready trigger
172
190
  // at the moment of registration.
173
191
  scheduleMicroTask(this._ctx, () => {
@@ -4,8 +4,12 @@
4
4
 
5
5
  import { afterEach, describe, it, test, vi } from 'vitest';
6
6
 
7
+ import { Context } from '@dxos/context';
8
+ import { type Presentation } from '@dxos/protocols/proto/dxos/halo/credentials';
9
+
7
10
  import { createEphemeralEdgeIdentity } from './auth';
8
11
  import { EdgeHttpClient } from './edge-http-client';
12
+ import { type EdgeIdentity } from './edge-identity';
9
13
 
10
14
  // TODO(burdon): Factor out config.
11
15
  const DEV_SERVER = 'https://edge.dxos.workers.dev';
@@ -54,3 +58,120 @@ describe('EdgeHttpClient.anthropicAiRequest', () => {
54
58
  expect(targetCall![1]?.method).toBe('POST');
55
59
  });
56
60
  });
61
+
62
+ describe('EdgeHttpClient blobs', () => {
63
+ afterEach(() => {
64
+ vi.unstubAllGlobals();
65
+ });
66
+
67
+ test('getBlobUrl URL-encodes the key', ({ expect }) => {
68
+ const client = new EdgeHttpClient('https://edge.example.com');
69
+ expect(client.getBlobUrl('abc123').toString()).toBe('https://edge.example.com/api/file/abc123');
70
+ expect(client.getBlobUrl('a/b/../c').toString()).toBe('https://edge.example.com/api/file/a%2Fb%2F..%2Fc');
71
+ });
72
+
73
+ test('putBlob sends a raw POST body and pre-fetches /auth', async ({ expect }) => {
74
+ const fetchMock = vi.fn(async (input: any, _init?: RequestInit) => {
75
+ const url = String(input instanceof URL ? input : (input.url ?? input));
76
+ if (url.endsWith('/auth')) {
77
+ return new Response(null, { status: 200 });
78
+ }
79
+ return new Response(null, { status: 200 });
80
+ });
81
+ vi.stubGlobal('fetch', fetchMock);
82
+
83
+ const client = new EdgeHttpClient('https://edge.example.com');
84
+ const bytes = new Uint8Array([1, 2, 3]);
85
+ await client.putBlob(Context.default(), 'abc123', bytes, { contentType: 'application/octet-stream' });
86
+
87
+ const authCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith('/auth'));
88
+ expect(authCall).toBeDefined();
89
+
90
+ const putCall = fetchMock.mock.calls.find((call) => String(call[0]).includes('/api/file/abc123'));
91
+ expect(putCall).toBeDefined();
92
+ expect(String(putCall![0])).toBe('https://edge.example.com/api/file/abc123');
93
+ expect(putCall![1]?.method).toBe('POST');
94
+ expect(putCall![1]?.body).toBe(bytes);
95
+ expect((putCall![1]?.headers as Record<string, string>)['Content-Type']).toBe('application/octet-stream');
96
+ });
97
+
98
+ test('putBlob retries with an auth header after a 401 challenge', async ({ expect }) => {
99
+ const identity: EdgeIdentity = {
100
+ peerKey: 'peer-key',
101
+ identityDid: 'did:halo:test',
102
+ presentCredentials: async (): Promise<Presentation> => ({}),
103
+ };
104
+
105
+ const fetchMock = vi.fn(async (input: any, init?: RequestInit) => {
106
+ const url = String(input instanceof URL ? input : (input.url ?? input));
107
+ if (url.endsWith('/auth')) {
108
+ return new Response(null, { status: 200 });
109
+ }
110
+ const headers = init?.headers as Record<string, string> | undefined;
111
+ if (!headers?.Authorization) {
112
+ return new Response(null, {
113
+ status: 401,
114
+ headers: { 'WWW-Authenticate': 'VerifiablePresentation challenge=Y2hhbGxlbmdl' },
115
+ });
116
+ }
117
+ return new Response(null, { status: 200 });
118
+ });
119
+ vi.stubGlobal('fetch', fetchMock);
120
+
121
+ const client = new EdgeHttpClient('https://edge.example.com');
122
+ client.setIdentity(identity);
123
+ await client.putBlob(Context.default(), 'abc123', new Uint8Array([1, 2, 3]));
124
+
125
+ const fileCalls = fetchMock.mock.calls.filter((call) => String(call[0]).includes('/api/file/abc123'));
126
+ expect(fileCalls.length).toBe(2);
127
+ expect((fileCalls[1][1]?.headers as Record<string, string>).Authorization).toMatch(/^VerifiablePresentation/);
128
+ });
129
+
130
+ test('getBlob returns bytes on success', async ({ expect }) => {
131
+ const fetchMock = vi.fn(async (input: any) => {
132
+ const url = String(input instanceof URL ? input : (input.url ?? input));
133
+ expect(url).toBe('https://edge.example.com/api/file/abc123');
134
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
135
+ });
136
+ vi.stubGlobal('fetch', fetchMock);
137
+
138
+ const client = new EdgeHttpClient('https://edge.example.com');
139
+ const bytes = await client.getBlob(Context.default(), 'abc123');
140
+ expect(bytes).toEqual(new Uint8Array([1, 2, 3]));
141
+ });
142
+
143
+ test('getBlob returns undefined on 404', async ({ expect }) => {
144
+ const fetchMock = vi.fn(async () => new Response(null, { status: 404 }));
145
+ vi.stubGlobal('fetch', fetchMock);
146
+
147
+ const client = new EdgeHttpClient('https://edge.example.com');
148
+ const bytes = await client.getBlob(Context.default(), 'missing');
149
+ expect(bytes).toBeUndefined();
150
+ });
151
+
152
+ test('hasBlob sends a HEAD request and reflects 404 as false', async ({ expect }) => {
153
+ const fetchMock = vi.fn(async (_input: any, init?: RequestInit) => {
154
+ expect(init?.method).toBe('HEAD');
155
+ return new Response(null, { status: 404 });
156
+ });
157
+ vi.stubGlobal('fetch', fetchMock);
158
+
159
+ const client = new EdgeHttpClient('https://edge.example.com');
160
+ expect(await client.hasBlob(Context.default(), 'missing')).toBe(false);
161
+ });
162
+
163
+ test('deleteBlob sends a DELETE request', async ({ expect }) => {
164
+ const fetchMock = vi.fn(async (input: any, init?: RequestInit) => {
165
+ expect(String(input instanceof URL ? input : (input.url ?? input))).toBe(
166
+ 'https://edge.example.com/api/file/abc123',
167
+ );
168
+ expect(init?.method).toBe('DELETE');
169
+ return new Response(null, { status: 200 });
170
+ });
171
+ vi.stubGlobal('fetch', fetchMock);
172
+
173
+ const client = new EdgeHttpClient('https://edge.example.com');
174
+ await client.deleteBlob(Context.default(), 'abc123');
175
+ expect(fetchMock).toHaveBeenCalled();
176
+ });
177
+ });
@@ -5,6 +5,7 @@
5
5
  import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
6
6
  import * as HttpClient from '@effect/platform/HttpClient';
7
7
  import * as HttpClientRequest from '@effect/platform/HttpClientRequest';
8
+ import * as EffectContext from 'effect/Context';
8
9
  import * as Effect from 'effect/Effect';
9
10
  import * as Function from 'effect/Function';
10
11
 
@@ -26,6 +27,8 @@ import {
26
27
  type ExportBundleRequest,
27
28
  type ExportBundleResponse,
28
29
  type FeedProtocol,
30
+ type GetAccessTokenRequest,
31
+ type GetAccessTokenResponseBody,
29
32
  type GetAgentStatusResponseBody,
30
33
  type GetNotarizationResponseBody,
31
34
  type GetPluginsResponseBody,
@@ -38,6 +41,7 @@ import {
38
41
  type PostNotarizationRequestBody,
39
42
  type RecoverIdentityRequest,
40
43
  type RecoverIdentityResponseBody,
44
+ type SerializedError,
41
45
  type UploadFunctionRequest,
42
46
  type UploadFunctionResponseBody,
43
47
  } from '@dxos/protocols';
@@ -81,8 +85,52 @@ export type GetCronTriggersResponse = {
81
85
  cronIds: string[];
82
86
  };
83
87
 
88
+ /**
89
+ * Per-trigger runtime status reported by the EDGE dispatcher, keyed to the
90
+ * trigger's ECHO object id so a client can correlate it with the replicated
91
+ * `Trigger` object in its local database.
92
+ *
93
+ * TODO(edge): The backing endpoint (`GET /triggers/{spaceId}`, see
94
+ * {@link EdgeHttpClient.getSpaceTriggers}) is a proposal and is not yet
95
+ * implemented server-side.
96
+ */
97
+ export type EdgeTriggerStatus = {
98
+ /** ECHO object id of the trigger. */
99
+ triggerId: ObjectId;
100
+ /** Whether the EDGE dispatcher currently has this trigger registered. */
101
+ registered: boolean;
102
+ kind: 'timer' | 'subscription' | 'email' | 'webhook' | 'feed' | 'direct';
103
+ /** Next scheduled cron execution (epoch ms). Set only for `timer` triggers. */
104
+ nextExecutionTimestamp?: number;
105
+ /** Cooldown expiry after a failure (epoch ms). */
106
+ cooldownUntilTimestamp?: number;
107
+ /** Outcome of the most recent invocation on the edge. */
108
+ lastResult?: {
109
+ status: 'success' | 'failure';
110
+ /** Completion time (epoch ms). */
111
+ timestamp: number;
112
+ error?: SerializedError;
113
+ };
114
+ };
115
+
116
+ /**
117
+ * Response of the proposed `GET /triggers/{spaceId}` endpoint: the full list of
118
+ * triggers registered on a space's EDGE dispatcher, with runtime status. Polled
119
+ * by the remote trigger monitor to surface edge trigger state.
120
+ */
121
+ export type GetSpaceTriggersResponse = {
122
+ /** Whether the space's edge dispatcher is active. */
123
+ isActive: boolean;
124
+ triggers: EdgeTriggerStatus[];
125
+ };
126
+
84
127
  export type EdgeHttpClientOptions = BaseHttpClientOptions;
85
128
 
129
+ export class EdgeHttpClientService extends EffectContext.Tag('@dxos/edge-client/EdgeHttpClient')<
130
+ EdgeHttpClientService,
131
+ EdgeHttpClient
132
+ >() {}
133
+
86
134
  /**
87
135
  * HTTP client for the edge worker API (spaces, queues, functions, agents, etc.).
88
136
  *
@@ -192,6 +240,18 @@ export class EdgeHttpClient extends BaseHttpClient {
192
240
  return this._call(ctx, new URL('/oauth/registration/complete', this.baseUrl), { ...args, body, method: 'POST' });
193
241
  }
194
242
 
243
+ /**
244
+ * Resolves the live access token behind a `MANAGED_ACCESS_TOKEN` placeholder. Authorized by the
245
+ * caller's presentation: EDGE serves it only to members of the owning space.
246
+ */
247
+ public async getAccessToken(
248
+ ctx: Context,
249
+ body: GetAccessTokenRequest,
250
+ args?: EdgeHttpCallArgs,
251
+ ): Promise<GetAccessTokenResponseBody> {
252
+ return this._call(ctx, new URL('/oauth/token', this.baseUrl), { ...args, body, method: 'POST' });
253
+ }
254
+
195
255
  //
196
256
  // Spaces
197
257
  //
@@ -208,10 +268,10 @@ export class EdgeHttpClient extends BaseHttpClient {
208
268
  ctx: Context,
209
269
  subspaceTag: string,
210
270
  spaceId: SpaceId,
211
- query: FeedProtocol.QueueQuery,
271
+ query: FeedProtocol.FeedQuery,
212
272
  args?: EdgeHttpCallArgs,
213
273
  ): Promise<EdgeQueryQueueResponse> {
214
- const queueId = query.queueIds?.[0];
274
+ const queueId = query.feedIds?.[0];
215
275
  invariant(queueId, 'queueId required');
216
276
  return this._call(
217
277
  ctx,
@@ -258,6 +318,72 @@ export class EdgeHttpClient extends BaseHttpClient {
258
318
  );
259
319
  }
260
320
 
321
+ //
322
+ // Blobs
323
+ //
324
+
325
+ /**
326
+ * Builds the URL for the blob stored under `key`. `key` is URL-encoded for defense in depth —
327
+ * callers pass a lowercase hex SHA-256 digest (extracted from an `ni:` URI by the edge backend).
328
+ */
329
+ public getBlobUrl(key: string): URL {
330
+ return new URL(`/api/file/${encodeURIComponent(key)}`, this.baseUrl);
331
+ }
332
+
333
+ /**
334
+ * Uploads bytes to the edge blob service, keyed by content hash. Pre-fetches `/auth` (`auth:
335
+ * true`) so large bodies aren't sent twice on an auth challenge.
336
+ */
337
+ public async putBlob(
338
+ ctx: Context,
339
+ key: string,
340
+ data: Uint8Array,
341
+ args?: EdgeHttpCallArgs & { contentType?: string },
342
+ ): Promise<void> {
343
+ const headers: Record<string, string> = {};
344
+ if (args?.contentType) {
345
+ headers['Content-Type'] = args.contentType;
346
+ }
347
+ await this._callRaw(ctx, this.getBlobUrl(key), {
348
+ retry: args?.retry,
349
+ auth: args?.auth ?? true,
350
+ method: 'POST',
351
+ // `Uint8Array` is generic over `ArrayBufferLike` (incl. `SharedArrayBuffer`) while DOM's
352
+ // `BodyInit` only covers `ArrayBuffer`-backed views — a gap between the DOM lib types and
353
+ // the TS standard lib, not fixable by typing `data` differently.
354
+ body: data as BodyInit,
355
+ headers,
356
+ });
357
+ }
358
+
359
+ /**
360
+ * Downloads bytes previously stored with {@link putBlob}. Returns `undefined` if `key` is not
361
+ * found.
362
+ */
363
+ public async getBlob(ctx: Context, key: string, args?: EdgeHttpCallArgs): Promise<Uint8Array | undefined> {
364
+ const response = await this._callRaw(ctx, this.getBlobUrl(key), { ...args, method: 'GET' });
365
+ if (response.status === 404) {
366
+ return undefined;
367
+ }
368
+ return new Uint8Array(await response.arrayBuffer());
369
+ }
370
+
371
+ /**
372
+ * Checks whether bytes are stored under `key`, without downloading them.
373
+ */
374
+ public async hasBlob(ctx: Context, key: string, args?: EdgeHttpCallArgs): Promise<boolean> {
375
+ const response = await this._callRaw(ctx, this.getBlobUrl(key), { ...args, method: 'HEAD' });
376
+ return response.status !== 404;
377
+ }
378
+
379
+ /**
380
+ * Deletes bytes stored under `key`. Not called by any core `Blob.remove` path in v1 (deletion is
381
+ * deferred), provided for completeness.
382
+ */
383
+ public async deleteBlob(ctx: Context, key: string, args?: EdgeHttpCallArgs): Promise<void> {
384
+ await this._callRaw(ctx, this.getBlobUrl(key), { ...args, method: 'DELETE' });
385
+ }
386
+
261
387
  //
262
388
  // Functions
263
389
  //
@@ -366,6 +492,34 @@ export class EdgeHttpClient extends BaseHttpClient {
366
492
  });
367
493
  }
368
494
 
495
+ /**
496
+ * Cancels the current run of a cron trigger on the EDGE dispatcher — its in-flight execution and
497
+ * `runAgain` continuation chain. The trigger stays enabled, so its schedule keeps firing.
498
+ */
499
+ public async cancelTriggerRun(ctx: Context, spaceId: SpaceId, triggerId: ObjectId) {
500
+ return this._call(ctx, new URL(`/functions/${spaceId}/triggers/crons/${triggerId}/cancel`, this.baseUrl), {
501
+ method: 'POST',
502
+ });
503
+ }
504
+
505
+ /**
506
+ * Returns the full list of triggers registered on a space's EDGE dispatcher, with per-trigger
507
+ * runtime status. Polled by the remote trigger monitor to surface edge trigger state.
508
+ *
509
+ * TODO(edge): Proposed endpoint; not yet implemented server-side.
510
+ */
511
+ public async getSpaceTriggers(
512
+ ctx: Context,
513
+ spaceId: SpaceId,
514
+ args?: EdgeHttpCallArgs,
515
+ ): Promise<GetSpaceTriggersResponse> {
516
+ return this._call<GetSpaceTriggersResponse>(ctx, new URL(`/triggers/${spaceId}`, this.baseUrl), {
517
+ ...args,
518
+ method: 'GET',
519
+ auth: true,
520
+ });
521
+ }
522
+
369
523
  //
370
524
  // Query
371
525
  //