@dxos/edge-client 0.8.4-main.fffef41 → 0.9.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.
- package/LICENSE +102 -5
- package/dist/lib/{node-esm/chunk-JTBFRYNM.mjs → neutral/chunk-L5ZHLJ4B.mjs} +54 -47
- package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs.map +7 -0
- package/dist/lib/neutral/chunk-WQKMEZJR.mjs +30 -0
- package/dist/lib/neutral/chunk-WQKMEZJR.mjs.map +7 -0
- package/dist/lib/neutral/cors-proxy.mjs +7 -0
- package/dist/lib/{browser → neutral}/edge-ws-muxer.mjs +1 -1
- package/dist/lib/{browser → neutral}/index.mjs +553 -467
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser → neutral}/testing/index.mjs +6 -31
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/auth.d.ts.map +1 -1
- package/dist/types/src/base-http-client.d.ts +48 -0
- package/dist/types/src/base-http-client.d.ts.map +1 -0
- package/dist/types/src/cors-proxy.d.ts +6 -0
- package/dist/types/src/cors-proxy.d.ts.map +1 -0
- package/dist/types/src/edge-ai-http-client.d.ts +65 -0
- package/dist/types/src/edge-ai-http-client.d.ts.map +1 -0
- package/dist/types/src/edge-client.d.ts +6 -3
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +76 -75
- package/dist/types/src/edge-http-client.d.ts.map +1 -1
- package/dist/types/src/edge-identity.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.d.ts +1 -0
- package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
- package/dist/types/src/edge-ws-muxer.d.ts.map +1 -1
- package/dist/types/src/errors.d.ts.map +1 -1
- package/dist/types/src/http-client.d.ts +2 -2
- package/dist/types/src/http-client.d.ts.map +1 -1
- package/dist/types/src/hub-http-client.d.ts +39 -0
- package/dist/types/src/hub-http-client.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/protocol.d.ts +1 -1
- package/dist/types/src/protocol.d.ts.map +1 -1
- package/dist/types/src/testing/test-server.d.ts.map +1 -1
- package/dist/types/src/testing/test-utils.d.ts +2 -2
- package/dist/types/src/testing/test-utils.d.ts.map +1 -1
- package/dist/types/src/utils.d.ts +1 -1
- package/dist/types/src/utils.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +33 -32
- package/src/base-http-client.ts +243 -0
- package/src/cors-proxy.ts +38 -0
- package/src/edge-ai-http-client.ts +129 -0
- package/src/edge-client.test.ts +16 -11
- package/src/edge-client.ts +37 -7
- package/src/edge-http-client.test.ts +36 -2
- package/src/edge-http-client.ts +237 -270
- package/src/edge-ws-connection.ts +2 -1
- package/src/edge-ws-muxer.ts +49 -5
- package/src/http-client.test.ts +3 -2
- package/src/hub-http-client.ts +118 -0
- package/src/index.ts +4 -0
- package/src/testing/test-utils.ts +4 -4
- package/dist/lib/browser/chunk-VESGVCLQ.mjs +0 -301
- package/dist/lib/browser/chunk-VESGVCLQ.mjs.map +0 -7
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-JTBFRYNM.mjs.map +0 -7
- package/dist/lib/node-esm/edge-ws-muxer.mjs +0 -12
- package/dist/lib/node-esm/index.mjs +0 -1363
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/testing/index.mjs +0 -186
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- /package/dist/lib/{browser/edge-ws-muxer.mjs.map → neutral/cors-proxy.mjs.map} +0 -0
- /package/dist/lib/{node-esm → neutral}/edge-ws-muxer.mjs.map +0 -0
package/package.json
CHANGED
|
@@ -1,68 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/edge-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "EDGE Client",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
-
"
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dxos/dxos"
|
|
10
|
+
},
|
|
11
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
8
12
|
"author": "DXOS.org",
|
|
9
|
-
"sideEffects":
|
|
13
|
+
"sideEffects": false,
|
|
10
14
|
"type": "module",
|
|
11
15
|
"exports": {
|
|
12
16
|
".": {
|
|
13
17
|
"source": "./src/index.ts",
|
|
14
18
|
"types": "./dist/types/src/index.d.ts",
|
|
15
|
-
"
|
|
16
|
-
|
|
19
|
+
"default": "./dist/lib/neutral/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./cors-proxy": {
|
|
22
|
+
"source": "./src/cors-proxy.ts",
|
|
23
|
+
"types": "./dist/types/src/cors-proxy.d.ts",
|
|
24
|
+
"default": "./dist/lib/neutral/cors-proxy.mjs"
|
|
17
25
|
},
|
|
18
26
|
"./muxer": {
|
|
19
27
|
"source": "./src/edge-ws-muxer.ts",
|
|
20
28
|
"types": "./dist/types/src/edge-ws-muxer.d.ts",
|
|
21
|
-
"
|
|
22
|
-
"node": "./dist/lib/node-esm/edge-ws-muxer.mjs"
|
|
29
|
+
"default": "./dist/lib/neutral/edge-ws-muxer.mjs"
|
|
23
30
|
},
|
|
24
31
|
"./testing": {
|
|
25
32
|
"source": "./src/testing/index.ts",
|
|
26
33
|
"types": "./dist/types/src/testing/index.d.ts",
|
|
27
|
-
"
|
|
28
|
-
"node": "./dist/lib/node-esm/testing/index.mjs"
|
|
34
|
+
"default": "./dist/lib/neutral/testing/index.mjs"
|
|
29
35
|
}
|
|
30
36
|
},
|
|
31
37
|
"types": "dist/types/src/index.d.ts",
|
|
32
|
-
"typesVersions": {
|
|
33
|
-
"*": {
|
|
34
|
-
"testing": [
|
|
35
|
-
"dist/types/src/testing/index.d.ts"
|
|
36
|
-
]
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
38
|
"files": [
|
|
40
39
|
"dist",
|
|
41
40
|
"src",
|
|
42
41
|
"README.md"
|
|
43
42
|
],
|
|
44
43
|
"dependencies": {
|
|
45
|
-
"@effect/platform": "
|
|
44
|
+
"@effect/platform": "0.96.1",
|
|
45
|
+
"@opentelemetry/api": "^1.9.1",
|
|
46
46
|
"isomorphic-ws": "^5.0.0",
|
|
47
|
-
"ws": "^8.
|
|
48
|
-
"@dxos/async": "0.
|
|
49
|
-
"@dxos/context": "0.
|
|
50
|
-
"@dxos/credentials": "0.
|
|
51
|
-
"@dxos/crypto": "0.
|
|
52
|
-
"@dxos/
|
|
53
|
-
"@dxos/
|
|
54
|
-
"@dxos/
|
|
55
|
-
"@dxos/
|
|
56
|
-
"@dxos/keys": "0.
|
|
57
|
-
"@dxos/
|
|
58
|
-
"@dxos/
|
|
59
|
-
"@dxos/
|
|
47
|
+
"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"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
|
-
"@dxos/test-utils": "0.
|
|
63
|
+
"@dxos/test-utils": "0.9.0"
|
|
63
64
|
},
|
|
64
65
|
"peerDependencies": {
|
|
65
|
-
"effect": "
|
|
66
|
+
"effect": "3.21.3"
|
|
66
67
|
},
|
|
67
68
|
"publishConfig": {
|
|
68
69
|
"access": "public"
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { sleep } from '@dxos/async';
|
|
6
|
+
import { Context, TRACE_SPAN_ATTRIBUTE, type TraceContextData } from '@dxos/context';
|
|
7
|
+
import { invariant } from '@dxos/invariant';
|
|
8
|
+
import { log } from '@dxos/log';
|
|
9
|
+
import { EDGE_CLIENT_TAG_HEADER, EdgeAuthChallengeError, EdgeCallFailedError, type EdgeFailure } from '@dxos/protocols';
|
|
10
|
+
|
|
11
|
+
import { type EdgeIdentity, handleAuthChallenge } from './edge-identity';
|
|
12
|
+
import { encodeAuthHeader } from './http-client';
|
|
13
|
+
import { getEdgeUrlWithProtocol } from './utils';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_RETRY_TIMEOUT = 1500;
|
|
16
|
+
const DEFAULT_RETRY_JITTER = 500;
|
|
17
|
+
const DEFAULT_MAX_RETRIES_COUNT = 3;
|
|
18
|
+
const WARNING_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
19
|
+
|
|
20
|
+
export type RetryConfig = {
|
|
21
|
+
/** Number of retries, not counting the initial request. */
|
|
22
|
+
count: number;
|
|
23
|
+
/** Delay before retries in ms. */
|
|
24
|
+
timeout?: number;
|
|
25
|
+
/** Random additional delay to spread retries. */
|
|
26
|
+
jitter?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type EdgeHttpCallArgs = {
|
|
30
|
+
retry?: RetryConfig;
|
|
31
|
+
/**
|
|
32
|
+
* Force authentication by pre-fetching `/auth` to obtain the challenge before
|
|
33
|
+
* sending the body. Use for requests with large bodies to avoid sending twice.
|
|
34
|
+
* Not available on HubHttpClient (hub-service has no `/auth` endpoint).
|
|
35
|
+
*/
|
|
36
|
+
auth?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type BaseHttpClientOptions = {
|
|
40
|
+
/**
|
|
41
|
+
* Tag included in the {@link EDGE_CLIENT_TAG_HEADER} header on every request.
|
|
42
|
+
* Used on Edge to classify traffic for metering (e.g. `ci-e2e`).
|
|
43
|
+
*/
|
|
44
|
+
clientTag?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type HttpRequestArgs = {
|
|
48
|
+
method: string;
|
|
49
|
+
retry?: RetryConfig;
|
|
50
|
+
body?: any;
|
|
51
|
+
/** @default true */
|
|
52
|
+
json?: boolean;
|
|
53
|
+
auth?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export abstract class BaseHttpClient {
|
|
57
|
+
protected readonly _baseUrl: string;
|
|
58
|
+
protected readonly _clientTag: string | undefined;
|
|
59
|
+
protected _edgeIdentity: EdgeIdentity | undefined;
|
|
60
|
+
/** Auth header cached until next 401. */
|
|
61
|
+
protected _authHeader: string | undefined;
|
|
62
|
+
|
|
63
|
+
constructor(baseUrl: string, options?: BaseHttpClientOptions) {
|
|
64
|
+
this._baseUrl = getEdgeUrlWithProtocol(baseUrl, 'http');
|
|
65
|
+
this._clientTag = options?.clientTag;
|
|
66
|
+
log('created', { url: this._baseUrl });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get baseUrl() {
|
|
70
|
+
return this._baseUrl;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setIdentity(identity: EdgeIdentity): void {
|
|
74
|
+
if (this._edgeIdentity?.identityKey !== identity.identityKey || this._edgeIdentity?.peerKey !== identity.peerKey) {
|
|
75
|
+
this._edgeIdentity = identity;
|
|
76
|
+
this._authHeader = undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
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
|
+
protected async _call<T>(ctx: Context, url: URL, args: HttpRequestArgs): Promise<T> {
|
|
84
|
+
const shouldRetry = createRetryHandler(args);
|
|
85
|
+
// Log presence/size only — never log raw body contents which may contain PII.
|
|
86
|
+
log('fetch', {
|
|
87
|
+
url,
|
|
88
|
+
hasBody: args.body !== undefined,
|
|
89
|
+
bodySize: typeof args.body === 'string' ? args.body.length : undefined,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const traceHeaders = getTraceHeaders(ctx);
|
|
93
|
+
|
|
94
|
+
let handledAuth = false;
|
|
95
|
+
const tryCount = 1;
|
|
96
|
+
while (true) {
|
|
97
|
+
let processingError: EdgeCallFailedError | undefined = undefined;
|
|
98
|
+
try {
|
|
99
|
+
if (!this._authHeader && args.auth) {
|
|
100
|
+
const response = await fetch(new URL('/auth', this._baseUrl));
|
|
101
|
+
if (response.status === 401) {
|
|
102
|
+
this._authHeader = await this._handleUnauthorized(response);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const request = createRequest(args, this._authHeader, traceHeaders, this._clientTag);
|
|
107
|
+
log('call', { url, tryCount, authHeader: !!this._authHeader });
|
|
108
|
+
const response = await fetch(url, request);
|
|
109
|
+
|
|
110
|
+
if (response.ok) {
|
|
111
|
+
const contentType = response.headers.get('Content-Type') ?? '';
|
|
112
|
+
// No-content responses (204, empty body, non-JSON) — return undefined.
|
|
113
|
+
if (
|
|
114
|
+
response.status === 204 ||
|
|
115
|
+
response.headers.get('Content-Length') === '0' ||
|
|
116
|
+
!contentType.includes('application/json')
|
|
117
|
+
) {
|
|
118
|
+
return undefined as T;
|
|
119
|
+
}
|
|
120
|
+
const body = await response.clone().json();
|
|
121
|
+
if (typeof body !== 'object' || body === null) {
|
|
122
|
+
return body;
|
|
123
|
+
}
|
|
124
|
+
if (!('success' in body)) {
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
if (body.success) {
|
|
128
|
+
return body.data;
|
|
129
|
+
}
|
|
130
|
+
} else if (response.status === 401 && response.headers.get('WWW-Authenticate') !== null && !handledAuth) {
|
|
131
|
+
// Only retry edge auth when the 401 came from edge's own auth layer. Edge always sets
|
|
132
|
+
// `WWW-Authenticate` on its own 401s; upstream-forwarded 401s lack it.
|
|
133
|
+
this._authHeader = await this._handleUnauthorized(response);
|
|
134
|
+
handledAuth = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const contentType = response.headers.get('Content-Type') ?? '';
|
|
139
|
+
const body: EdgeFailure = contentType.startsWith('application/json')
|
|
140
|
+
? await response.clone().json()
|
|
141
|
+
: undefined;
|
|
142
|
+
|
|
143
|
+
invariant(!body?.success, 'Expected body to not be a failure response or undefined.');
|
|
144
|
+
|
|
145
|
+
if (body?.data?.type === 'auth_challenge' && typeof body?.data?.challenge === 'string') {
|
|
146
|
+
processingError = new EdgeAuthChallengeError(body.data.challenge, body.data);
|
|
147
|
+
} else if (body?.success === false) {
|
|
148
|
+
processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
|
|
149
|
+
} else {
|
|
150
|
+
invariant(!response.ok, 'Expected response to not be ok.');
|
|
151
|
+
processingError = await EdgeCallFailedError.fromHttpFailure(response);
|
|
152
|
+
}
|
|
153
|
+
} catch (error: any) {
|
|
154
|
+
processingError = EdgeCallFailedError.fromProcessingFailureCause(error);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (processingError?.isRetryable && (await shouldRetry(ctx, processingError.retryAfterMs))) {
|
|
158
|
+
log.verbose('retrying request', { url, processingError });
|
|
159
|
+
} else {
|
|
160
|
+
throw processingError!;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
protected async _handleUnauthorized(response: Response): Promise<string> {
|
|
166
|
+
if (!this._edgeIdentity) {
|
|
167
|
+
log.warn('unauthorized response received before identity was set');
|
|
168
|
+
throw await EdgeCallFailedError.fromHttpFailure(response);
|
|
169
|
+
}
|
|
170
|
+
const challenge = await handleAuthChallenge(response, this._edgeIdentity);
|
|
171
|
+
return encodeAuthHeader(challenge);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const createRequest = (
|
|
176
|
+
{ method, body, json = true }: HttpRequestArgs,
|
|
177
|
+
authHeader: string | undefined,
|
|
178
|
+
traceHeaders?: Record<string, string>,
|
|
179
|
+
clientTag?: string,
|
|
180
|
+
): RequestInit => {
|
|
181
|
+
let requestBody: BodyInit | undefined;
|
|
182
|
+
const headers: HeadersInit = {};
|
|
183
|
+
|
|
184
|
+
if (json) {
|
|
185
|
+
requestBody = body === undefined ? undefined : JSON.stringify(body);
|
|
186
|
+
headers['Content-Type'] = 'application/json';
|
|
187
|
+
} else {
|
|
188
|
+
requestBody = body;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof requestBody === 'string' && requestBody.length > WARNING_BODY_SIZE) {
|
|
192
|
+
log.warn('Request with large body', { bodySize: requestBody.length });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (authHeader) {
|
|
196
|
+
headers['Authorization'] = authHeader;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (traceHeaders) {
|
|
200
|
+
Object.assign(headers, traceHeaders);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (clientTag) {
|
|
204
|
+
headers[EDGE_CLIENT_TAG_HEADER] = clientTag;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { method, body: requestBody, headers };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const getTraceHeaders = (ctx: Context): Record<string, string> | undefined => {
|
|
211
|
+
const traceCtx = ctx.getAttribute(TRACE_SPAN_ATTRIBUTE) as TraceContextData | undefined;
|
|
212
|
+
if (!traceCtx) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const headers: Record<string, string> = { traceparent: traceCtx.traceparent };
|
|
216
|
+
if (traceCtx.tracestate) {
|
|
217
|
+
headers.tracestate = traceCtx.tracestate;
|
|
218
|
+
}
|
|
219
|
+
return headers;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** @deprecated */
|
|
223
|
+
const createRetryHandler = ({ retry }: HttpRequestArgs) => {
|
|
224
|
+
if (!retry || retry.count < 1) {
|
|
225
|
+
return async () => false;
|
|
226
|
+
}
|
|
227
|
+
let retries = 0;
|
|
228
|
+
const maxRetries = retry.count ?? DEFAULT_MAX_RETRIES_COUNT;
|
|
229
|
+
const baseTimeout = retry.timeout ?? DEFAULT_RETRY_TIMEOUT;
|
|
230
|
+
const jitter = retry.jitter ?? DEFAULT_RETRY_JITTER;
|
|
231
|
+
return async (ctx: Context, retryAfter?: number) => {
|
|
232
|
+
if (++retries > maxRetries || ctx.disposed) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
if (retryAfter) {
|
|
236
|
+
await sleep(retryAfter);
|
|
237
|
+
} else {
|
|
238
|
+
const timeout = baseTimeout + Math.random() * jitter;
|
|
239
|
+
await sleep(timeout);
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
};
|
|
243
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// Lightweight CORS-proxy helpers — intentionally free of heavy transitive
|
|
6
|
+
// dependencies so they can be bundled into workerd / browser environments
|
|
7
|
+
// without pulling in protobufjs or similar node-only packages.
|
|
8
|
+
|
|
9
|
+
const LEGACY_CORS_PROXY_URL = 'https://cors-proxy.dxos.workers.dev';
|
|
10
|
+
|
|
11
|
+
// Matches EDGE_CLIENT_TAG_HEADER from @dxos/protocols.
|
|
12
|
+
// Duplicated here to avoid importing the heavy protocols bundle in edge environments.
|
|
13
|
+
const EDGE_CLIENT_TAG_HEADER = 'X-DXOS-Client-Tag';
|
|
14
|
+
|
|
15
|
+
const remapAuthorizationForProxy = (headers: Headers): Headers => {
|
|
16
|
+
const callerAuth = headers.get('Authorization');
|
|
17
|
+
if (callerAuth !== null) {
|
|
18
|
+
headers.delete('Authorization');
|
|
19
|
+
headers.set('X-Cors-Proxy-Authorization', callerAuth);
|
|
20
|
+
}
|
|
21
|
+
return headers;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch through the legacy standalone open proxy at `cors-proxy.dxos.workers.dev`.
|
|
26
|
+
* TEMPORARY — delete when the authenticated `/proxy/*` route on edge ships.
|
|
27
|
+
*/
|
|
28
|
+
export const proxyFetchLegacy = (target: URL, init: RequestInit = {}, clientTag?: string): Promise<Response> => {
|
|
29
|
+
const proxyUrl = new URL(`/${target.host}${target.pathname}${target.search}`, LEGACY_CORS_PROXY_URL);
|
|
30
|
+
if (target.protocol === 'http:') {
|
|
31
|
+
proxyUrl.searchParams.set('scheme', 'http');
|
|
32
|
+
}
|
|
33
|
+
const requestHeaders = remapAuthorizationForProxy(new Headers(init.headers ?? undefined));
|
|
34
|
+
if (clientTag) {
|
|
35
|
+
requestHeaders.set(EDGE_CLIENT_TAG_HEADER, clientTag);
|
|
36
|
+
}
|
|
37
|
+
return fetch(proxyUrl, { ...init, headers: requestHeaders });
|
|
38
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Headers from '@effect/platform/Headers';
|
|
6
|
+
import * as HttpClient from '@effect/platform/HttpClient';
|
|
7
|
+
import * as HttpClientError from '@effect/platform/HttpClientError';
|
|
8
|
+
import * as HttpClientResponse from '@effect/platform/HttpClientResponse';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as FiberRef from 'effect/FiberRef';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as Stream from 'effect/Stream';
|
|
13
|
+
|
|
14
|
+
import { BaseError, type BaseErrorOptions } from '@dxos/errors';
|
|
15
|
+
import { log } from '@dxos/log';
|
|
16
|
+
import { BYOK_HEADER } from '@dxos/protocols';
|
|
17
|
+
|
|
18
|
+
import { type EdgeHttpClient } from './edge-http-client';
|
|
19
|
+
|
|
20
|
+
export type GetEdgeHttpClient = () => EdgeHttpClient;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Thrown by {@link EdgeAiHttpClient} when an AI request carrying {@link BYOK_HEADER} is rejected
|
|
24
|
+
* with 401/403 by the upstream provider — i.e. the user-supplied API key is invalid. Wrapped as
|
|
25
|
+
* the `cause` of an `HttpClientError.ResponseError` so it flows through `@effect/ai`'s error
|
|
26
|
+
* mapping; callers walk the cause chain (via {@link ByokError.is}) to render a useful message.
|
|
27
|
+
*/
|
|
28
|
+
export class ByokError extends BaseError.extend('ByokError', 'BYOK authentication failed') {
|
|
29
|
+
constructor(options: { status: number; provider: string } & BaseErrorOptions) {
|
|
30
|
+
super({ context: { status: options.status, provider: options.provider }, ...options });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Copy pasted from https://github.com/Effect-TS/effect/blob/main/packages/platform/src/internal/fetchHttpClient.ts
|
|
36
|
+
*/
|
|
37
|
+
export const requestInitTagKey = '@effect/platform/FetchHttpClient/FetchOptions';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An `@effect/platform` {@link HttpClient.HttpClient} that routes requests through the
|
|
41
|
+
* authenticated EDGE AI endpoint via {@link EdgeHttpClient.anthropicAiRequest}, instead of
|
|
42
|
+
* fetching the AI service directly.
|
|
43
|
+
*
|
|
44
|
+
* Provide this layer in place of `FetchHttpClient.layer` when constructing an Anthropic client,
|
|
45
|
+
* e.g. `AnthropicClient.layer({ apiUrl: 'http://edge' }).pipe(Layer.provide(EdgeAiHttpClient.layer(() => edgeClient)))`.
|
|
46
|
+
* The `apiUrl` host is a sentinel; only the request path is forwarded (see `anthropicAiRequest`).
|
|
47
|
+
*
|
|
48
|
+
* Modeled on `FunctionsAiHttpClient` in `@dxos/functions`.
|
|
49
|
+
*/
|
|
50
|
+
export class EdgeAiHttpClient {
|
|
51
|
+
static make = (getClient: GetEdgeHttpClient) =>
|
|
52
|
+
HttpClient.make((request, url, signal, fiber) => {
|
|
53
|
+
const edgeClient = getClient();
|
|
54
|
+
const context = fiber.getFiberRef(FiberRef.currentContext);
|
|
55
|
+
const options: RequestInit = context.unsafeMap.get(requestInitTagKey) ?? {};
|
|
56
|
+
const headers = options.headers
|
|
57
|
+
? Headers.merge(Headers.fromInput(options.headers), request.headers)
|
|
58
|
+
: request.headers;
|
|
59
|
+
|
|
60
|
+
const carriedByok = !!headers[BYOK_HEADER.toLowerCase()];
|
|
61
|
+
|
|
62
|
+
const send = (body: BodyInit | undefined) =>
|
|
63
|
+
Effect.tryPromise({
|
|
64
|
+
try: () =>
|
|
65
|
+
edgeClient.anthropicAiRequest(
|
|
66
|
+
new Request(url, {
|
|
67
|
+
...options,
|
|
68
|
+
method: request.method,
|
|
69
|
+
headers,
|
|
70
|
+
body,
|
|
71
|
+
signal,
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
catch: (cause) => {
|
|
75
|
+
log.error('Failed to fetch', { cause });
|
|
76
|
+
return new HttpClientError.RequestError({
|
|
77
|
+
request,
|
|
78
|
+
reason: 'Transport',
|
|
79
|
+
cause,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
}).pipe(
|
|
83
|
+
Effect.flatMap((response) => {
|
|
84
|
+
const httpResponse = HttpClientResponse.fromWeb(request, response);
|
|
85
|
+
// A 401/403 on a BYOK-carrying request means the user's upstream key was rejected.
|
|
86
|
+
// Wrap as a typed ResponseError with `cause: ByokError` so it survives AiError's
|
|
87
|
+
// `fromRequestError` mapping; callers walk the cause chain to render a useful message.
|
|
88
|
+
if (carriedByok && (response.status === 401 || response.status === 403)) {
|
|
89
|
+
return Effect.tryPromise({
|
|
90
|
+
try: () => response.clone().json() as Promise<{ error?: { message?: string } } | undefined>,
|
|
91
|
+
catch: () => undefined,
|
|
92
|
+
}).pipe(
|
|
93
|
+
Effect.orElseSucceed(() => undefined),
|
|
94
|
+
Effect.flatMap((body) =>
|
|
95
|
+
Effect.fail(
|
|
96
|
+
new HttpClientError.ResponseError({
|
|
97
|
+
request,
|
|
98
|
+
response: httpResponse,
|
|
99
|
+
reason: 'StatusCode',
|
|
100
|
+
cause: new ByokError({
|
|
101
|
+
status: response.status,
|
|
102
|
+
provider: 'anthropic.com',
|
|
103
|
+
message: body?.error?.message ?? 'Authentication failed',
|
|
104
|
+
}),
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return Effect.succeed(httpResponse);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
switch (request.body._tag) {
|
|
115
|
+
case 'Raw':
|
|
116
|
+
case 'Uint8Array':
|
|
117
|
+
return send(request.body.body as any);
|
|
118
|
+
case 'FormData':
|
|
119
|
+
return send(request.body.formData);
|
|
120
|
+
case 'Stream':
|
|
121
|
+
return Stream.toReadableStreamEffect(request.body.stream).pipe(Effect.flatMap(send));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return send(undefined);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
static layer = (getClient: GetEdgeHttpClient) =>
|
|
128
|
+
Layer.succeed(HttpClient.HttpClient, EdgeAiHttpClient.make(getClient));
|
|
129
|
+
}
|
package/src/edge-client.test.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { describe, expect, onTestFinished, test } from 'vitest';
|
|
6
6
|
|
|
7
7
|
import { Trigger } from '@dxos/async';
|
|
8
|
+
import { Context } from '@dxos/context';
|
|
8
9
|
import { Keyring } from '@dxos/keyring';
|
|
9
10
|
import { TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
|
|
10
11
|
import { EdgeStatus } from '@dxos/protocols/proto/dxos/client/services';
|
|
@@ -25,13 +26,13 @@ describe('EdgeClient', () => {
|
|
|
25
26
|
onTestFinished(cleanup);
|
|
26
27
|
|
|
27
28
|
const { client, reconnectTrigger } = await openNewClient(endpoint);
|
|
28
|
-
await client.send(textMessage('Hello world 1'));
|
|
29
|
+
await client.send(Context.default(), textMessage('Hello world 1'));
|
|
29
30
|
expect(client.isOpen).is.true;
|
|
30
31
|
|
|
31
32
|
reconnectTrigger.reset();
|
|
32
33
|
await closeConnection();
|
|
33
34
|
await reconnectTrigger.wait();
|
|
34
|
-
await expect(client.send(textMessage('Hello world 2'))).resolves.not.toThrow();
|
|
35
|
+
await expect(client.send(Context.default(), textMessage('Hello world 2'))).resolves.not.toThrow();
|
|
35
36
|
});
|
|
36
37
|
|
|
37
38
|
test('isConnected', async () => {
|
|
@@ -59,13 +60,13 @@ describe('EdgeClient', () => {
|
|
|
59
60
|
onTestFinished(cleanup);
|
|
60
61
|
|
|
61
62
|
const { client, reconnectTrigger } = await openNewClient(endpoint);
|
|
62
|
-
await client.send(textMessage('Hello world 1'));
|
|
63
|
+
await client.send(Context.default(), textMessage('Hello world 1'));
|
|
63
64
|
expect(client.isOpen).is.true;
|
|
64
65
|
|
|
65
66
|
reconnectTrigger.reset();
|
|
66
67
|
client.setIdentity(await createEphemeralEdgeIdentity());
|
|
67
68
|
await reconnectTrigger.wait();
|
|
68
|
-
await expect(client.send(textMessage('Hello world 2'))).resolves.not.toThrow();
|
|
69
|
+
await expect(client.send(Context.default(), textMessage('Hello world 2'))).resolves.not.toThrow();
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
test('send blocks until connection becomes ready', async () => {
|
|
@@ -75,7 +76,7 @@ describe('EdgeClient', () => {
|
|
|
75
76
|
|
|
76
77
|
const { client } = await openNewClient(endpoint);
|
|
77
78
|
setTimeout(() => admitConnection.wake(), 20);
|
|
78
|
-
await client.send(textMessage('Hello world 1'));
|
|
79
|
+
await client.send(Context.default(), textMessage('Hello world 1'));
|
|
79
80
|
await expect.poll(() => messageSink.length).toBe(1);
|
|
80
81
|
});
|
|
81
82
|
|
|
@@ -86,11 +87,13 @@ describe('EdgeClient', () => {
|
|
|
86
87
|
|
|
87
88
|
const { client } = await openNewClient(endpoint);
|
|
88
89
|
setTimeout(async () => client.setIdentity(await createEphemeralEdgeIdentity()));
|
|
89
|
-
await expect(client.send(textMessage('Hello world 1'))).rejects.toThrow(
|
|
90
|
+
await expect(client.send(Context.default(), textMessage('Hello world 1'))).rejects.toThrow(
|
|
91
|
+
EdgeIdentityChangedError,
|
|
92
|
+
);
|
|
90
93
|
|
|
91
94
|
// Test recovers.
|
|
92
95
|
setTimeout(() => admitConnection.wake(), 20);
|
|
93
|
-
await client.send(textMessage('Hello world 1'));
|
|
96
|
+
await client.send(Context.default(), textMessage('Hello world 1'));
|
|
94
97
|
await expect.poll(() => messageSink.length).toBe(1);
|
|
95
98
|
});
|
|
96
99
|
|
|
@@ -101,7 +104,9 @@ describe('EdgeClient', () => {
|
|
|
101
104
|
|
|
102
105
|
const { client } = await openNewClient(endpoint);
|
|
103
106
|
setTimeout(() => client.close());
|
|
104
|
-
await expect(client.send(textMessage('Hello world 1'))).rejects.toThrow(
|
|
107
|
+
await expect(client.send(Context.default(), textMessage('Hello world 1'))).rejects.toThrow(
|
|
108
|
+
EdgeConnectionClosedError,
|
|
109
|
+
);
|
|
105
110
|
});
|
|
106
111
|
|
|
107
112
|
test('onReconnect trigger', async () => {
|
|
@@ -129,12 +134,12 @@ describe('EdgeClient', () => {
|
|
|
129
134
|
onTestFinished(cleanup);
|
|
130
135
|
|
|
131
136
|
const { client, identity: oldIdentity } = await openNewClient(endpoint);
|
|
132
|
-
await client.send(textMessage('Hello world 1', oldIdentity));
|
|
137
|
+
await client.send(Context.default(), textMessage('Hello world 1', oldIdentity));
|
|
133
138
|
expect(client.isOpen).is.true;
|
|
134
139
|
|
|
135
140
|
const newIdentity = await createEphemeralEdgeIdentity();
|
|
136
141
|
client.setIdentity(newIdentity);
|
|
137
|
-
await client.send(textMessage('Hello world 2', newIdentity));
|
|
142
|
+
await client.send(Context.default(), textMessage('Hello world 2', newIdentity));
|
|
138
143
|
await expect.poll(() => messageSourceLog.length).toBe(2);
|
|
139
144
|
expect(messageSourceLog.map((m) => m.peerKey)).toStrictEqual([oldIdentity.peerKey, newIdentity.peerKey]);
|
|
140
145
|
});
|
|
@@ -147,7 +152,7 @@ describe('EdgeClient', () => {
|
|
|
147
152
|
|
|
148
153
|
const client = new EdgeClient(identity, { socketEndpoint: process.env.EDGE_ENDPOINT! });
|
|
149
154
|
await openAndClose(client);
|
|
150
|
-
await client.send(textMessage('Hello world 1'));
|
|
155
|
+
await client.send(Context.default(), textMessage('Hello world 1'));
|
|
151
156
|
expect(client.isOpen).is.true;
|
|
152
157
|
});
|
|
153
158
|
|