@dxos/edge-client 0.9.0 → 0.9.1-main.c7dcc2e112

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 (40) hide show
  1. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  2. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  3. package/dist/lib/neutral/cors-proxy.mjs +1 -0
  4. package/dist/lib/neutral/edge-ws-muxer.mjs +1 -0
  5. package/dist/lib/neutral/index.mjs +82 -7
  6. package/dist/lib/neutral/index.mjs.map +3 -3
  7. package/dist/lib/neutral/meta.json +1 -1
  8. package/dist/lib/neutral/service/index.mjs +134 -0
  9. package/dist/lib/neutral/service/index.mjs.map +7 -0
  10. package/dist/lib/neutral/testing/index.mjs +1 -0
  11. package/dist/lib/neutral/testing/index.mjs.map +1 -1
  12. package/dist/types/src/edge-ai-http-client.d.ts +39 -0
  13. package/dist/types/src/edge-ai-http-client.d.ts.map +1 -1
  14. package/dist/types/src/edge-ai-http-client.test.d.ts +2 -0
  15. package/dist/types/src/edge-ai-http-client.test.d.ts.map +1 -0
  16. package/dist/types/src/edge-http-client.d.ts +17 -2
  17. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  18. package/dist/types/src/hub-http-client.d.ts +8 -1
  19. package/dist/types/src/hub-http-client.d.ts.map +1 -1
  20. package/dist/types/src/service/Image.d.ts +25 -0
  21. package/dist/types/src/service/Image.d.ts.map +1 -0
  22. package/dist/types/src/service/edge-service.d.ts +36 -0
  23. package/dist/types/src/service/edge-service.d.ts.map +1 -0
  24. package/dist/types/src/service/edge-service.test.d.ts +2 -0
  25. package/dist/types/src/service/edge-service.test.d.ts.map +1 -0
  26. package/dist/types/src/service/image-service.e2e.test.d.ts +2 -0
  27. package/dist/types/src/service/image-service.e2e.test.d.ts.map +1 -0
  28. package/dist/types/src/service/index.d.ts +3 -0
  29. package/dist/types/src/service/index.d.ts.map +1 -0
  30. package/dist/types/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +20 -15
  32. package/src/edge-ai-http-client.test.ts +37 -0
  33. package/src/edge-ai-http-client.ts +87 -2
  34. package/src/edge-http-client.ts +18 -6
  35. package/src/hub-http-client.ts +20 -0
  36. package/src/service/Image.ts +61 -0
  37. package/src/service/edge-service.test.ts +151 -0
  38. package/src/service/edge-service.ts +139 -0
  39. package/src/service/image-service.e2e.test.ts +53 -0
  40. package/src/service/index.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/edge-client",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-main.c7dcc2e112",
4
4
  "description": "EDGE Client",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -28,6 +28,11 @@
28
28
  "types": "./dist/types/src/edge-ws-muxer.d.ts",
29
29
  "default": "./dist/lib/neutral/edge-ws-muxer.mjs"
30
30
  },
