@dxos/edge-client 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/edge-client",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "EDGE Client",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -61,23 +61,23 @@
61
61
  "@bufbuild/protobuf": "2.11.0",
62
62
  "isomorphic-ws": "^5.0.0",
63
63
  "ws": "^8.17.1",
64
- "@dxos/async": "0.13.0",
65
- "@dxos/context": "0.13.0",
66
- "@dxos/credentials": "0.13.0",
67
- "@dxos/crypto": "0.13.0",
68
- "@dxos/effect": "0.13.0",
69
- "@dxos/errors": "0.13.0",
70
- "@dxos/invariant": "0.13.0",
71
- "@dxos/keyring": "0.13.0",
72
- "@dxos/keys": "0.13.0",
73
- "@dxos/log": "0.13.0",
74
- "@dxos/node-std": "0.13.0",
75
- "@dxos/protocols": "0.13.0",
76
- "@dxos/tracing": "0.13.0",
77
- "@dxos/util": "0.13.0"
64
+ "@dxos/async": "0.14.0",
65
+ "@dxos/context": "0.14.0",
66
+ "@dxos/credentials": "0.14.0",
67
+ "@dxos/crypto": "0.14.0",
68
+ "@dxos/effect": "0.14.0",
69
+ "@dxos/errors": "0.14.0",
70
+ "@dxos/invariant": "0.14.0",
71
+ "@dxos/keyring": "0.14.0",
72
+ "@dxos/keys": "0.14.0",
73
+ "@dxos/log": "0.14.0",
74
+ "@dxos/node-std": "0.14.0",
75
+ "@dxos/protocols": "0.14.0",
76
+ "@dxos/tracing": "0.14.0",
77
+ "@dxos/util": "0.14.0"
78
78
  },
79
79
  "devDependencies": {
80
- "@dxos/test-utils": "0.13.0"
80
+ "@dxos/test-utils": "0.14.0"
81
81
  },
