@abloatai/ablo 0.7.0 → 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/CHANGELOG.md +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +7 -3
- package/dist/schema/serialize.js +6 -2
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router adapter for Data Source. The core `dataSource()` already
|
|
3
|
+
* returns a Web-standard `(Request) => Promise<Response>`, which Next App Router
|
|
4
|
+
* accepts directly — so this is pure ergonomics: wire an ORM `adapter` in via the
|
|
5
|
+
* bridge and hand back a named `POST` so the customer's route file is the minimum:
|
|
6
|
+
*
|
|
7
|
+
* // app/api/ablo/source/route.ts
|
|
8
|
+
* import { dataSourceNext } from '@abloatai/ablo/source/next';
|
|
9
|
+
* import { prismaDataSource } from '@abloatai/ablo/source';
|
|
10
|
+
* import { schema } from '@/ablo/schema';
|
|
11
|
+
* import { prisma } from '@/lib/prisma';
|
|
12
|
+
*
|
|
13
|
+
* export const { POST } = dataSourceNext({
|
|
14
|
+
* schema,
|
|
15
|
+
* apiKey: process.env.ABLO_API_KEY!,
|
|
16
|
+
* adapter: prismaDataSource(prisma, schema),
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* Day-one scope: Next + the adapter form only. Hand-written handlers use the core
|
|
20
|
+
* `dataSource()` directly; Hono/Express are the same one-liner and land on demand
|
|
21
|
+
* — not pre-built.
|
|
22
|
+
*/
|
|
23
|
+
import { dataSource } from './index.js';
|
|
24
|
+
export function dataSourceNext(options) {
|
|
25
|
+
return { POST: dataSource(options) };
|
|
26
|
+
}
|
|
@@ -61,7 +61,13 @@ export interface BootstrapOptions {
|
|
|
61
61
|
* old clients that don't send a models param).
|
|
62
62
|
*/
|
|
63
63
|
instantModels?: string[];
|
|
64
|
+
/**
|
|
65
|
+
* Shared SDK credential getter. Preferred over `setAuthToken`; read at
|
|
66
|
+
* request time so token refreshes apply without recreating BootstrapHelper.
|
|
67
|
+
*/
|
|
68
|
+
getAuthToken?: AuthTokenGetter;
|
|
64
69
|
}
|
|
70
|
+
import { type AuthTokenGetter } from '../auth/credentialSource.js';
|
|
65
71
|
import { type ValidatedServerDelta } from './schemas.js';
|
|
66
72
|
export declare class BootstrapHelper {
|
|
67
73
|
private options;
|
|
@@ -74,6 +80,10 @@ export declare class BootstrapHelper {
|
|
|
74
80
|
*/
|
|
75
81
|
setCacheScope(cacheScope: string): void;
|
|
76
82
|
setSyncGroups(syncGroups: readonly string[] | undefined): void;
|
|
83
|
+
/**
|
|
84
|
+
* Compatibility setter for direct BootstrapHelper users. The SDK-owned
|
|
85
|
+
* `Ablo()` path passes `getAuthToken` and does not mutate this helper.
|
|
86
|
+
*/
|
|
77
87
|
setAuthToken(authToken: string | undefined): void;
|
|
78
88
|
/**
|
|
79
89
|
* Create a promise that rejects after a timeout
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Removed problematic caching that was serving stale data
|
|
4
4
|
*/
|
|
5
5
|
import { getContext } from '../context.js';
|
|
6
|
-
import { SyncSessionError, AbloConnectionError, translateHttpError } from '../errors.js';
|
|
6
|
+
import { SyncSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '../errors.js';
|
|
7
|
+
import { withAuthHeaders } from '../auth/credentialSource.js';
|
|
7
8
|
// SyncObservability replaced by getContext().observability
|
|
8
9
|
import { parseBootstrapResponse } from './schemas.js';
|
|
9
10
|
export class BootstrapHelper {
|
|
@@ -46,6 +47,10 @@ export class BootstrapHelper {
|
|
|
46
47
|
setSyncGroups(syncGroups) {
|
|
47
48
|
this.options.syncGroups = [...(syncGroups ?? [])];
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Compatibility setter for direct BootstrapHelper users. The SDK-owned
|
|
52
|
+
* `Ablo()` path passes `getAuthToken` and does not mutate this helper.
|
|
53
|
+
*/
|
|
49
54
|
setAuthToken(authToken) {
|
|
50
55
|
if (!authToken) {
|
|
51
56
|
delete this.options.authToken;
|
|
@@ -60,7 +65,9 @@ export class BootstrapHelper {
|
|
|
60
65
|
createTimeoutPromise(ms, operation) {
|
|
61
66
|
return new Promise((_, reject) => {
|
|
62
67
|
setTimeout(() => {
|
|
63
|
-
reject(new
|
|
68
|
+
reject(new AbloConnectionError(`Bootstrap ${operation} timed out after ${ms}ms`, {
|
|
69
|
+
code: 'bootstrap_fetch_timeout',
|
|
70
|
+
}));
|
|
64
71
|
}, ms);
|
|
65
72
|
});
|
|
66
73
|
}
|
|
@@ -138,6 +145,17 @@ export class BootstrapHelper {
|
|
|
138
145
|
});
|
|
139
146
|
throw error;
|
|
140
147
|
}
|
|
148
|
+
// Don't retry NON-retryable errors. A 401/403/4xx auth or client error
|
|
149
|
+
// (api_key_required, jwt_issuer_untrusted, …) will NOT succeed by
|
|
150
|
+
// repeating the same request with the same credential — retrying just
|
|
151
|
+
// hammers the server and floods the console with doomed requests. Only
|
|
152
|
+
// transient failures (5xx, 429, timeouts, network blips, or an
|
|
153
|
+
// unclassified error with no code) flow through to the retry/backoff.
|
|
154
|
+
const ablo = toAbloError(error);
|
|
155
|
+
if (ablo.code && !isRetryableCode(ablo.code)) {
|
|
156
|
+
getContext().observability.breadcrumb('Bootstrap non-retryable error — failing fast', 'sync.bootstrap', 'warning', { code: ablo.code, httpStatus: ablo.httpStatus });
|
|
157
|
+
throw ablo;
|
|
158
|
+
}
|
|
141
159
|
lastError = error;
|
|
142
160
|
getContext().observability.breadcrumb('Bootstrap fetch failed', 'sync.bootstrap', 'warning', {
|
|
143
161
|
attempt: attempt + 1,
|
|
@@ -157,7 +175,11 @@ export class BootstrapHelper {
|
|
|
157
175
|
});
|
|
158
176
|
return cached;
|
|
159
177
|
}
|
|
160
|
-
throw lastError
|
|
178
|
+
throw lastError
|
|
179
|
+
? toAbloError(lastError)
|
|
180
|
+
: new AbloConnectionError('Failed to fetch bootstrap data', {
|
|
181
|
+
code: 'bootstrap_fetch_timeout',
|
|
182
|
+
});
|
|
161
183
|
}
|
|
162
184
|
/**
|
|
163
185
|
* Fetch bootstrap with ETag, returning 304 hints
|
|
@@ -180,15 +202,11 @@ export class BootstrapHelper {
|
|
|
180
202
|
// conditional revalidation (If-None-Match) implement it at their own
|
|
181
203
|
// level where they own the cache-key namespace. The 304 branch below
|
|
182
204
|
// remains defensively in place for when a caller enables revalidation.
|
|
183
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
184
|
-
if (this.options.authToken) {
|
|
185
|
-
headers.Authorization = `Bearer ${this.options.authToken}`;
|
|
186
|
-
}
|
|
205
|
+
const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
|
|
187
206
|
this.abortController = new AbortController();
|
|
188
207
|
const res = await fetch(url, {
|
|
189
208
|
method: 'GET',
|
|
190
209
|
headers,
|
|
191
|
-
credentials: 'include',
|
|
192
210
|
signal: this.abortController.signal,
|
|
193
211
|
});
|
|
194
212
|
const etag = res.headers.get('ETag');
|
|
@@ -198,17 +216,6 @@ export class BootstrapHelper {
|
|
|
198
216
|
return { notModified: true, etag };
|
|
199
217
|
}
|
|
200
218
|
if (!res.ok) {
|
|
201
|
-
// Check for session/auth errors - these should redirect to login
|
|
202
|
-
if (SyncSessionError.isSessionErrorResponse(res.status)) {
|
|
203
|
-
let body = '';
|
|
204
|
-
try {
|
|
205
|
-
body = await res.text();
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
// Ignore body parsing errors
|
|
209
|
-
}
|
|
210
|
-
throw new SyncSessionError(body || `Session expired or invalid: ${res.status}`, res.status);
|
|
211
|
-
}
|
|
212
219
|
const bodyText = await res.text().catch(() => '');
|
|
213
220
|
let parsed = bodyText;
|
|
214
221
|
if (bodyText) {
|
|
@@ -219,7 +226,21 @@ export class BootstrapHelper {
|
|
|
219
226
|
// Keep as string.
|
|
220
227
|
}
|
|
221
228
|
}
|
|
222
|
-
|
|
229
|
+
// Translate the canonical envelope FIRST so the server's specific code +
|
|
230
|
+
// message survive (e.g. `api_key_required`, `jwt_issuer_untrusted`).
|
|
231
|
+
const translated = translateHttpError(res.status, parsed || `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
|
|
232
|
+
// Only a genuine session/JWT EXPIRY — or a bare auth failure carrying no
|
|
233
|
+
// structured code — should drive the sign-in redirect. A specific auth
|
|
234
|
+
// code like `api_key_required` is NOT an expired session: re-logging-in
|
|
235
|
+
// mints the same credential and loops. Surface it as its real typed error
|
|
236
|
+
// instead of a `session_expired` wrapping the stringified body.
|
|
237
|
+
if (translated.code === 'session_expired' ||
|
|
238
|
+
translated.code === 'jwt_expired' ||
|
|
239
|
+
((res.status === 401 || res.status === 403) &&
|
|
240
|
+
translated.code === undefined)) {
|
|
241
|
+
throw new SyncSessionError(translated.message, res.status);
|
|
242
|
+
}
|
|
243
|
+
throw translated;
|
|
223
244
|
}
|
|
224
245
|
const rawJson = await res.json();
|
|
225
246
|
const data = parseBootstrapResponse(rawJson);
|
|
@@ -252,15 +273,11 @@ export class BootstrapHelper {
|
|
|
252
273
|
try {
|
|
253
274
|
response = await fetch(url, {
|
|
254
275
|
method: 'GET',
|
|
255
|
-
headers: {
|
|
276
|
+
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
256
277
|
'Content-Type': 'application/json',
|
|
257
278
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
258
279
|
Pragma: 'no-cache',
|
|
259
|
-
|
|
260
|
-
? { Authorization: `Bearer ${this.options.authToken}` }
|
|
261
|
-
: {}),
|
|
262
|
-
},
|
|
263
|
-
credentials: 'include',
|
|
280
|
+
}, this.options.authToken),
|
|
264
281
|
signal: this.abortController.signal,
|
|
265
282
|
cache: 'no-store', // Force browser to not cache
|
|
266
283
|
});
|
|
@@ -275,17 +292,6 @@ export class BootstrapHelper {
|
|
|
275
292
|
}
|
|
276
293
|
clearTimeout(timeoutId);
|
|
277
294
|
if (!response.ok) {
|
|
278
|
-
// Check for session/auth errors - these should redirect to login
|
|
279
|
-
if (SyncSessionError.isSessionErrorResponse(response.status)) {
|
|
280
|
-
let body = '';
|
|
281
|
-
try {
|
|
282
|
-
body = await response.text();
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
// Ignore body parsing errors
|
|
286
|
-
}
|
|
287
|
-
throw new SyncSessionError(body || `Session expired or invalid: ${response.status}`, response.status);
|
|
288
|
-
}
|
|
289
295
|
const bodyText = await response.text().catch(() => '');
|
|
290
296
|
let parsed = bodyText;
|
|
291
297
|
if (bodyText) {
|
|
@@ -296,7 +302,17 @@ export class BootstrapHelper {
|
|
|
296
302
|
// Keep as string.
|
|
297
303
|
}
|
|
298
304
|
}
|
|
299
|
-
|
|
305
|
+
// Same code-aware handling as the primary bootstrap fetch: preserve the
|
|
306
|
+
// server's specific code/message; only a genuine expiry (or a bare,
|
|
307
|
+
// code-less auth failure) drives the sign-in redirect.
|
|
308
|
+
const translated = translateHttpError(response.status, parsed || `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
|
|
309
|
+
if (translated.code === 'session_expired' ||
|
|
310
|
+
translated.code === 'jwt_expired' ||
|
|
311
|
+
((response.status === 401 || response.status === 403) &&
|
|
312
|
+
translated.code === undefined)) {
|
|
313
|
+
throw new SyncSessionError(translated.message, response.status);
|
|
314
|
+
}
|
|
315
|
+
throw translated;
|
|
300
316
|
}
|
|
301
317
|
const rawJson = await response.json();
|
|
302
318
|
const data = parseBootstrapResponse(rawJson);
|
|
@@ -318,10 +334,9 @@ export class BootstrapHelper {
|
|
|
318
334
|
const url = `${this.options.baseUrl}/sync/entity/${modelName}/${id}`;
|
|
319
335
|
const response = await fetch(url, {
|
|
320
336
|
method: 'GET',
|
|
321
|
-
headers: {
|
|
337
|
+
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
322
338
|
'Content-Type': 'application/json',
|
|
323
|
-
},
|
|
324
|
-
credentials: 'include',
|
|
339
|
+
}, this.options.authToken),
|
|
325
340
|
});
|
|
326
341
|
if (response.status === 404) {
|
|
327
342
|
return null;
|
|
@@ -417,7 +432,6 @@ export class BootstrapHelper {
|
|
|
417
432
|
try {
|
|
418
433
|
const response = await fetch(`${this.options.baseUrl}/health`, {
|
|
419
434
|
method: 'GET',
|
|
420
|
-
credentials: 'include',
|
|
421
435
|
signal: AbortSignal.timeout(5000),
|
|
422
436
|
cache: 'no-store',
|
|
423
437
|
});
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
* instead of hard-reloading an already-offline browser.
|
|
35
35
|
*/
|
|
36
36
|
import { type ProbeResult } from './NetworkProbe.js';
|
|
37
|
-
|
|
37
|
+
import type { AuthTokenGetter } from '../auth/credentialSource.js';
|
|
38
|
+
export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'refreshing_credential' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'auth_blocked' | 'session_expired';
|
|
38
39
|
export type ConnectionEvent = {
|
|
39
40
|
type: 'NETWORK_LOST';
|
|
40
41
|
} | {
|
|
@@ -52,8 +53,20 @@ export type ConnectionEvent = {
|
|
|
52
53
|
} | {
|
|
53
54
|
type: 'PROBE_SUCCESS';
|
|
54
55
|
sessionValid: boolean;
|
|
56
|
+
} | {
|
|
57
|
+
type: 'PROBE_AUTH_BLOCKED';
|
|
58
|
+
}
|
|
59
|
+
/** The probe saw an expired ephemeral access key (`access_credential_expiry`).
|
|
60
|
+
* Recoverable: re-mint a fresh `ek_`/`rk_` and re-probe — never a sign-out. */
|
|
61
|
+
| {
|
|
62
|
+
type: 'PROBE_CREDENTIAL_STALE';
|
|
55
63
|
} | {
|
|
56
64
|
type: 'PROBE_FAILED';
|
|
65
|
+
}
|
|
66
|
+
/** A fresh access credential is available (the re-mint succeeded, or one was
|
|
67
|
+
* pushed in via `setAuthToken`). Re-probe so a parked connection picks it up. */
|
|
68
|
+
| {
|
|
69
|
+
type: 'CREDENTIAL_REFRESHED';
|
|
57
70
|
} | {
|
|
58
71
|
type: 'RECONNECT_SUCCESS';
|
|
59
72
|
} | {
|
|
@@ -68,6 +81,20 @@ export type ConnectionEvent = {
|
|
|
68
81
|
export interface ConnectionCallbacks {
|
|
69
82
|
/** Run bootstrap + WebSocket reconnect. Returns the outcome. */
|
|
70
83
|
onReconnect: () => Promise<'success' | 'session_error' | 'network_error'>;
|
|
84
|
+
/**
|
|
85
|
+
* Re-mint the short-lived access credential (the Stripe-style `ek_`/`rk_`)
|
|
86
|
+
* and push it into the credential source, then report the outcome. Invoked
|
|
87
|
+
* on `refreshing_credential` — i.e. when a probe found the access key stale
|
|
88
|
+
* (`PROBE_CREDENTIAL_STALE`). Mirrors the `getToken` contract:
|
|
89
|
+
* - `'refreshed'` → a fresh credential is in place; re-probe & reconnect.
|
|
90
|
+
* - `'session_error'` → the LONG-LIVED login is gone (mint returned null →
|
|
91
|
+
* 401/403); terminal → sign out.
|
|
92
|
+
* - `'network_error'` → couldn't reach the mint endpoint (offline/5xx/throw);
|
|
93
|
+
* transient → back off and retry, never sign out.
|
|
94
|
+
* Optional: a deployment with no re-mint path (e.g. a static `apiKey`) omits
|
|
95
|
+
* it, and the FSM falls back to a plain re-probe.
|
|
96
|
+
*/
|
|
97
|
+
onRefreshCredential?: () => Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
71
98
|
/** Called when the session is confirmed expired — route to signin. */
|
|
72
99
|
onSessionExpired: () => void;
|
|
73
100
|
/** Called to tear down the WebSocket when entering a dead state. */
|
|
@@ -88,6 +115,12 @@ export interface ConnectionManagerOptions {
|
|
|
88
115
|
* default of `probeNetwork`.
|
|
89
116
|
*/
|
|
90
117
|
baseUrl?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Current bearer credential for authenticated probes. Read lazily so token
|
|
120
|
+
* refreshes pushed through `Ablo.setAuthToken()` are used by the next probe
|
|
121
|
+
* without recreating the manager.
|
|
122
|
+
*/
|
|
123
|
+
getAuthToken?: AuthTokenGetter;
|
|
91
124
|
/** Override retry ceilings / jitter. Production should leave defaults. */
|
|
92
125
|
backoff?: Partial<typeof DEFAULT_BACKOFF>;
|
|
93
126
|
}
|
|
@@ -107,8 +140,12 @@ export declare class ConnectionManager {
|
|
|
107
140
|
private debounceTimer;
|
|
108
141
|
private watchdogTimer;
|
|
109
142
|
private stuckCycles;
|
|
143
|
+
/** Consecutive access-key re-mints in the current recovery cycle; reset on
|
|
144
|
+
* reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
|
|
145
|
+
private credentialRefreshAttempts;
|
|
110
146
|
private disposed;
|
|
111
147
|
private readonly baseUrl?;
|
|
148
|
+
private readonly getAuthToken?;
|
|
112
149
|
private readonly backoff;
|
|
113
150
|
private handleBrowserOnline;
|
|
114
151
|
private handleBrowserOffline;
|
|
@@ -120,6 +157,25 @@ export declare class ConnectionManager {
|
|
|
120
157
|
private transition;
|
|
121
158
|
private onEnterState;
|
|
122
159
|
private runProbe;
|
|
160
|
+
/**
|
|
161
|
+
* Re-mint the short-lived access key on `refreshing_credential`. Delegates to
|
|
162
|
+
* the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
|
|
163
|
+
* still-valid login and pushes it into the credential source) and maps its
|
|
164
|
+
* tri-state outcome onto the FSM:
|
|
165
|
+
* - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
|
|
166
|
+
* - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
|
|
167
|
+
* - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
|
|
168
|
+
*
|
|
169
|
+
* A bounded attempt counter guards against a hot loop where the server keeps
|
|
170
|
+
* reporting the key stale even after a "successful" re-mint (e.g. a clock skew
|
|
171
|
+
* or a mint that returns an already-rejected key): after
|
|
172
|
+
* `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
|
|
173
|
+
* no sign-out) rather than spin. The counter resets once we reach `connected`.
|
|
174
|
+
*
|
|
175
|
+
* When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
|
|
176
|
+
* directly — the credential source's own scheduler owns refresh there.
|
|
177
|
+
*/
|
|
178
|
+
private runRefreshCredential;
|
|
123
179
|
private runReconnect;
|
|
124
180
|
private scheduleBackoff;
|
|
125
181
|
private setupBrowserListeners;
|
|
@@ -46,6 +46,10 @@ const DEFAULT_BACKOFF = {
|
|
|
46
46
|
const ONLINE_DEBOUNCE_MS = 500;
|
|
47
47
|
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
48
48
|
const MAX_STUCK_CYCLES_BEFORE_RELOAD = 6;
|
|
49
|
+
/** Cap on consecutive access-key re-mints before giving up to `auth_blocked`.
|
|
50
|
+
* Stops a hot loop if the server keeps reporting the key stale even after a
|
|
51
|
+
* "successful" re-mint (clock skew, a mint returning an already-rejected key). */
|
|
52
|
+
const MAX_CREDENTIAL_REFRESH_ATTEMPTS = 3;
|
|
49
53
|
// ─── ConnectionManager ────────────────────────────────────────────────────
|
|
50
54
|
export class ConnectionManager {
|
|
51
55
|
// Observable state
|
|
@@ -59,14 +63,19 @@ export class ConnectionManager {
|
|
|
59
63
|
debounceTimer = null;
|
|
60
64
|
watchdogTimer = null;
|
|
61
65
|
stuckCycles = 0;
|
|
66
|
+
/** Consecutive access-key re-mints in the current recovery cycle; reset on
|
|
67
|
+
* reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
|
|
68
|
+
credentialRefreshAttempts = 0;
|
|
62
69
|
disposed = false;
|
|
63
70
|
baseUrl;
|
|
71
|
+
getAuthToken;
|
|
64
72
|
backoff;
|
|
65
73
|
handleBrowserOnline = null;
|
|
66
74
|
handleBrowserOffline = null;
|
|
67
75
|
handleVisibilityChange = null;
|
|
68
76
|
constructor(options = {}) {
|
|
69
77
|
this.baseUrl = options.baseUrl;
|
|
78
|
+
this.getAuthToken = options.getAuthToken;
|
|
70
79
|
this.backoff = { ...DEFAULT_BACKOFF, ...(options.backoff ?? {}) };
|
|
71
80
|
makeAutoObservable(this, {}, { autoBind: true });
|
|
72
81
|
}
|
|
@@ -151,6 +160,7 @@ export class ConnectionManager {
|
|
|
151
160
|
case 'MANUAL_RETRY':
|
|
152
161
|
case 'TAB_VISIBLE':
|
|
153
162
|
case 'WS_HANDSHAKE_FAILED':
|
|
163
|
+
case 'CREDENTIAL_REFRESHED':
|
|
154
164
|
return 'probing_network';
|
|
155
165
|
case 'WS_SESSION_ERROR':
|
|
156
166
|
case 'BOOTSTRAP_FAILED_SESSION':
|
|
@@ -162,6 +172,11 @@ export class ConnectionManager {
|
|
|
162
172
|
switch (event.type) {
|
|
163
173
|
case 'PROBE_SUCCESS':
|
|
164
174
|
return event.sessionValid ? 'reconnecting' : 'session_expired';
|
|
175
|
+
case 'PROBE_CREDENTIAL_STALE':
|
|
176
|
+
// Access key expired but the login is fine — re-mint, don't sign out.
|
|
177
|
+
return 'refreshing_credential';
|
|
178
|
+
case 'PROBE_AUTH_BLOCKED':
|
|
179
|
+
return 'auth_blocked';
|
|
165
180
|
case 'PROBE_FAILED':
|
|
166
181
|
return 'waiting_for_network';
|
|
167
182
|
case 'NETWORK_LOST':
|
|
@@ -175,6 +190,7 @@ export class ConnectionManager {
|
|
|
175
190
|
case 'TAB_VISIBLE':
|
|
176
191
|
case 'MANUAL_RETRY':
|
|
177
192
|
case 'BACKOFF_ELAPSED':
|
|
193
|
+
case 'CREDENTIAL_REFRESHED':
|
|
178
194
|
return 'probing_network';
|
|
179
195
|
case 'NETWORK_LOST':
|
|
180
196
|
return 'offline';
|
|
@@ -185,6 +201,35 @@ export class ConnectionManager {
|
|
|
185
201
|
switch (event.type) {
|
|
186
202
|
case 'PROBE_SUCCESS':
|
|
187
203
|
return event.sessionValid ? 'reconnecting' : 'session_expired';
|
|
204
|
+
case 'PROBE_CREDENTIAL_STALE':
|
|
205
|
+
return 'refreshing_credential';
|
|
206
|
+
case 'PROBE_AUTH_BLOCKED':
|
|
207
|
+
return 'auth_blocked';
|
|
208
|
+
case 'NETWORK_LOST':
|
|
209
|
+
return 'offline';
|
|
210
|
+
default:
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
case 'refreshing_credential':
|
|
214
|
+
// Re-minting the short-lived access key (the Stripe-style `ek_`/`rk_`).
|
|
215
|
+
// The login is presumed valid; this is NOT a sign-out state.
|
|
216
|
+
switch (event.type) {
|
|
217
|
+
case 'CREDENTIAL_REFRESHED':
|
|
218
|
+
// Fresh key in hand — re-probe so we reconnect with it.
|
|
219
|
+
return 'probing_network';
|
|
220
|
+
case 'BOOTSTRAP_FAILED_SESSION':
|
|
221
|
+
// The re-mint hit a genuine 401/403: the long-lived login itself is
|
|
222
|
+
// gone. THIS is the only path from here to sign-out.
|
|
223
|
+
return 'session_expired';
|
|
224
|
+
case 'RECONNECT_FAILED':
|
|
225
|
+
// Couldn't reach the mint endpoint (offline/5xx/throw) — transient.
|
|
226
|
+
// Back off and retry; never sign out for a network failure.
|
|
227
|
+
return 'backoff';
|
|
228
|
+
case 'PROBE_AUTH_BLOCKED':
|
|
229
|
+
// Bounded-attempt fallback: the key keeps coming back stale even
|
|
230
|
+
// after re-mint (see runRefreshCredential's attempt guard). Stop
|
|
231
|
+
// looping without signing out.
|
|
232
|
+
return 'auth_blocked';
|
|
188
233
|
case 'NETWORK_LOST':
|
|
189
234
|
return 'offline';
|
|
190
235
|
default:
|
|
@@ -214,10 +259,32 @@ export class ConnectionManager {
|
|
|
214
259
|
return 'probing_network';
|
|
215
260
|
case 'NETWORK_ONLINE':
|
|
216
261
|
case 'TAB_VISIBLE':
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
262
|
+
case 'CREDENTIAL_REFRESHED':
|
|
263
|
+
// Network came back (or a fresh credential arrived) while we were
|
|
264
|
+
// waiting out a backoff delay — jump straight to probing instead of
|
|
265
|
+
// waiting the full exponential interval. Fixes the "doesn't
|
|
266
|
+
// retrigger when internet comes back" bug.
|
|
267
|
+
return 'probing_network';
|
|
268
|
+
case 'NETWORK_LOST':
|
|
269
|
+
return 'offline';
|
|
270
|
+
case 'WS_SESSION_ERROR':
|
|
271
|
+
case 'BOOTSTRAP_FAILED_SESSION':
|
|
272
|
+
return 'session_expired';
|
|
273
|
+
default:
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
case 'auth_blocked':
|
|
277
|
+
// Reachable, but the data-plane rejected the credential (non-retryable,
|
|
278
|
+
// non-expiry — e.g. api_key_required, jwt_issuer_untrusted). Don't
|
|
279
|
+
// auto-reconnect and don't sign out. Allow a manual retry or a
|
|
280
|
+
// tab-focus / network-return / fresh-credential re-probe (e.g. after a
|
|
281
|
+
// server deploy or an out-of-band re-mint); a network drop parks
|
|
282
|
+
// offline; a genuine session error still expires.
|
|
283
|
+
switch (event.type) {
|
|
284
|
+
case 'MANUAL_RETRY':
|
|
285
|
+
case 'TAB_VISIBLE':
|
|
286
|
+
case 'NETWORK_ONLINE':
|
|
287
|
+
case 'CREDENTIAL_REFRESHED':
|
|
221
288
|
return 'probing_network';
|
|
222
289
|
case 'NETWORK_LOST':
|
|
223
290
|
return 'offline';
|
|
@@ -238,6 +305,9 @@ export class ConnectionManager {
|
|
|
238
305
|
switch (state) {
|
|
239
306
|
case 'connected':
|
|
240
307
|
this.clearBackoffTimer();
|
|
308
|
+
runInAction(() => {
|
|
309
|
+
this.credentialRefreshAttempts = 0;
|
|
310
|
+
});
|
|
241
311
|
break;
|
|
242
312
|
case 'offline':
|
|
243
313
|
this.clearBackoffTimer();
|
|
@@ -252,9 +322,22 @@ export class ConnectionManager {
|
|
|
252
322
|
case 'reconnecting':
|
|
253
323
|
this.runReconnect();
|
|
254
324
|
break;
|
|
325
|
+
case 'refreshing_credential':
|
|
326
|
+
this.runRefreshCredential();
|
|
327
|
+
break;
|
|
255
328
|
case 'backoff':
|
|
256
329
|
this.scheduleBackoff();
|
|
257
330
|
break;
|
|
331
|
+
case 'auth_blocked':
|
|
332
|
+
// Stop — reachable but the credential was rejected (e.g.
|
|
333
|
+
// api_key_required / jwt_issuer_untrusted from the data plane). Neither
|
|
334
|
+
// reconnecting nor re-auth fixes it. Drop the socket and wait for a
|
|
335
|
+
// manual retry / re-probe. Crucially NOT onSessionExpired (no sign-out)
|
|
336
|
+
// and NOT a reconnect — that's the whole point of this state.
|
|
337
|
+
this.clearBackoffTimer();
|
|
338
|
+
this.callbacks?.onDisconnectWebSocket();
|
|
339
|
+
getContext().observability.breadcrumb('Auth blocked — reachable but credential rejected; not reconnecting or signing out', 'sync.offline', 'error');
|
|
340
|
+
break;
|
|
258
341
|
case 'session_expired':
|
|
259
342
|
this.clearBackoffTimer();
|
|
260
343
|
this.callbacks?.onDisconnectWebSocket();
|
|
@@ -266,21 +349,108 @@ export class ConnectionManager {
|
|
|
266
349
|
// ── Async operations ─────────────────────────────────────────────────
|
|
267
350
|
async runProbe() {
|
|
268
351
|
try {
|
|
269
|
-
const result = await probeNetwork(
|
|
352
|
+
const result = await probeNetwork({
|
|
353
|
+
baseUrl: this.baseUrl,
|
|
354
|
+
getAuthToken: this.getAuthToken,
|
|
355
|
+
});
|
|
270
356
|
runInAction(() => {
|
|
271
357
|
this.lastProbeResult = result;
|
|
272
358
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
359
|
+
// One probe outcome → one event. Exhaustive over ProbeOutcome so a new
|
|
360
|
+
// outcome can't be silently dropped.
|
|
361
|
+
switch (result.outcome) {
|
|
362
|
+
case 'reachable':
|
|
363
|
+
this.send({ type: 'PROBE_SUCCESS', sessionValid: true });
|
|
364
|
+
break;
|
|
365
|
+
case 'session_expired':
|
|
366
|
+
// Genuine login expiry — terminal. (PROBE_SUCCESS with
|
|
367
|
+
// sessionValid:false routes to session_expired in the FSM.)
|
|
368
|
+
this.send({ type: 'PROBE_SUCCESS', sessionValid: false });
|
|
369
|
+
break;
|
|
370
|
+
case 'credential_stale':
|
|
371
|
+
// Access key expired but the login is fine — re-mint, don't sign out.
|
|
372
|
+
this.send({ type: 'PROBE_CREDENTIAL_STALE' });
|
|
373
|
+
break;
|
|
374
|
+
case 'auth_blocked':
|
|
375
|
+
this.send({ type: 'PROBE_AUTH_BLOCKED' });
|
|
376
|
+
break;
|
|
377
|
+
case 'unreachable':
|
|
378
|
+
this.send({ type: 'PROBE_FAILED' });
|
|
379
|
+
break;
|
|
380
|
+
default: {
|
|
381
|
+
const _exhaustive = result.outcome;
|
|
382
|
+
void _exhaustive;
|
|
383
|
+
this.send({ type: 'PROBE_FAILED' });
|
|
384
|
+
}
|
|
278
385
|
}
|
|
279
386
|
}
|
|
280
387
|
catch {
|
|
281
388
|
this.send({ type: 'PROBE_FAILED' });
|
|
282
389
|
}
|
|
283
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Re-mint the short-lived access key on `refreshing_credential`. Delegates to
|
|
393
|
+
* the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
|
|
394
|
+
* still-valid login and pushes it into the credential source) and maps its
|
|
395
|
+
* tri-state outcome onto the FSM:
|
|
396
|
+
* - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
|
|
397
|
+
* - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
|
|
398
|
+
* - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
|
|
399
|
+
*
|
|
400
|
+
* A bounded attempt counter guards against a hot loop where the server keeps
|
|
401
|
+
* reporting the key stale even after a "successful" re-mint (e.g. a clock skew
|
|
402
|
+
* or a mint that returns an already-rejected key): after
|
|
403
|
+
* `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
|
|
404
|
+
* no sign-out) rather than spin. The counter resets once we reach `connected`.
|
|
405
|
+
*
|
|
406
|
+
* When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
|
|
407
|
+
* directly — the credential source's own scheduler owns refresh there.
|
|
408
|
+
*/
|
|
409
|
+
async runRefreshCredential() {
|
|
410
|
+
if (this.credentialRefreshAttempts >= MAX_CREDENTIAL_REFRESH_ATTEMPTS) {
|
|
411
|
+
getContext().logger.warn('[ConnectionManager] Access key still stale after repeated re-mints — stopping', { attempts: this.credentialRefreshAttempts });
|
|
412
|
+
runInAction(() => {
|
|
413
|
+
this.credentialRefreshAttempts = 0;
|
|
414
|
+
});
|
|
415
|
+
this.send({ type: 'PROBE_AUTH_BLOCKED' });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
runInAction(() => {
|
|
419
|
+
this.credentialRefreshAttempts += 1;
|
|
420
|
+
});
|
|
421
|
+
const refresher = this.callbacks?.onRefreshCredential;
|
|
422
|
+
if (!refresher) {
|
|
423
|
+
// No re-mint path wired — re-probe with whatever the credential source
|
|
424
|
+
// holds (a static-key deployment refreshes out-of-band, if at all).
|
|
425
|
+
this.send({ type: 'CREDENTIAL_REFRESHED' });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const result = await refresher();
|
|
430
|
+
switch (result) {
|
|
431
|
+
case 'refreshed':
|
|
432
|
+
this.send({ type: 'CREDENTIAL_REFRESHED' });
|
|
433
|
+
break;
|
|
434
|
+
case 'session_error':
|
|
435
|
+
this.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
|
|
436
|
+
break;
|
|
437
|
+
case 'network_error':
|
|
438
|
+
this.send({ type: 'RECONNECT_FAILED' });
|
|
439
|
+
break;
|
|
440
|
+
default: {
|
|
441
|
+
const _exhaustive = result;
|
|
442
|
+
void _exhaustive;
|
|
443
|
+
this.send({ type: 'RECONNECT_FAILED' });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
// A thrown refresher is transient by contract (offline / mint endpoint
|
|
449
|
+
// unreachable) — back off and retry, never sign out.
|
|
450
|
+
getContext().logger.warn('[ConnectionManager] Credential re-mint threw (transient)', { error });
|
|
451
|
+
this.send({ type: 'RECONNECT_FAILED' });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
284
454
|
async runReconnect() {
|
|
285
455
|
if (!this.callbacks)
|
|
286
456
|
return;
|
|
@@ -395,6 +565,7 @@ export class ConnectionManager {
|
|
|
395
565
|
const isStuck = this.state !== 'connected' &&
|
|
396
566
|
this.state !== 'session_expired' &&
|
|
397
567
|
this.state !== 'probing_network' &&
|
|
568
|
+
this.state !== 'refreshing_credential' &&
|
|
398
569
|
this.state !== 'reconnecting';
|
|
399
570
|
if (isStuck) {
|
|
400
571
|
this.stuckCycles++;
|
|
@@ -431,7 +602,11 @@ export class ConnectionManager {
|
|
|
431
602
|
get isConnected() { return this.state === 'connected'; }
|
|
432
603
|
get isOffline() { return this.state === 'offline' || this.state === 'waiting_for_network'; }
|
|
433
604
|
get isReconnecting() {
|
|
434
|
-
return this.state === 'probing_network' ||
|
|
605
|
+
return (this.state === 'probing_network' ||
|
|
606
|
+
this.state === 'validating_session' ||
|
|
607
|
+
this.state === 'refreshing_credential' ||
|
|
608
|
+
this.state === 'reconnecting' ||
|
|
609
|
+
this.state === 'backoff');
|
|
435
610
|
}
|
|
436
611
|
get isSessionExpired() { return this.state === 'session_expired'; }
|
|
437
612
|
get offlineDuration() {
|