@dxos/edge-client 0.10.0 → 0.11.1
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.
- package/dist/lib/chunk-edge-ws-muxer.mjs +322 -0
- package/dist/lib/chunk-edge-ws-muxer.mjs.map +1 -0
- package/dist/lib/cors-proxy.mjs +29 -0
- package/dist/lib/cors-proxy.mjs.map +1 -0
- package/dist/lib/edge-ws-muxer.mjs +2 -0
- package/dist/lib/index.mjs +1769 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/service.mjs +138 -0
- package/dist/lib/service.mjs.map +1 -0
- package/dist/lib/testing.mjs +160 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/base-http-client.d.ts +18 -0
- package/dist/types/src/base-http-client.d.ts.map +1 -1
- package/dist/types/src/browser-rendering.d.ts +0 -7
- package/dist/types/src/browser-rendering.d.ts.map +1 -1
- package/dist/types/src/edge-client.d.ts +22 -2
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +86 -2
- package/dist/types/src/edge-http-client.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.d.ts +19 -0
- package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.test.d.ts +2 -0
- package/dist/types/src/edge-ws-connection.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/protocol.d.ts +2 -1
- package/dist/types/src/protocol.d.ts.map +1 -1
- package/dist/types/src/testing/test-utils.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -20
- package/src/base-http-client.ts +71 -3
- package/src/browser-rendering.ts +0 -9
- package/src/edge-client.ts +21 -3
- package/src/edge-http-client.test.ts +121 -0
- package/src/edge-http-client.ts +156 -2
- package/src/edge-ws-connection.test.ts +219 -0
- package/src/edge-ws-connection.ts +97 -28
- package/src/index.ts +1 -0
- package/src/protocol.ts +11 -2
- package/src/testing/test-utils.ts +5 -1
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
- package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs +0 -310
- package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs.map +0 -7
- package/dist/lib/neutral/chunk-WQKMEZJR.mjs +0 -30
- package/dist/lib/neutral/chunk-WQKMEZJR.mjs.map +0 -7
- package/dist/lib/neutral/cors-proxy.mjs +0 -8
- package/dist/lib/neutral/cors-proxy.mjs.map +0 -7
- package/dist/lib/neutral/edge-ws-muxer.mjs +0 -12
- package/dist/lib/neutral/edge-ws-muxer.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -1524
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
- package/dist/lib/neutral/service/index.mjs +0 -134
- package/dist/lib/neutral/service/index.mjs.map +0 -7
- package/dist/lib/neutral/testing/index.mjs +0 -161
- 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.
|
|
3
|
+
"version": "0.11.1",
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
54
|
-
"@dxos/
|
|
55
|
-
"@dxos/
|
|
56
|
-
"@dxos/
|
|
57
|
-
"@dxos/
|
|
58
|
-
"@dxos/invariant": "0.
|
|
59
|
-
"@dxos/
|
|
60
|
-
"@dxos/
|
|
61
|
-
"@dxos/
|
|
62
|
-
"@dxos/
|
|
63
|
-
"@dxos/
|
|
64
|
-
"@dxos/
|
|
65
|
-
"@dxos/
|
|
53
|
+
"@dxos/async": "0.11.1",
|
|
54
|
+
"@dxos/context": "0.11.1",
|
|
55
|
+
"@dxos/crypto": "0.11.1",
|
|
56
|
+
"@dxos/credentials": "0.11.1",
|
|
57
|
+
"@dxos/effect": "0.11.1",
|
|
58
|
+
"@dxos/invariant": "0.11.1",
|
|
59
|
+
"@dxos/errors": "0.11.1",
|
|
60
|
+
"@dxos/keyring": "0.11.1",
|
|
61
|
+
"@dxos/keys": "0.11.1",
|
|
62
|
+
"@dxos/log": "0.11.1",
|
|
63
|
+
"@dxos/node-std": "0.11.1",
|
|
64
|
+
"@dxos/protocols": "0.11.1",
|
|
65
|
+
"@dxos/util": "0.11.1"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@dxos/test-utils": "0.
|
|
68
|
+
"@dxos/test-utils": "0.11.1"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"effect": "3.21.4"
|
package/src/base-http-client.ts
CHANGED
|
@@ -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');
|
package/src/browser-rendering.ts
CHANGED
|
@@ -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
|
-
>() {}
|
package/src/edge-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/edge-http-client.ts
CHANGED
|
@@ -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.
|
|
271
|
+
query: FeedProtocol.FeedQuery,
|
|
212
272
|
args?: EdgeHttpCallArgs,
|
|
213
273
|
): Promise<EdgeQueryQueueResponse> {
|
|
214
|
-
const queueId = query.
|
|
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
|
//
|