82
82
  "peerDependencies": {
83
83
  "effect": "4.0.0"
@@ -5,8 +5,10 @@
5
5
  import { create } from '@bufbuild/protobuf';
6
6
  import { afterEach, describe, test, vi } from 'vitest';
7
7
 
8
+ import { EDGE_CLIENT_TOO_OLD, EDGE_CLIENT_VERSION_HEADER, EdgeClientTooOldError, EdgeResponse } from '@dxos/protocols';
8
9
  import { type Presentation, PresentationSchema } from '@dxos/protocols/buf/dxos/halo/credentials_pb';
9
10
 
11
+ import { version as packageVersion } from '../package.json';
10
12
  import {
11
13
  authenticateViaChallengeEndpoint,
12
14
  fetchAuthChallenge,
@@ -135,15 +137,28 @@ describe('fetchAuthChallenge', () => {
135
137
  vi.unstubAllGlobals();
136
138
  });
137
139
 
138
- test('GETs /auth relative to the base URL', async ({ expect }) => {
140
+ test('GETs /auth relative to the base URL, advertising the SDK version', async ({ expect }) => {
139
141
  // Typed with the input parameter so `mock.calls[0][0]` is a tuple element rather than `never`.
140
- const fetchMock = vi.fn(async (_input: URL | RequestInfo) =>
142
+ const fetchMock = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) =>
141
143
  jsonResponse({ success: true, data: { challenge: CHALLENGE } }),
142
144
  );
143
145
  vi.stubGlobal('fetch', fetchMock);
144
146
 
145
147
  expect(await fetchAuthChallenge('https://edge.example.com')).toBe(CHALLENGE);
146
148
  expect(String(fetchMock.mock.calls[0][0])).toBe('https://edge.example.com/auth');
149
+ expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get(EDGE_CLIENT_VERSION_HEADER)).toBe(packageVersion);
150
+ });
151
+
152
+ test('throws when EDGE refuses the SDK as too old, rather than falling back', async ({ expect }) => {
153
+ // Every fallback carries the same version, so swallowing the refusal would only retry into it.
154
+ vi.stubGlobal('fetch', async () =>
155
+ EdgeResponse.failure({
156
+ message: 'Client too old: SDK 0.12.0 is older than 0.13.0. Update the app (reload it) to continue.',
157
+ status: 426,
158
+ data: { type: EDGE_CLIENT_TOO_OLD, clientVersion: '0.12.0', minimumVersion: '0.13.0' },
159
+ }),
160
+ );
161
+ await expect(fetchAuthChallenge('https://edge.example.com')).rejects.toBeInstanceOf(EdgeClientTooOldError);
147
162
  });
148
163
 
149
164
  test('still works against a server whose /auth only answers 401', async ({ expect }) => {
@@ -7,8 +7,10 @@ import { base64Decode } from '@bufbuild/protobuf/wire';
7
7
 
8
8
  import { invariant } from '@dxos/invariant';
9
9
  import { log } from '@dxos/log';
10
+ import { EdgeClientTooOldError } from '@dxos/protocols';
10
11
  import { PresentationSchema } from '@dxos/protocols/buf/dxos/halo/credentials_pb';
11
12
 
13
+ import { assertClientSupported, clientVersionHeaders } from './client-version.ts';
12
14
  import { type EdgeIdentity } from './edge-identity.ts';
13
15
 
14
16
  /**
@@ -165,11 +167,13 @@ export type AuthChallengeInfo = {
165
167
  * console and records a routine auth failure server-side on each client boot.
166
168
  *
167
169
  * Returns undefined if the endpoint is unreachable or answers in neither known shape; callers fall
168
- * back to the 401 path, which still works against every server.
170
+ * back to the 401 path, which still works against every server. Throws {@link EdgeClientTooOldError} when EDGE refuses
171
+ * this SDK, which no fallback can get past.
169
172
  */
170
173
  export const fetchAuthChallengeInfo = async (baseHttpUrl: string | URL): Promise<AuthChallengeInfo | undefined> => {
171
174
  try {
172
- const response = await fetch(new URL('/auth', baseHttpUrl));
175
+ const response = await fetch(new URL('/auth', baseHttpUrl), { headers: clientVersionHeaders() });
176
+ await assertClientSupported(response);
173
177
  const challenge = await readAuthChallenge(response);
174
178
  if (!challenge) {
175
179
  log.verbose('no challenge in /auth response', { status: response.status });
@@ -186,6 +190,9 @@ export const fetchAuthChallengeInfo = async (baseHttpUrl: string | URL): Promise
186
190
  }
187
191
  return { challenge, expiresInMs };
188
192
  } catch (error) {
193
+ if (error instanceof EdgeClientTooOldError) {
194
+ throw error;
195
+ }
189
196
  log.verbose('failed to fetch auth challenge', { error });
190
197
  return undefined;
191
198
  }
@@ -6,7 +6,14 @@ import { sleep } from '@dxos/async';
6
6
  import { Context, TRACE_SPAN_ATTRIBUTE, type TraceContextData } from '@dxos/context';
7
7
  import { invariant } from '@dxos/invariant';
8
8
  import { log } from '@dxos/log';
9
- import { EDGE_CLIENT_TAG_HEADER, EdgeAuthChallengeError, EdgeCallFailedError, type EdgeFailure } from '@dxos/protocols';
9
+ import {
10
+ EDGE_CLIENT_TAG_HEADER,
11
+ EDGE_CLIENT_TOO_OLD,
12
+ EdgeAuthChallengeError,
13
+ EdgeCallFailedError,
14
+ EdgeClientTooOldError,
15
+ type EdgeFailure,
16
+ } from '@dxos/protocols';
10
17
 
11
18
  import { authenticateViaChallengeEndpoint, handleAuthChallenge, parseChallengeHeader } from './auth-challenge.ts';
12
19
  import { type EdgeIdentity } from './edge-identity.ts';
@@ -193,6 +200,8 @@ export abstract class BaseHttpClient {
193
200
 
194
201
  if (body?.data?.type === 'auth_challenge' && typeof body?.data?.challenge === 'string') {
195
202
  processingError = new EdgeAuthChallengeError(body.data.challenge, body.data);
203
+ } else if (body?.success === false && body.data?.type === EDGE_CLIENT_TOO_OLD) {
204
+ processingError = new EdgeClientTooOldError(body, response.status);
196
205
  } else if (body?.success === false) {
197
206
  processingError = EdgeCallFailedError.fromUnsuccessfulResponse(response, body);
198
207
  } else {
@@ -293,12 +302,16 @@ export abstract class BaseHttpClient {
293
302
  * device with no HALO chain (mid-invitation), and without this the rejection would surface from
294
303
  * `_call` as a failed request — turning a call that used to succeed unauthenticated into an error.
295
304
  */
296
- private _prefetchAuthHeader(): Promise<void> {
305
+ protected _prefetchAuthHeader(): Promise<void> {
297
306
  if (this._authPrefetch) {
298
307
  return this._authPrefetch;
299
308
  }
300
309
  const prefetch: Promise<void> = this._prefetchAuthHeaderOnce()
301
310
  .catch((err) => {
311
+ // Unauthenticated would only be refused again, and silently; the caller has to see it.
312
+ if (err instanceof EdgeClientTooOldError) {
313
+ throw err;
314
+ }
302
315
  log.verbose('auth prefetch failed; proceeding unauthenticated', { err });
303
316
  })
304
317
  .finally(() => {
@@ -0,0 +1,44 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import {
6
+ EDGE_CLIENT_TOO_OLD,
7
+ EDGE_CLIENT_VERSION_HEADER,
8
+ EDGE_CLIENT_VERSION_PROTOCOL_PREFIX,
9
+ EdgeClientTooOldError,
10
+ type EdgeFailure,
11
+ } from '@dxos/protocols';
12
+
13
+ import { version } from '../package.json';
14
+
15
+ /**
16
+ * This SDK's version, which EDGE checks against the oldest it serves. It releases in lockstep with the rest of the
17
+ * SDK, and Vite tree-shakes the import to this one field.
18
+ */
19
+ export const CLIENT_SDK_VERSION: string = version;
20
+
21
+ /** Headers advertising {@link CLIENT_SDK_VERSION} on a request of the EDGE connect flow (`/auth` and its fallback). */
22
+ export const clientVersionHeaders = (): Record<string, string> => ({
23
+ [EDGE_CLIENT_VERSION_HEADER]: CLIENT_SDK_VERSION,
24
+ });
25
+
26
+ /** The same advertisement for the WebSocket upgrade, where a browser can set no header but the subprotocol list. */
27
+ export const clientVersionProtocol = (): string => `${EDGE_CLIENT_VERSION_PROTOCOL_PREFIX}${CLIENT_SDK_VERSION}`;
28
+
29
+ /**
30
+ * Throws {@link EdgeClientTooOldError} when `response` is EDGE's failure envelope refusing this SDK. Matched on the
31
+ * envelope's `data.type`, as any WebSocket endpoint answers a plain GET with a bare 426 "Upgrade Required".
32
+ */
33
+ export const assertClientSupported = async (response: Response): Promise<void> => {
34
+ if (response.ok || !response.headers.get('Content-Type')?.startsWith('application/json')) {
35
+ return;
36
+ }
37
+ const body: EdgeFailure | undefined = await response
38
+ .clone()
39
+ .json()
40
+ .catch(() => undefined);
41
+ if (body?.success === false && body.data?.type === EDGE_CLIENT_TOO_OLD) {
42
+ throw new EdgeClientTooOldError(body, response.status);
43
+ }
44
+ };
@@ -2,11 +2,12 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { describe, expect, onTestFinished, test } from 'vitest';
5
+ import { describe, expect, onTestFinished, test, vi } from 'vitest';
6
6
 
7
- import { Trigger } from '@dxos/async';
7
+ import { Trigger, sleep } from '@dxos/async';
8
8
  import { Context } from '@dxos/context';
9
9
  import { Keyring } from '@dxos/keyring';
10
+ import { EDGE_CLIENT_TOO_OLD, EdgeClientTooOldError, EdgeResponse } from '@dxos/protocols';
10
11
  import { EdgeStatus_ConnectionState } from '@dxos/protocols/buf/dxos/client/services_pb';
11
12
  import { TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
12
13
  import { openAndClose } from '@dxos/test-utils';
@@ -179,6 +180,31 @@ describe('EdgeClient', () => {
179
180
  expect(client.isOpen).is.true;
180
181
  });
181
182
 
183
+ // Every retry would carry the same version and be refused the same way, so the client must stop and say why.
184
+ test('a refusal at /auth stops reconnecting and fails sends with the refusal', async () => {
185
+ const fetchMock = vi.fn(async (_input: URL | RequestInfo) =>
186
+ EdgeResponse.failure({
187
+ message: 'Client too old: SDK 0.12.0 is older than 0.13.0. Update the app (reload it) to continue.',
188
+ status: 426,
189
+ data: { type: EDGE_CLIENT_TOO_OLD, clientVersion: '0.12.0', minimumVersion: '0.13.0' },
190
+ }),
191
+ );
192
+ vi.stubGlobal('fetch', fetchMock);
193
+ onTestFinished(() => {
194
+ vi.unstubAllGlobals();
195
+ });
196
+
197
+ const client = new EdgeClient(await createEphemeralEdgeIdentity(), { socketEndpoint: 'ws://127.0.0.1:1' });
198
+ await openAndClose(client);
199
+
200
+ await expect(client.send(Context.default(), textMessage('Hello'))).rejects.toBeInstanceOf(EdgeClientTooOldError);
201
+ // Several backoff periods (100 ms, doubling): a retrying client would have fetched /auth again by now.
202
+ await sleep(1_000);
203
+ // Counted by host: an earlier test's client may still be reconnecting through the same stub.
204
+ expect(fetchMock.mock.calls.filter((call) => new URL(String(call[0])).host === '127.0.0.1:1')).toHaveLength(1);
205
+ await expect(client.send(Context.default(), textMessage('Hello'))).rejects.toBeInstanceOf(EdgeClientTooOldError);
206
+ });
207
+
182
208
  const textMessage = (message: string, source?: EdgeIdentity) =>
183
209
  protocol.createMessage(TextMessageSchema, {
184
210
  source: source && { peerKey: source.peerKey, identityDid: source.identityDid },
@@ -17,7 +17,7 @@ import {
17
17
  import { Context, TRACE_SPAN_ATTRIBUTE, type TraceContextData } from '@dxos/context';
18
18
  import { type Lifecycle, Resource } from '@dxos/context';
19
19
  import { log, logInfo } from '@dxos/log';
20
- import { EdgeCredentialsHeaderCodec } from '@dxos/protocols';
20
+ import { EdgeClientTooOldError, EdgeCredentialsHeaderCodec } from '@dxos/protocols';
21
21
  import {
22
22
  type EdgeStatus,
23
23
  EdgeStatus_ConnectionState,
@@ -31,6 +31,7 @@ import {
31
31
  presentCredentialsForChallenge,
32
32
  readAuthChallenge,
33
33
  } from './auth-challenge.ts';
34
+ import { assertClientSupported, clientVersionHeaders } from './client-version.ts';
34
35
  import { protocol } from './defs.ts';
35
36
  import { type EdgeIdentity } from './edge-identity.ts';
36
37
  import { EdgeWsConnection } from './edge-ws-connection.ts';
@@ -120,6 +121,8 @@ export class EdgeClient extends Resource implements EdgeConnection {
120
121
  private readonly _baseHttpUrl: string;
121
122
  private _currentConnection?: EdgeWsConnection = undefined;
122
123
  private _ready = new Trigger();
124
+ /** Set when EDGE refused this SDK as outdated; a retry would carry the same version, so none is made. */
125
+ #refusal: EdgeClientTooOldError | undefined = undefined;
123
126
 
124
127
  constructor(
125
128
  private _identity: EdgeIdentity,
@@ -177,6 +180,9 @@ export class EdgeClient extends Resource implements EdgeConnection {
177
180
  * NOTE: The message is guaranteed to be delivered but the service must respond with a message to confirm processing.
178
181
  */
179
182
  public async send(ctx: Context, message: Message) {
183
+ if (this.#refusal) {
184
+ throw this.#refusal;
185
+ }
180
186
  if (this._ready.state !== TriggerState.RESOLVED) {
181
187
  log('waiting for websocket');
182
188
  await this._ready.wait({ timeout: this._config.timeout ?? DEFAULT_TIMEOUT });
@@ -289,7 +295,22 @@ export class EdgeClient extends Resource implements EdgeConnection {
289
295
 
290
296
  const identity = this._identity;
291
297
  const path = `/ws/${identity.identityDid}/${identity.peerKey}`;
292
- const protocolHeader = this._config.disableAuth ? undefined : await this._createAuthHeader(path);
298
+ this.#refusal = undefined;
299
+ let protocolHeader: string | undefined;
300
+ try {
301
+ protocolHeader = this._config.disableAuth ? undefined : await this._createAuthHeader(path);
302
+ } catch (err) {
303
+ if (!(err instanceof EdgeClientTooOldError)) {
304
+ throw err;
305
+ }
306
+ // Returning, not throwing, ends PersistentLifecycle's backoff loop: every retry would be refused the same way.
307
+ log.warn('EDGE refused this SDK as outdated; not reconnecting until the app is updated', { data: err.data });
308
+ this.#refusal = err;
309
+ this._ready.throw(err);
310
+ this._ready.reset();
311
+ this.statusChanged.emit(this.status);
312
+ return undefined;
313
+ }
293
314
  if (this._identity !== identity) {
294
315
  log('identity changed during auth header request');
295
316
  return undefined;
@@ -446,7 +467,8 @@ export class EdgeClient extends Resource implements EdgeConnection {
446
467
  return encodePresentationWsAuthHeader(authentication.presentation);
447
468
  }
448
469
 
449
- const response = await fetch(new URL(path, this._baseHttpUrl), { method: 'GET' });
470
+ const response = await fetch(new URL(path, this._baseHttpUrl), { method: 'GET', headers: clientVersionHeaders() });
471
+ await assertClientSupported(response);
450
472
  // Gate on a parsed VP challenge, not merely on a 401. A 401 forwarded from upstream can carry
451
473
  // an unrelated `WWW-Authenticate` (or none), and signing a challenge that isn't there would
452
474
  // throw instead of degrading to an unauthenticated attempt.
@@ -6,6 +6,7 @@ import { create } from '@bufbuild/protobuf';
6
6
  import { afterEach, describe, it, test, vi } from 'vitest';
7
7
 
8
8
  import { Context } from '@dxos/context';
9
+ import { EDGE_CLIENT_TOO_OLD, EdgeClientTooOldError, EdgeResponse } from '@dxos/protocols';
9
10
  import { type Presentation, PresentationSchema } from '@dxos/protocols/buf/dxos/halo/credentials_pb';
10
11
 
11
12
  import { createEphemeralEdgeIdentity } from './auth.ts';
@@ -329,6 +330,48 @@ describe('EdgeHttpClient auth refresh', () => {
329
330
  });
330
331
  });
331
332
 
333
+ describe('EdgeHttpClient outdated SDK', () => {
334
+ afterEach(() => {
335
+ vi.unstubAllGlobals();
336
+ });
337
+
338
+ // Built by EDGE's own envelope producer, exactly as `requireClientSdkVersion` answers.
339
+ const refusal = () =>
340
+ EdgeResponse.failure({
341
+ message: 'Client too old: SDK 0.12.0 is older than 0.13.0. Update the app (reload it) to continue.',
342
+ status: 426,
343
+ data: { type: EDGE_CLIENT_TOO_OLD, clientVersion: '0.12.0', minimumVersion: '0.13.0' },
344
+ });
345
+
346
+ // The prefetch swallows other failures to proceed unauthenticated, which here would only be refused again, silently.
347
+ test('a refusal at the /auth prefetch fails the call once, without sending it', async ({ expect }) => {
348
+ const fetchMock = vi.fn(async (_input: any, _init?: RequestInit) => refusal());
349
+ vi.stubGlobal('fetch', fetchMock);
350
+ const client = new EdgeHttpClient('https://edge.example.com');
351
+ client.setIdentity({
352
+ peerKey: 'peer-key',
353
+ identityDid: 'did:halo:test',
354
+ presentCredentials: async (): Promise<Presentation> => create(PresentationSchema, {}),
355
+ });
356
+
357
+ const error = await client.getStatus(Context.default()).catch((error: unknown) => error);
358
+ expect(error).toBeInstanceOf(EdgeClientTooOldError);
359
+ expect(error).toMatchObject({ status: 426, isRetryable: false, data: { minimumVersion: '0.13.0' } });
360
+ expect(fetchMock.mock.calls.map((call) => new URL(String(call[0])).pathname)).toEqual(['/auth']);
361
+ });
362
+
363
+ test('a route refusing the SDK fails with the typed error and is not retried', async ({ expect }) => {
364
+ const fetchMock = vi.fn(async (_input: any, _init?: RequestInit) => refusal());
365
+ vi.stubGlobal('fetch', fetchMock);
366
+ const client = new EdgeHttpClient('https://edge.example.com');
367
+
368
+ await expect(client.request(Context.default(), '/status', { method: 'GET' })).rejects.toBeInstanceOf(
369
+ EdgeClientTooOldError,
370
+ );
371
+ expect(fetchMock).toHaveBeenCalledTimes(1);
372
+ });
373
+ });
374
+
332
375
  describe('EdgeHttpClient blobs', () => {
333
376
  afterEach(() => {
334
377
  vi.unstubAllGlobals();
@@ -840,10 +840,7 @@ export class EdgeHttpClient extends BaseHttpClient {
840
840
  let handledAuth = false;
841
841
  while (true) {
842
842
  if (!this._authHeader) {
843
- const authResponse = await fetch(new URL('/auth', this.baseUrl));
844
- if (authResponse.status === 401) {
845
- this._authHeader = await this._handleUnauthorized(authResponse);
846
- }
843
+ await this._prefetchAuthHeader();
847
844
  }
848
845
 
849
846
  const headers = new Headers(request.headers);
@@ -8,11 +8,11 @@ import { Mutex, scheduleTask, scheduleTaskInterval } from '@dxos/async';
8
8
  import { Context, Resource } from '@dxos/context';
9
9
  import { invariant } from '@dxos/invariant';
10
10
  import { log, logInfo } from '@dxos/log';
11
- import { EDGE_CLIENT_VERSION_PROTOCOL_PREFIX, EdgeWebsocketProtocol } from '@dxos/protocols';
11
+ import { EdgeWebsocketProtocol } from '@dxos/protocols';
12
12
  import { buf } from '@dxos/protocols/buf';
13
13
  import { type Message, MessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
14
14
 
15
- import { version } from '../package.json';
15
+ import { clientVersionProtocol } from './client-version.ts';
16
16
  import { protocol } from './defs.ts';
17
17
  import { type EdgeIdentity } from './edge-identity.ts';
18
18
  import { CLOUDFLARE_MESSAGE_MAX_BYTES, WebSocketClosedError, WebSocketMuxer } from './edge-ws-muxer.ts';
@@ -181,8 +181,7 @@ export class EdgeWsConnection extends Resource {
181
181
  }
182
182
 
183
183
  protected override async _open(): Promise<void> {
184
- // Browsers cannot set WebSocket headers, so the SDK version rides in the subprotocol list.
185
- const baseProtocols = [...Object.values(EdgeWebsocketProtocol), `${EDGE_CLIENT_VERSION_PROTOCOL_PREFIX}${version}`];
184
+ const baseProtocols = [...Object.values(EdgeWebsocketProtocol), clientVersionProtocol()];
186
185
  this._ws = new WebSocket(
187
186
  this._connectionInfo.url.toString(),
188
187
  this._connectionInfo.protocolHeader