31
+ "./service": {
32
+ "source": "./src/service/index.ts",
33
+ "types": "./dist/types/src/service/index.d.ts",
34
+ "default": "./dist/lib/neutral/service/index.mjs"
35
+ },
31
36
  "./testing": {
32
37
  "source": "./src/testing/index.ts",
33
38
  "types": "./dist/types/src/testing/index.d.ts",
@@ -45,22 +50,22 @@
45
50
  "@opentelemetry/api": "^1.9.1",
46
51
  "isomorphic-ws": "^5.0.0",
47
52
  "ws": "^8.17.1",
48
- "@dxos/async": "0.9.0",
49
- "@dxos/context": "0.9.0",
50
- "@dxos/credentials": "0.9.0",
51
- "@dxos/crypto": "0.9.0",
52
- "@dxos/effect": "0.9.0",
53
- "@dxos/errors": "0.9.0",
54
- "@dxos/invariant": "0.9.0",
55
- "@dxos/keyring": "0.9.0",
56
- "@dxos/keys": "0.9.0",
57
- "@dxos/log": "0.9.0",
58
- "@dxos/util": "0.9.0",
59
- "@dxos/node-std": "0.9.0",
60
- "@dxos/protocols": "0.9.0"
53
+ "@dxos/context": "0.9.1-main.c7dcc2e112",
54
+ "@dxos/credentials": "0.9.1-main.c7dcc2e112",
55
+ "@dxos/async": "0.9.1-main.c7dcc2e112",
56
+ "@dxos/errors": "0.9.1-main.c7dcc2e112",
57
+ "@dxos/effect": "0.9.1-main.c7dcc2e112",
58
+ "@dxos/invariant": "0.9.1-main.c7dcc2e112",
59
+ "@dxos/keyring": "0.9.1-main.c7dcc2e112",
60
+ "@dxos/keys": "0.9.1-main.c7dcc2e112",
61
+ "@dxos/node-std": "0.9.1-main.c7dcc2e112",
62
+ "@dxos/log": "0.9.1-main.c7dcc2e112",
63
+ "@dxos/protocols": "0.9.1-main.c7dcc2e112",
64
+ "@dxos/crypto": "0.9.1-main.c7dcc2e112",
65
+ "@dxos/util": "0.9.1-main.c7dcc2e112"
61
66
  },
62
67
  "devDependencies": {
63
- "@dxos/test-utils": "0.9.0"
68
+ "@dxos/test-utils": "0.9.1-main.c7dcc2e112"
64
69
  },
65
70
  "peerDependencies": {
66
71
  "effect": "3.21.3"
@@ -0,0 +1,37 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { patchAnthropicMessagesRequestBody } from './edge-ai-http-client';
8
+
9
+ describe('patchAnthropicMessagesRequestBody', () => {
10
+ test('adds eager_input_streaming to user-defined tools', ({ expect }) => {
11
+ const body = JSON.stringify({
12
+ model: 'claude-sonnet-4-6',
13
+ stream: true,
14
+ tools: [
15
+ {
16
+ name: 'delegate-task',
17
+ description: 'Delegate work',
18
+ input_schema: { type: 'object', properties: { title: { type: 'string' } } },
19
+ },
20
+ {
21
+ name: 'bash',
22
+ type: 'bash_20250124',
23
+ },
24
+ ],
25
+ });
26
+
27
+ const patched = JSON.parse(patchAnthropicMessagesRequestBody(body) as string);
28
+
29
+ expect(patched.tools[0]).toMatchObject({ eager_input_streaming: true });
30
+ expect(patched.tools[1]).not.toHaveProperty('eager_input_streaming');
31
+ });
32
+
33
+ test('leaves non-json bodies unchanged', ({ expect }) => {
34
+ const body = 'not-json';
35
+ expect(patchAnthropicMessagesRequestBody(body)).toBe(body);
36
+ });
37
+ });
@@ -31,11 +31,72 @@ export class ByokError extends BaseError.extend('ByokError', 'BYOK authenticatio
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Thrown by {@link EdgeAiHttpClient} when EDGE rejects an AI request with 429 because the
36
+ * authenticated profile exceeded a metering limit. Wrapped as the `cause` of an
37
+ * {@link HttpClientError.ResponseError} so it survives `@effect/ai`'s error mapping.
38
+ */
39
+ export class UsageQuotaExceededError extends BaseError.extend('UsageQuotaExceededError', 'Usage quota exceeded') {}
40
+
34
41
  /**
35
42
  * Copy pasted from https://github.com/Effect-TS/effect/blob/main/packages/platform/src/internal/fetchHttpClient.ts
36
43
  */
37
44
  export const requestInitTagKey = '@effect/platform/FetchHttpClient/FetchOptions';
38
45
 
46
+ type AnthropicMessagesPayload = {
47
+ tools?: ReadonlyArray<Record<string, unknown>>;
48
+ };
49
+
50
+ const isUserDefinedAnthropicTool = (tool: Record<string, unknown>): boolean =>
51
+ tool.input_schema != null && typeof tool.input_schema === 'object';
52
+
53
+ /**
54
+ * Enables Anthropic fine-grained tool input streaming for client-defined tools.
55
+ * Provider tools (bash, web_search, etc.) are left unchanged.
56
+ */
57
+ export const patchAnthropicMessagesRequestBody = (body: BodyInit | undefined): BodyInit | undefined => {
58
+ if (body == null) {
59
+ return body;
60
+ }
61
+
62
+ const decodeBody = (): string | undefined => {
63
+ if (typeof body === 'string') {
64
+ return body;
65
+ }
66
+ if (body instanceof Uint8Array) {
67
+ return new TextDecoder().decode(body);
68
+ }
69
+ return undefined;
70
+ };
71
+
72
+ const text = decodeBody();
73
+ if (text == null) {
74
+ return body;
75
+ }
76
+
77
+ try {
78
+ const payload = JSON.parse(text) as AnthropicMessagesPayload;
79
+ if (!Array.isArray(payload.tools)) {
80
+ return body;
81
+ }
82
+
83
+ payload.tools = payload.tools.map((tool) =>
84
+ isUserDefinedAnthropicTool(tool) ? { ...tool, eager_input_streaming: true } : tool,
85
+ );
86
+
87
+ const patched = JSON.stringify(payload);
88
+ return typeof body === 'string' ? patched : new TextEncoder().encode(patched);
89
+ } catch {
90
+ return body;
91
+ }
92
+ };
93
+
94
+ const readStreamBody = (stream: ReadableStream<Uint8Array>): Effect.Effect<string | undefined> =>
95
+ Effect.promise(async () => {
96
+ const response = new Response(stream);
97
+ return await response.text();
98
+ });
99
+
39
100
  /**
40
101
  * An `@effect/platform` {@link HttpClient.HttpClient} that routes requests through the
41
102
  * authenticated EDGE AI endpoint via {@link EdgeHttpClient.anthropicAiRequest}, instead of
@@ -67,7 +128,7 @@ export class EdgeAiHttpClient {
67
128
  ...options,
68
129
  method: request.method,
69
130
  headers,
70
- body,
131
+ body: patchAnthropicMessagesRequestBody(body),
71
132
  signal,
72
133
  }),
73
134
  ),
@@ -107,6 +168,27 @@ export class EdgeAiHttpClient {
107
168
  ),
108
169
  );
109
170
  }
171
+ // Platform quota (not BYOK): reserve/commit rejected the request before upstream AI.
172
+ if (!carriedByok && response.status === 429) {
173
+ return Effect.tryPromise({
174
+ try: () => response.clone().json() as Promise<{ error?: { message?: string } } | undefined>,
175
+ catch: () => undefined,
176
+ }).pipe(
177
+ Effect.orElseSucceed(() => undefined),
178
+ Effect.flatMap((body) =>
179
+ Effect.fail(
180
+ new HttpClientError.ResponseError({
181
+ request,
182
+ response: httpResponse,
183
+ reason: 'StatusCode',
184
+ cause: new UsageQuotaExceededError({
185
+ message: body?.error?.message,
186
+ }),
187
+ }),
188
+ ),
189
+ ),
190
+ );
191
+ }
110
192
  return Effect.succeed(httpResponse);
111
193
  }),
112
194
  );
@@ -118,7 +200,10 @@ export class EdgeAiHttpClient {
118
200
  case 'FormData':
119
201
  return send(request.body.formData);
120
202
  case 'Stream':
121
- return Stream.toReadableStreamEffect(request.body.stream).pipe(Effect.flatMap(send));
203
+ return Stream.toReadableStreamEffect(request.body.stream).pipe(
204
+ Effect.flatMap((readable) => readStreamBody(readable)),
205
+ Effect.flatMap((text) => send(text)),
206
+ );
122
207
  }
123
208
 
124
209
  return send(undefined);
@@ -28,7 +28,6 @@ import {
28
28
  type FeedProtocol,
29
29
  type GetAgentStatusResponseBody,
30
30
  type GetNotarizationResponseBody,
31
- type GetPluginVersionsResponseBody,
32
31
  type GetPluginsResponseBody,
33
32
  type ImportBundleRequest,
34
33
  type InitiateOAuthFlowRequest,
@@ -63,6 +62,12 @@ export type EdgeQueryQueueResponse = {
63
62
  prevCursor?: string;
64
63
  };
65
64
 
65
+ export type UploadPluginBundleRequest = {
66
+ slug: string;
67
+ version: string;
68
+ files: { path: string; content: string }[];
69
+ };
70
+
66
71
  export type TriggersDispatcherStatus = {
67
72
  isActive: boolean;
68
73
  nextCronTaskRunTimestamp?: number;
@@ -379,14 +384,21 @@ export class EdgeHttpClient extends BaseHttpClient {
379
384
  return this._call(ctx, new URL('/registry/plugins', this.baseUrl), { ...args, method: 'GET' });
380
385
  }
381
386
 
382
- public async getRegistryPluginVersions(
387
+ /**
388
+ * Uploads a built plugin bundle to the registry's R2-backed hosting. Authenticated
389
+ * with the caller's hub identity (verifiable presentation) — `setIdentity` must
390
+ * have been called. Returns the canonical `moduleUrl` (the hosted `manifest.json`).
391
+ */
392
+ public async uploadPluginBundle(
383
393
  ctx: Context,
384
- repo: string,
394
+ request: UploadPluginBundleRequest,
385
395
  args?: EdgeHttpCallArgs,
386
- ): Promise<GetPluginVersionsResponseBody> {
387
- return this._call(ctx, new URL(`/registry/plugins/${encodeURIComponent(repo)}/versions`, this.baseUrl), {
396
+ ): Promise<{ moduleUrl: string }> {
397
+ return this._call(ctx, new URL('/registry/upload', this.baseUrl), {
398
+ body: request,
399
+ method: 'POST',
400
+ auth: true,
388
401
  ...args,
389
- method: 'GET',
390
402
  });
391
403
  }
392
404
 
@@ -14,9 +14,11 @@ import {
14
14
  type RedeemInvitationCodeResponse,
15
15
  type RequestAccessRequest,
16
16
  type RequestAccessResponse,
17
+ type GetProfileUsageResponse,
17
18
  type ResendVerificationEmailResponse,
18
19
  type ValidateInvitationCodeResponse,
19
20
  } from '@dxos/protocols';
21
+ import { createUrl } from '@dxos/util';
20
22
 
21
23
  import { BaseHttpClient, type BaseHttpClientOptions, type EdgeHttpCallArgs } from './base-http-client';
22
24
 
@@ -115,4 +117,22 @@ export class HubHttpClient extends BaseHttpClient {
115
117
  ): Promise<ResendVerificationEmailResponse> {
116
118
  return this._call(ctx, new URL('/account/email/resend-verification', this.baseUrl), { ...args, method: 'POST' });
117
119
  }
120
+
121
+ /**
122
+ * Rolling-window usage and effective limits for the authenticated identity.
123
+ * Served from the per-user metering DO; optional `windowSeconds` defaults to the largest limit window.
124
+ */
125
+ public async getProfileUsage(
126
+ ctx: Context,
127
+ query?: { windowSeconds?: number },
128
+ args?: EdgeHttpCallArgs,
129
+ ): Promise<GetProfileUsageResponse> {
130
+ return this._call(
131
+ ctx,
132
+ createUrl(new URL('/api/metering/profile/usage', this.baseUrl), {
133
+ windowSeconds: query?.windowSeconds,
134
+ }),
135
+ { ...args, method: 'GET' },
136
+ );
137
+ }
118
138
  }
@@ -0,0 +1,61 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ // Image service helpers built on the generic {@link EdgeServiceClient}. The
8
+ // worker exposes two contract-identical routes (`/upload` and `/thumbnail`,
9
+ // both returning `{ id, url }`); we mirror them so callers stay on whichever
10
+ // route they already use.
11
+
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Schema from 'effect/Schema';
14
+
15
+ import { type EdgeServiceClient, type EdgeServiceError } from './edge-service';
16
+
17
+ /**
18
+ * Default Composer image-service base URL — the Cloudflare Worker used in
19
+ * production. Override per-environment via the client's `baseUrl`.
20
+ */
21
+ // TODO(burdon): Get from config.
22
+ export const DEFAULT_IMAGE_SERVICE_URL = 'https://image-service-main.dxos.workers.dev';
23
+
24
+ /** Hosted image returned by the service: a CDN `url` and its storage `id`. */
25
+ export const Result = Schema.Struct({
26
+ id: Schema.optional(Schema.String),
27
+ url: Schema.String,
28
+ });
29
+ export type Result = typeof Result.Type;
30
+
31
+ export type UploadOptions = {
32
+ /** Multipart field name. */
33
+ field?: string;
34
+ /** Upload filename. */
35
+ filename?: string;
36
+ };
37
+
38
+ /** Store an image and return its hosted CDN URL (POST `/upload`). */
39
+ export const upload = (
40
+ client: EdgeServiceClient,
41
+ blob: Blob,
42
+ opts?: UploadOptions,
43
+ ): Effect.Effect<Result, EdgeServiceError> => uploadToPath(client, blob, '/upload', opts);
44
+
45
+ /** Store an image and create a thumbnail, returning its hosted CDN URL (POST `/thumbnail`). */
46
+ export const thumbnail = (
47
+ client: EdgeServiceClient,
48
+ blob: Blob,
49
+ opts?: UploadOptions,
50
+ ): Effect.Effect<Result, EdgeServiceError> => uploadToPath(client, blob, '/thumbnail', opts);
51
+
52
+ const uploadToPath = (
53
+ client: EdgeServiceClient,
54
+ blob: Blob,
55
+ path: string,
56
+ opts?: UploadOptions,
57
+ ): Effect.Effect<Result, EdgeServiceError> => {
58
+ const form = new FormData();
59
+ form.append(opts?.field ?? 'file', blob, opts?.filename ?? 'image');
60
+ return client.postForm(path, form, Result);
61
+ };
@@ -0,0 +1,151 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Schema from 'effect/Schema';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { EffectEx } from '@dxos/effect';
10
+
11
+ import { EdgeServiceClient, EdgeServiceError } from './edge-service';
12
+ import * as Image from './Image';
13
+
14
+ const Echo = Schema.Struct({ value: Schema.String });
15
+
16
+ // Minimal Response stub so tests stay free of a real fetch implementation.
17
+ type StubResponse = {
18
+ ok: boolean;
19
+ status: number;
20
+ json?: () => Promise<unknown>;
21
+ text?: () => Promise<string>;
22
+ };
23
+
24
+ type Captured = { url: string; init: RequestInit };
25
+
26
+ const stubFetch = (
27
+ handler: (captured: Captured) => StubResponse | Promise<StubResponse>,
28
+ ): { fetch: typeof globalThis.fetch; calls: Captured[] } => {
29
+ const calls: Captured[] = [];
30
+ const fetch = (async (input: any, init: any) => {
31
+ const captured: Captured = { url: input.toString(), init: init ?? {} };
32
+ calls.push(captured);
33
+ return (await handler(captured)) as unknown as Response;
34
+ }) as typeof globalThis.fetch;
35
+ return { fetch, calls };
36
+ };
37
+
38
+ const json = (body: unknown, status = 200): StubResponse => ({
39
+ ok: status >= 200 && status < 300,
40
+ status,
41
+ json: () => Promise.resolve(body),
42
+ text: () => Promise.resolve(JSON.stringify(body)),
43
+ });
44
+
45
+ describe('EdgeServiceClient', () => {
46
+ test('postJson resolves and decodes the response', async ({ expect }) => {
47
+ const { fetch, calls } = stubFetch(() => json({ value: 'pong' }));
48
+ const client = new EdgeServiceClient({ baseUrl: 'https://edge.test', fetch });
49
+
50
+ const result = await EffectEx.runPromise(client.postJson('/ping', { ping: true }, Echo));
51
+ expect(result).toEqual({ value: 'pong' });
52
+ expect(calls[0].url).toBe('https://edge.test/ping');
53
+ expect((calls[0].init.headers as Headers).get('Content-Type')).toBe('application/json');
54
+ });
55
+
56
+ test('sets the client-tag header when configured', async ({ expect }) => {
57
+ const { fetch, calls } = stubFetch(() => json({ value: 'ok' }));
58
+ const client = new EdgeServiceClient({ baseUrl: 'https://edge.test', clientTag: 'ci-e2e', fetch });
59
+
60
+ await EffectEx.runPromise(client.postJson('/ping', {}, Echo));
61
+ expect((calls[0].init.headers as Headers).get('X-DXOS-Client-Tag')).toBe('ci-e2e');
62
+ });
63
+
64
+ test('merges authHeaders when provided', async ({ expect }) => {
65
+ const { fetch, calls } = stubFetch(() => json({ value: 'ok' }));
66
+ const client = new EdgeServiceClient({
67
+ baseUrl: 'https://edge.test',
68
+ fetch,
69
+ authHeaders: () => Effect.succeed({ Authorization: 'Bearer xyz' }),
70
+ });
71
+
72
+ await EffectEx.runPromise(client.postJson('/ping', {}, Echo));
73
+ expect((calls[0].init.headers as Headers).get('Authorization')).toBe('Bearer xyz');
74
+ });
75
+
76
+ test('non-2xx fails with EdgeServiceError carrying the status', async ({ expect }) => {
77
+ const { fetch } = stubFetch(() => ({ ok: false, status: 422, text: () => Promise.resolve('bad image') }));
78
+ const client = new EdgeServiceClient({ baseUrl: 'https://edge.test', fetch });
79
+
80
+ const error = await EffectEx.runPromise(Effect.flip(client.postForm('/upload', new FormData(), Echo)));
81
+ expect(error).toBeInstanceOf(EdgeServiceError);
82
+ expect(error.status).toBe(422);
83
+ expect(error.message).toContain('bad image');
84
+ });
85
+
86
+ test('malformed JSON fails with EdgeServiceError', async ({ expect }) => {
87
+ const { fetch } = stubFetch(() => ({
88
+ ok: true,
89
+ status: 200,
90
+ json: () => Promise.reject(new Error('Unexpected token')),
91
+ }));
92
+ const client = new EdgeServiceClient({ baseUrl: 'https://edge.test', fetch });
93
+
94
+ const error = await EffectEx.runPromise(Effect.flip(client.postJson('/ping', {}, Echo)));
95
+ expect(error).toBeInstanceOf(EdgeServiceError);
96
+ expect(error.message).toContain('parse JSON');
97
+ });
98
+
99
+ test('schema mismatch fails with EdgeServiceError', async ({ expect }) => {
100
+ const { fetch } = stubFetch(() => json({ wrong: 'shape' }));
101
+ const client = new EdgeServiceClient({ baseUrl: 'https://edge.test', fetch });
102
+
103
+ const error = await EffectEx.runPromise(Effect.flip(client.postJson('/ping', {}, Echo)));
104
+ expect(error).toBeInstanceOf(EdgeServiceError);
105
+ expect(error.message).toContain('schema');
106
+ });
107
+
108
+ test('non-serializable body fails with EdgeServiceError, not a synchronous throw', async ({ expect }) => {
109
+ const { fetch } = stubFetch(() => json({ value: 'ok' }));
110
+ const client = new EdgeServiceClient({ baseUrl: 'https://edge.test', fetch });
111
+ const circular: Record<string, unknown> = {};
112
+ circular.self = circular;
113
+
114
+ const error = await EffectEx.runPromise(Effect.flip(client.postJson('/ping', circular, Echo)));
115
+ expect(error).toBeInstanceOf(EdgeServiceError);
116
+ expect(error.message).toContain('serialize');
117
+ });
118
+
119
+ test('transport rejection fails with EdgeServiceError', async ({ expect }) => {
120
+ const { fetch } = stubFetch(() => Promise.reject(new Error('ECONNREFUSED')));
121
+ const client = new EdgeServiceClient({ baseUrl: 'https://edge.test', fetch });
122
+
123
+ const error = await EffectEx.runPromise(Effect.flip(client.postJson('/ping', {}, Echo)));
124
+ expect(error).toBeInstanceOf(EdgeServiceError);
125
+ expect(error.cause).toBeInstanceOf(Error);
126
+ });
127
+ });
128
+
129
+ describe('Image', () => {
130
+ test('upload posts file field to /upload and returns the hosted URL', async ({ expect }) => {
131
+ const { fetch, calls } = stubFetch(() => json({ id: 'abc', url: 'https://cdn.test/abc/public' }));
132
+ const client = new EdgeServiceClient({ baseUrl: Image.DEFAULT_IMAGE_SERVICE_URL, fetch });
133
+ const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' });
134
+
135
+ const result = await EffectEx.runPromise(Image.upload(client, blob, { filename: 'pic.png' }));
136
+ expect(result).toEqual({ id: 'abc', url: 'https://cdn.test/abc/public' });
137
+ expect(calls[0].url).toBe(`${Image.DEFAULT_IMAGE_SERVICE_URL}/upload`);
138
+ const form = calls[0].init.body as FormData;
139
+ expect(form.get('file')).toBeInstanceOf(Blob);
140
+ });
141
+
142
+ test('thumbnail posts to /thumbnail', async ({ expect }) => {
143
+ const { fetch, calls } = stubFetch(() => json({ url: 'https://cdn.test/x/public' }));
144
+ const client = new EdgeServiceClient({ baseUrl: Image.DEFAULT_IMAGE_SERVICE_URL, fetch });
145
+ const blob = new Blob([new Uint8Array([1])], { type: 'image/png' });
146
+
147
+ const result = await EffectEx.runPromise(Image.thumbnail(client, blob));
148
+ expect(result.url).toBe('https://cdn.test/x/public');
149
+ expect(calls[0].url).toBe(`${Image.DEFAULT_IMAGE_SERVICE_URL}/thumbnail`);
150
+ });
151
+ });
@@ -0,0 +1,139 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Lightweight client for EDGE-hosted HTTP services — intentionally free of the
6
+ // heavy transitive dependencies (`@dxos/context`, `@dxos/protocols`) that
7
+ // `BaseHttpClient` pulls in, so it bundles cleanly into browser / workerd. The
8
+ // only runtime dependencies are `effect` and `@dxos/log`.
9
+
10
+ import * as Data from 'effect/Data';
11
+ import * as Duration from 'effect/Duration';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Schema from 'effect/Schema';
14
+
15
+ // Matches EDGE_CLIENT_TAG_HEADER from @dxos/protocols. Duplicated here (as in
16
+ // `cors-proxy`) to avoid importing the heavy protocols bundle.
17
+ const EDGE_CLIENT_TAG_HEADER = 'X-DXOS-Client-Tag';
18
+
19
+ const DEFAULT_TIMEOUT = Duration.seconds(15);
20
+
21
+ /** Number of body characters retained for the error message on a non-2xx response. */
22
+ const ERROR_BODY_LIMIT = 512;
23
+
24
+ export type EdgeServiceClientOptions = {
25
+ /** Base URL the service is hosted at; request paths resolve against it. */
26
+ baseUrl: string;
27
+ /** Tag included in the {@link EDGE_CLIENT_TAG_HEADER} header for metering. */
28
+ clientTag?: string;
29
+ /** Per-request timeout. */
30
+ timeout?: Duration.DurationInput;
31
+ /** Injectable for deterministic tests; defaults to `globalThis.fetch`. */
32
+ fetch?: typeof globalThis.fetch;
33
+ /** Auth seam invoked per request; unused by the image service today. */
34
+ authHeaders?: () => Effect.Effect<Record<string, string>>;
35
+ };
36
+
37
+ /** Single failure type for every transport, status, and decode error. */
38
+ export class EdgeServiceError extends Data.TaggedError('EdgeServiceError')<{
39
+ message: string;
40
+ status?: number;
41
+ cause?: unknown;
42
+ }> {}
43
+
44
+ export class EdgeServiceClient {
45
+ readonly #baseUrl: string;
46
+ readonly #clientTag: string | undefined;
47
+ readonly #timeout: Duration.Duration;
48
+ readonly #fetch: typeof globalThis.fetch;
49
+ readonly #authHeaders: (() => Effect.Effect<Record<string, string>>) | undefined;
50
+
51
+ constructor(options: EdgeServiceClientOptions) {
52
+ this.#baseUrl = options.baseUrl;
53
+ this.#clientTag = options.clientTag;
54
+ this.#timeout = Duration.decode(options.timeout ?? DEFAULT_TIMEOUT);
55
+ // Bind so `fetch` keeps its expected `this` when injected as a method reference.
56
+ const fetchImpl = options.fetch ?? globalThis.fetch;
57
+ this.#fetch = fetchImpl.bind(globalThis);
58
+ this.#authHeaders = options.authHeaders;
59
+ }
60
+
61
+ get baseUrl(): string {
62
+ return this.#baseUrl;
63
+ }
64
+
65
+ /** POST a `FormData` (multipart) body and decode the JSON response. */
66
+ postForm<A>(path: string, form: FormData, schema: Schema.Schema<A>): Effect.Effect<A, EdgeServiceError> {
67
+ // The runtime sets `Content-Type: multipart/form-data` with the boundary;
68
+ // setting it manually here would omit the boundary and break parsing.
69
+ return this.#request(path, schema, { method: 'POST', body: form });
70
+ }
71
+
72
+ /** POST a JSON body and decode the JSON response. */
73
+ postJson<A>(path: string, body: unknown, schema: Schema.Schema<A>): Effect.Effect<A, EdgeServiceError> {
74
+ // Serialize inside the Effect so a circular/non-serializable body surfaces as
75
+ // EdgeServiceError rather than throwing synchronously from the call site.
76
+ return Effect.try({
77
+ try: () => JSON.stringify(body),
78
+ catch: (cause) => new EdgeServiceError({ message: 'Failed to serialize JSON request body', cause }),
79
+ }).pipe(
80
+ Effect.flatMap((json) =>
81
+ this.#request(path, schema, {
82
+ method: 'POST',
83
+ body: json,
84
+ headers: { 'Content-Type': 'application/json' },
85
+ }),
86
+ ),
87
+ );
88
+ }
89
+
90
+ #request<A>(path: string, schema: Schema.Schema<A>, init: RequestInit): Effect.Effect<A, EdgeServiceError> {
91
+ return Effect.gen(this, function* () {
92
+ const url = new URL(path, this.#baseUrl);
93
+ const headers = new Headers(init.headers ?? undefined);
94
+ if (this.#clientTag) {
95
+ headers.set(EDGE_CLIENT_TAG_HEADER, this.#clientTag);
96
+ }
97
+ if (this.#authHeaders) {
98
+ const auth = yield* this.#authHeaders();
99
+ for (const [key, value] of Object.entries(auth)) {
100
+ headers.set(key, value);
101
+ }
102
+ }
103
+
104
+ const response = yield* Effect.tryPromise({
105
+ try: (signal) => this.#fetch(url, { ...init, headers, signal }),
106
+ catch: (cause) => new EdgeServiceError({ message: `Request to ${url.pathname} failed`, cause }),
107
+ });
108
+
109
+ if (!response.ok) {
110
+ // Body text aids debugging but may be large; truncate and never throw on read.
111
+ const detail = yield* Effect.promise(() => response.text().catch(() => '')).pipe(
112
+ Effect.map((text) => text.slice(0, ERROR_BODY_LIMIT)),
113
+ );
114
+ return yield* new EdgeServiceError({
115
+ message: `Service responded ${response.status}${detail ? `: ${detail}` : ''}`,
116
+ status: response.status,
117
+ });
118
+ }
119
+
120
+ const json = yield* Effect.tryPromise({
121
+ try: () => response.json(),
122
+ catch: (cause) =>
123
+ new EdgeServiceError({ message: 'Failed to parse JSON response', status: response.status, cause }),
124
+ });
125
+
126
+ return yield* Schema.decodeUnknown(schema)(json).pipe(
127
+ Effect.mapError(
128
+ (cause) =>
129
+ new EdgeServiceError({ message: 'Response did not match expected schema', status: response.status, cause }),
130
+ ),
131
+ );
132
+ }).pipe(
133
+ Effect.timeoutFail({
134
+ duration: this.#timeout,
135
+ onTimeout: () => new EdgeServiceError({ message: `Request to ${path} timed out` }),
136
+ }),
137
+ );
138
+ }
139
+ }