@carlonicora/nextjs-jsonapi 6.12.2 → 6.13.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/dist/{ApiData-DPKNfY-9.d.mts → ApiData-Be8yxCOX.d.mts} +2 -0
- package/dist/{ApiData-DPKNfY-9.d.ts → ApiData-Be8yxCOX.d.ts} +2 -0
- package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-22Q4DJBU.mjs} +20 -8
- package/dist/BlockNoteEditor-22Q4DJBU.mjs.map +1 -0
- package/dist/{BlockNoteEditor-7M5QW2YQ.js → BlockNoteEditor-2VLPSEKS.js} +34 -22
- package/dist/BlockNoteEditor-2VLPSEKS.js.map +1 -0
- package/dist/{BlockNoteViewer-WGCDB7IJ.js → BlockNoteViewer-2XYASA5Y.js} +8 -5
- package/dist/BlockNoteViewer-2XYASA5Y.js.map +1 -0
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs → BlockNoteViewer-NBW2H6LC.mjs} +7 -4
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs.map → BlockNoteViewer-NBW2H6LC.mjs.map} +1 -1
- package/dist/{JsonApiRequest-A66XK5WT.mjs → JsonApiRequest-F6TMJ42G.mjs} +2 -2
- package/dist/JsonApiRequest-WISJZSDE.js +26 -0
- package/dist/{JsonApiRequest-FZGPLNUF.js.map → JsonApiRequest-WISJZSDE.js.map} +1 -1
- package/dist/billing/index.js +499 -494
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +13 -8
- package/dist/billing/index.mjs.map +1 -1
- package/dist/chunk-27LQDGNP.mjs +317 -0
- package/dist/chunk-27LQDGNP.mjs.map +1 -0
- package/dist/{chunk-V3YDVJ63.js → chunk-2H342HNN.js} +948 -921
- package/dist/chunk-2H342HNN.js.map +1 -0
- package/dist/{chunk-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
- package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
- package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
- package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
- package/dist/chunk-6JUCIWDC.mjs +570 -0
- package/dist/chunk-6JUCIWDC.mjs.map +1 -0
- package/dist/chunk-7DBJTNSU.mjs +1049 -0
- package/dist/chunk-7DBJTNSU.mjs.map +1 -0
- package/dist/chunk-7JZHPMGW.mjs +704 -0
- package/dist/chunk-7JZHPMGW.mjs.map +1 -0
- package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
- package/dist/chunk-7ZHTPFFT.js.map +1 -0
- package/dist/chunk-B6JGKDP2.js +214 -0
- package/dist/chunk-B6JGKDP2.js.map +1 -0
- package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
- package/dist/chunk-BZ5J2FIV.js +570 -0
- package/dist/chunk-BZ5J2FIV.js.map +1 -0
- package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
- package/dist/chunk-DOCIFE6A.mjs +212 -0
- package/dist/chunk-DOCIFE6A.mjs.map +1 -0
- package/dist/chunk-GB6DBFYY.js +25 -0
- package/dist/chunk-GB6DBFYY.js.map +1 -0
- package/dist/{chunk-RXQYEEKZ.mjs → chunk-H4NLDX5G.mjs} +51 -24
- package/dist/chunk-H4NLDX5G.mjs.map +1 -0
- package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
- package/dist/chunk-JJMMT24D.mjs.map +1 -0
- package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
- package/dist/chunk-KMW7UNL2.js.map +1 -0
- package/dist/chunk-MHB36MYE.js +317 -0
- package/dist/chunk-MHB36MYE.js.map +1 -0
- package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
- package/dist/chunk-MOGEGWXW.js.map +1 -0
- package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
- package/dist/chunk-P445QGOC.mjs.map +1 -0
- package/dist/chunk-PV4Z5W6V.js +212 -0
- package/dist/chunk-PV4Z5W6V.js.map +1 -0
- package/dist/chunk-PXDUJ47N.js +1049 -0
- package/dist/chunk-PXDUJ47N.js.map +1 -0
- package/dist/{chunk-GHE4VT2R.js → chunk-QKNGZDSQ.js} +2 -2
- package/dist/{chunk-GHE4VT2R.js.map → chunk-QKNGZDSQ.js.map} +1 -1
- package/dist/chunk-W62MHDWM.js +704 -0
- package/dist/chunk-W62MHDWM.js.map +1 -0
- package/dist/chunk-XXP3TQHF.mjs +25 -0
- package/dist/chunk-XXP3TQHF.mjs.map +1 -0
- package/dist/chunk-YRKIFYOE.mjs +214 -0
- package/dist/chunk-YRKIFYOE.mjs.map +1 -0
- package/dist/{chunk-O3PDL2CV.js → chunk-Z4POQWFG.js} +40 -6
- package/dist/chunk-Z4POQWFG.js.map +1 -0
- package/dist/{chunk-PI56A4DG.mjs → chunk-ZLTOUZN2.mjs} +37 -2
- package/dist/chunk-ZLTOUZN2.mjs.map +1 -0
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +14 -9
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +24 -19
- package/dist/components/index.d.mts +7 -1
- package/dist/components/index.d.ts +7 -1
- package/dist/components/index.js +11 -7
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +10 -6
- package/dist/contexts/index.d.mts +1 -1
- package/dist/contexts/index.d.ts +1 -1
- package/dist/contexts/index.js +11 -7
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +10 -6
- package/dist/core/index.d.mts +128 -2
- package/dist/core/index.d.ts +128 -2
- package/dist/core/index.js +17 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +22 -9
- package/dist/email-automations/index.js +101 -96
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +13 -8
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.js +128 -123
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +13 -8
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +11 -8
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +9 -6
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.js +158 -153
- package/dist/features/blog/index.js.map +1 -1
- package/dist/features/blog/index.mjs +14 -9
- package/dist/features/blog/index.mjs.map +1 -1
- package/dist/features/blog/server.js +30 -26
- package/dist/features/blog/server.js.map +1 -1
- package/dist/features/blog/server.mjs +10 -6
- package/dist/features/blog/server.mjs.map +1 -1
- package/dist/features/help/index.js +44 -40
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +10 -6
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.js +134 -129
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +13 -8
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -11
- package/dist/lifecycle-4GUOTB46.js +23 -0
- package/dist/lifecycle-4GUOTB46.js.map +1 -0
- package/dist/lifecycle-LKGPRBS7.mjs +23 -0
- package/dist/local-transport-7H3SPZSA.js +15 -0
- package/dist/local-transport-7H3SPZSA.js.map +1 -0
- package/dist/local-transport-QYFKA6AN.mjs +15 -0
- package/dist/local-transport-QYFKA6AN.mjs.map +1 -0
- package/dist/offline/index.d.mts +607 -0
- package/dist/offline/index.d.ts +607 -0
- package/dist/offline/index.js +1052 -0
- package/dist/offline/index.js.map +1 -0
- package/dist/offline/index.mjs +1052 -0
- package/dist/offline/index.mjs.map +1 -0
- package/dist/offline/sqlite/device-worker.mjs +62 -0
- package/dist/offline/sqlite/index.mjs +17392 -0
- package/dist/offline/sqlite/sqlite3-opfs-async-proxy.js +881 -0
- package/dist/offline/sqlite/sqlite3.wasm +0 -0
- package/dist/request-KTBV6HHH.js +10 -0
- package/dist/request-KTBV6HHH.js.map +1 -0
- package/dist/request-SQOUNWZ4.mjs +10 -0
- package/dist/request-SQOUNWZ4.mjs.map +1 -0
- package/dist/server/cachedGet.d.mts +1 -1
- package/dist/server/cachedGet.d.ts +1 -1
- package/dist/server/cachedGet.stub.d.mts +1 -1
- package/dist/server/cachedGet.stub.d.ts +1 -1
- package/dist/server/index.d.mts +28 -2
- package/dist/server/index.d.ts +28 -2
- package/dist/server/index.js +18 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +18 -8
- package/dist/{useSocket-IRHtk2xc.d.ts → useSocket-Bo9M0t0J.d.ts} +3 -1
- package/dist/{useSocket-C8fqU4od.d.mts → useSocket-J5qanQPN.d.mts} +3 -1
- package/dist/write-through-FDQ7N4YI.js +15 -0
- package/dist/write-through-FDQ7N4YI.js.map +1 -0
- package/dist/write-through-R25VQMH6.mjs +15 -0
- package/dist/write-through-R25VQMH6.mjs.map +1 -0
- package/package.json +7 -1
- package/pwa/sw.js +277 -26
- package/src/client/request.ts +37 -0
- package/src/components/editors/BlockNoteEditor.tsx +14 -0
- package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
- package/src/contexts/SocketContext.tsx +7 -1
- package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
- package/src/core/factories/RehydrationFactory.ts +28 -8
- package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
- package/src/core/index.ts +3 -0
- package/src/core/interfaces/ApiData.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/auth/components/forms/Logout.tsx +8 -1
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
- package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
- package/src/hooks/useSocket.ts +20 -1
- package/src/offline/__tests__/answer-builders.spec.ts +176 -0
- package/src/offline/__tests__/device-switch.spec.ts +179 -0
- package/src/offline/__tests__/generic-answers.spec.ts +380 -0
- package/src/offline/__tests__/local-transport.spec.ts +150 -0
- package/src/offline/__tests__/logout.spec.ts +122 -0
- package/src/offline/__tests__/offline-fixtures.ts +89 -0
- package/src/offline/__tests__/router.spec.ts +56 -0
- package/src/offline/__tests__/state.spec.ts +62 -0
- package/src/offline/__tests__/write-through.spec.ts +137 -0
- package/src/offline/config.ts +229 -0
- package/src/offline/configure.ts +14 -0
- package/src/offline/device-switch.ts +75 -0
- package/src/offline/generic-answers.ts +706 -0
- package/src/offline/index.ts +67 -0
- package/src/offline/local-transport.ts +189 -0
- package/src/offline/logout.ts +41 -0
- package/src/offline/router.ts +41 -0
- package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
- package/src/offline/server/build-offline-manifest.ts +49 -0
- package/src/offline/state-hooks.ts +10 -0
- package/src/offline/state.ts +143 -0
- package/src/offline/store/DeviceStore.ts +112 -0
- package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
- package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
- package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
- package/src/offline/store/lifecycle.ts +315 -0
- package/src/offline/store/schema.ts +101 -0
- package/src/offline/store/shared-database.ts +321 -0
- package/src/offline/store/sql-device-store.ts +469 -0
- package/src/offline/store/worker-client.ts +71 -0
- package/src/offline/store/worker.ts +87 -0
- package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
- package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
- package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
- package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
- package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
- package/src/offline/sync/artwork-cache.ts +67 -0
- package/src/offline/sync/conflicts.ts +211 -0
- package/src/offline/sync/counts.ts +39 -0
- package/src/offline/sync/drain-queue.ts +246 -0
- package/src/offline/sync/known-roots.ts +37 -0
- package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
- package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
- package/src/offline/sync/models/RootSync.ts +68 -0
- package/src/offline/sync/models/RootSyncInterface.ts +20 -0
- package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
- package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
- package/src/offline/sync/models/index.ts +5 -0
- package/src/offline/sync/models/offline.modules.ts +14 -0
- package/src/offline/sync/runtime.ts +226 -0
- package/src/offline/sync/session-refresh.ts +32 -0
- package/src/offline/sync/sync-root.ts +336 -0
- package/src/offline/sync/waiting.ts +40 -0
- package/src/offline/types.ts +115 -0
- package/src/offline/write-through.ts +189 -0
- package/src/pwa/__tests__/sw.spec.ts +358 -1
- package/src/server/index.ts +5 -0
- package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
- package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
- package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
- package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
- package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
- package/dist/chunk-AA52ORW2.mjs.map +0 -1
- package/dist/chunk-EWCY6QJA.mjs.map +0 -1
- package/dist/chunk-FF4EIQ6H.js.map +0 -1
- package/dist/chunk-JTPRJNT2.js.map +0 -1
- package/dist/chunk-L6PYFQTO.js.map +0 -1
- package/dist/chunk-O3PDL2CV.js.map +0 -1
- package/dist/chunk-PI56A4DG.mjs.map +0 -1
- package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
- package/dist/chunk-V3YDVJ63.js.map +0 -1
- package/dist/request-5RLK2AM3.mjs +0 -9
- package/dist/request-EUGTI3BE.js +0 -9
- package/dist/request-EUGTI3BE.js.map +0 -1
- /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
- /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
- /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
- /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `@carlonicora/nextjs-jsonapi/offline` — the opt-in offline layer (C4).
|
|
2
|
+
// Importing this entry starts nothing: no store is opened, no worker created, no listener added,
|
|
3
|
+
// until the app calls `configureOffline(...)`.
|
|
4
|
+
|
|
5
|
+
export * from "./types";
|
|
6
|
+
export { configureOffline } from "./configure";
|
|
7
|
+
export {
|
|
8
|
+
isOfflineConfigured,
|
|
9
|
+
isOfflineAvailable,
|
|
10
|
+
isOfflineEnabledOnDevice,
|
|
11
|
+
refreshOfflineDeviceState,
|
|
12
|
+
getOfflineConfig,
|
|
13
|
+
getOfflineStore,
|
|
14
|
+
forgetOfflineStore,
|
|
15
|
+
setDeviceStoreOpener,
|
|
16
|
+
DeviceStoreUnavailableError,
|
|
17
|
+
OfflineUnavailableError,
|
|
18
|
+
} from "./config";
|
|
19
|
+
export { getOfflineState, subscribeOfflineState, setOfflineState, type OfflineState } from "./state";
|
|
20
|
+
export { useOfflineState, useNeedsConnection } from "./state-hooks";
|
|
21
|
+
export { matchRoute, matchPattern, type RouteMatch } from "./router";
|
|
22
|
+
export { localTransport } from "./local-transport";
|
|
23
|
+
export { writeThrough } from "./write-through";
|
|
24
|
+
export {
|
|
25
|
+
needsConnectionAnswer,
|
|
26
|
+
notFoundAnswer,
|
|
27
|
+
errorAnswer,
|
|
28
|
+
singleAnswer,
|
|
29
|
+
orderedListAnswer,
|
|
30
|
+
resourceListAnswer,
|
|
31
|
+
mergeResource,
|
|
32
|
+
resourceFromDocument,
|
|
33
|
+
includedFor,
|
|
34
|
+
listAnswer,
|
|
35
|
+
searchRecords,
|
|
36
|
+
orderRecords,
|
|
37
|
+
linksOfKind,
|
|
38
|
+
linkKindFor,
|
|
39
|
+
linkKindsOf,
|
|
40
|
+
SERVER_DEFAULT_ORDER_BY,
|
|
41
|
+
type ListOptions,
|
|
42
|
+
type ResourceDocumentData,
|
|
43
|
+
} from "./generic-answers";
|
|
44
|
+
export type { OfflineChangeResolution } from "./sync/models/OfflineChangeSetInterface";
|
|
45
|
+
|
|
46
|
+
// Device store (Task 5)
|
|
47
|
+
export type * from "./store/DeviceStore";
|
|
48
|
+
export {
|
|
49
|
+
openDeviceStore,
|
|
50
|
+
openMemoryDeviceStore,
|
|
51
|
+
deleteDeviceStore,
|
|
52
|
+
hasUnsentChanges,
|
|
53
|
+
onUserSignedIn,
|
|
54
|
+
isRootLocked,
|
|
55
|
+
} from "./store/lifecycle";
|
|
56
|
+
|
|
57
|
+
// Sync, sending changes, conflicts, runtime (Task 6)
|
|
58
|
+
export * from "./sync/sync-root";
|
|
59
|
+
export * from "./sync/drain-queue";
|
|
60
|
+
export * from "./sync/conflicts";
|
|
61
|
+
export * from "./sync/runtime";
|
|
62
|
+
|
|
63
|
+
// Logout (Task 7)
|
|
64
|
+
export { prepareOfflineLogout, finishOfflineLogout } from "./logout";
|
|
65
|
+
|
|
66
|
+
// Per-device switch (`OfflineConfig.requireOptIn`)
|
|
67
|
+
export { turnOnOffline, turnOffOffline } from "./device-switch";
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { getClientApiUrl } from "../client/JsonApiClient";
|
|
2
|
+
import type { ApiData } from "../core/interfaces/ApiData";
|
|
3
|
+
import { getApiUrl } from "../unified/JsonApiRequest";
|
|
4
|
+
import { getOfflineConfig, getOfflineStore } from "./config";
|
|
5
|
+
import {
|
|
6
|
+
buildGenericRoutes,
|
|
7
|
+
rootIdForResource,
|
|
8
|
+
isRootWriteLocked,
|
|
9
|
+
needsConnectionAnswer,
|
|
10
|
+
queuePath,
|
|
11
|
+
} from "./generic-answers";
|
|
12
|
+
import { matchRoute } from "./router";
|
|
13
|
+
import { setOfflineState } from "./state";
|
|
14
|
+
import type { DeviceStore } from "./store/DeviceStore";
|
|
15
|
+
import {
|
|
16
|
+
NeedsConnectionError,
|
|
17
|
+
type OfflineAnswer,
|
|
18
|
+
type OfflineMethod,
|
|
19
|
+
type OfflineRequest,
|
|
20
|
+
type OfflineRoute,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
export type LocalTransportRequest = {
|
|
24
|
+
method: string;
|
|
25
|
+
url: string;
|
|
26
|
+
body?: any;
|
|
27
|
+
files?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const METHODS: OfflineMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
31
|
+
|
|
32
|
+
function apiBases(): string[] {
|
|
33
|
+
const bases: string[] = [];
|
|
34
|
+
for (const read of [getApiUrl, getClientApiUrl]) {
|
|
35
|
+
try {
|
|
36
|
+
const base = read();
|
|
37
|
+
if (base) bases.push(base);
|
|
38
|
+
} catch {
|
|
39
|
+
// not configured on this side
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return bases;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The path relative to the API base, as `EndpointCreator` built it, and its query. */
|
|
46
|
+
export function splitApiUrl(url: string): { path: string; query: URLSearchParams } {
|
|
47
|
+
let relative: string | undefined;
|
|
48
|
+
for (const base of apiBases()) {
|
|
49
|
+
if (url.startsWith(base)) {
|
|
50
|
+
relative = url.slice(base.length);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (relative === undefined) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(url);
|
|
57
|
+
relative = `${parsed.pathname}${parsed.search}`;
|
|
58
|
+
} catch {
|
|
59
|
+
relative = url;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const [pathPart, ...queryParts] = relative.split("?");
|
|
64
|
+
return {
|
|
65
|
+
path: pathPart.replace(/^\/+/, "").replace(/\/+$/, ""),
|
|
66
|
+
query: new URLSearchParams(queryParts.join("?")),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toApiData(answer: OfflineAnswer): ApiData {
|
|
71
|
+
const ok = answer.status >= 200 && answer.status < 300;
|
|
72
|
+
return {
|
|
73
|
+
ok,
|
|
74
|
+
status: answer.status,
|
|
75
|
+
statusText: ok ? "OK" : answer.status === 503 ? "Service Unavailable" : "Error",
|
|
76
|
+
data: answer.body,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The root record a custom write belongs to: a `<root>/:param` capture, a stored record named by a param, or the body. */
|
|
81
|
+
async function rootIdForCustomWrite(
|
|
82
|
+
store: DeviceStore,
|
|
83
|
+
pattern: OfflineRequest,
|
|
84
|
+
params: Record<string, string>,
|
|
85
|
+
rootSegmentParam: string | undefined,
|
|
86
|
+
): Promise<string | undefined> {
|
|
87
|
+
const config = getOfflineConfig();
|
|
88
|
+
if (rootSegmentParam && params[rootSegmentParam]) return params[rootSegmentParam];
|
|
89
|
+
|
|
90
|
+
for (const value of Object.values(params)) {
|
|
91
|
+
for (const typeConfig of config.types) {
|
|
92
|
+
const stored = await store.getRecord(typeConfig.module.name, value);
|
|
93
|
+
if (stored?.rootId) return stored.rootId;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return rootIdForResource(store, config, pattern.body?.data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function refreshPendingCount(store: DeviceStore): Promise<void> {
|
|
100
|
+
const entries = await store.queue();
|
|
101
|
+
setOfflineState({ pending: entries.filter((entry) => entry.status !== "conflict").length });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Answers a request from the device copy: the app's custom routes first, then the generic ones.
|
|
106
|
+
* The result has the `ApiData` shape `directFetch` returns, so `translateResponse` and
|
|
107
|
+
* `rehydrate()` run unchanged.
|
|
108
|
+
*/
|
|
109
|
+
export async function localTransport(request: LocalTransportRequest): Promise<ApiData> {
|
|
110
|
+
try {
|
|
111
|
+
const method = request.method.toUpperCase() as OfflineMethod;
|
|
112
|
+
if (!METHODS.includes(method)) return toApiData(needsConnectionAnswer());
|
|
113
|
+
if (request.files) return toApiData(needsConnectionAnswer("Uploading files needs a connection"));
|
|
114
|
+
|
|
115
|
+
const config = getOfflineConfig();
|
|
116
|
+
const { path, query } = splitApiUrl(request.url);
|
|
117
|
+
const offlineRequest: OfflineRequest = { method, path, query, body: request.body, url: request.url };
|
|
118
|
+
|
|
119
|
+
const custom = matchRoute(config.routes, method, path);
|
|
120
|
+
const match = custom ?? matchRoute(buildGenericRoutes(config), method, path);
|
|
121
|
+
if (!match) return toApiData(needsConnectionAnswer(`${method} ${path} has no offline answer`));
|
|
122
|
+
|
|
123
|
+
let store: DeviceStore;
|
|
124
|
+
try {
|
|
125
|
+
store = await getOfflineStore();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// The device copy cannot be opened here (another tab holds the file, storage refused,
|
|
128
|
+
// no signed-in user): only the server can answer.
|
|
129
|
+
if (error instanceof NeedsConnectionError) return toApiData(needsConnectionAnswer(error.message));
|
|
130
|
+
console.warn("[offline] the device copy could not be opened", error);
|
|
131
|
+
return toApiData(needsConnectionAnswer("The device copy is not available in this tab"));
|
|
132
|
+
}
|
|
133
|
+
const ctx = { request: offlineRequest, params: match.params, store };
|
|
134
|
+
|
|
135
|
+
// The root record of a custom write (a `<root>/:param` capture, a stored record, or the body).
|
|
136
|
+
const customRootId = async (route: OfflineRoute): Promise<string | undefined> => {
|
|
137
|
+
const rootIndex = route.pattern.findIndex(
|
|
138
|
+
(segment) => typeof segment !== "string" && segment.name === config.rootModule.name,
|
|
139
|
+
);
|
|
140
|
+
const next = rootIndex >= 0 ? route.pattern[rootIndex + 1] : undefined;
|
|
141
|
+
const rootParam = typeof next === "string" && next.startsWith(":") ? next.slice(1) : undefined;
|
|
142
|
+
return rootIdForCustomWrite(store, offlineRequest, match.params, rootParam);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (!custom || !custom.route.queue) {
|
|
146
|
+
// A custom write that is not queued is refused on a locked root record too (GETs stay allowed).
|
|
147
|
+
if (custom && method !== "GET") {
|
|
148
|
+
const rootId = await customRootId(custom.route);
|
|
149
|
+
if (rootId && isRootWriteLocked(rootId)) {
|
|
150
|
+
return toApiData(needsConnectionAnswer("The root record is locked"));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const answer = await match.route.answer(ctx);
|
|
154
|
+
if (method !== "GET") await refreshPendingCount(store);
|
|
155
|
+
return toApiData(answer);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A custom write the server must run too: refused on a locked root record, otherwise the answer
|
|
159
|
+
// updates the device copy and the original request is queued in the same transaction.
|
|
160
|
+
const rootId = await customRootId(custom.route);
|
|
161
|
+
if (!rootId) return toApiData(needsConnectionAnswer(`${method} ${path} belongs to no root record on this device`));
|
|
162
|
+
if (isRootWriteLocked(rootId)) return toApiData(needsConnectionAnswer("The root record is locked"));
|
|
163
|
+
|
|
164
|
+
const answer = await store.transaction(async (transactionStore) => {
|
|
165
|
+
const result = await custom.route.answer({ ...ctx, store: transactionStore });
|
|
166
|
+
if (result.status >= 200 && result.status < 300) {
|
|
167
|
+
await transactionStore.enqueue({
|
|
168
|
+
rootId,
|
|
169
|
+
method,
|
|
170
|
+
path: queuePath(ctx),
|
|
171
|
+
body: request.body,
|
|
172
|
+
base: null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
});
|
|
177
|
+
await refreshPendingCount(store);
|
|
178
|
+
return toApiData(answer);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error instanceof NeedsConnectionError) return toApiData(needsConnectionAnswer(error.message));
|
|
181
|
+
console.error("[offline] local answer failed", error);
|
|
182
|
+
return toApiData({
|
|
183
|
+
status: 500,
|
|
184
|
+
body: {
|
|
185
|
+
errors: [{ status: "500", title: "Offline answer failed", detail: String((error as Error)?.message ?? error) }],
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
blockOfflineStore,
|
|
3
|
+
getOfflineConfig,
|
|
4
|
+
getOfflineStore,
|
|
5
|
+
isOfflineConfigured,
|
|
6
|
+
setOfflineEnabledOnDevice,
|
|
7
|
+
writeOfflineLastSync,
|
|
8
|
+
} from "./config";
|
|
9
|
+
import { setOfflineState } from "./state";
|
|
10
|
+
import { deleteImagesCaches } from "./sync/artwork-cache";
|
|
11
|
+
import { stopOfflineRuntime } from "./sync/runtime";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How many changes the signed-in user's device still has to send (every queue entry: pending,
|
|
15
|
+
* sending, in conflict or failed). The app warns before logging out when this is above 0.
|
|
16
|
+
*/
|
|
17
|
+
export async function prepareOfflineLogout(): Promise<{ pending: number }> {
|
|
18
|
+
if (!isOfflineConfigured()) return { pending: 0 };
|
|
19
|
+
if (!getOfflineConfig().getUserId()) return { pending: 0 };
|
|
20
|
+
const store = await getOfflineStore();
|
|
21
|
+
const entries = await store.queue();
|
|
22
|
+
return { pending: entries.length };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deletes the user's device database and the cached artwork images. Call it right before logging
|
|
27
|
+
* out. The runtime is stopped first and the store blocked until the next sign-in
|
|
28
|
+
* (`onUserSignedIn`), so a sync still running cannot open, and so recreate, the deleted file.
|
|
29
|
+
* The device switch (`requireOptIn`) is turned off too: after a logout nothing is downloaded again
|
|
30
|
+
* until the user turns offline mode back on.
|
|
31
|
+
*/
|
|
32
|
+
export async function finishOfflineLogout(userId: string): Promise<void> {
|
|
33
|
+
stopOfflineRuntime();
|
|
34
|
+
blockOfflineStore(userId);
|
|
35
|
+
const { deleteDeviceStore } = await import("./store/lifecycle");
|
|
36
|
+
await deleteDeviceStore(userId);
|
|
37
|
+
await deleteImagesCaches();
|
|
38
|
+
setOfflineEnabledOnDevice(userId, false);
|
|
39
|
+
writeOfflineLastSync(userId, null);
|
|
40
|
+
setOfflineState({ enabled: false, syncProgress: null, lastSyncedAt: null, rootCount: null });
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { OfflineMethod, OfflineRoute, PatternSegment } from "./types";
|
|
2
|
+
|
|
3
|
+
export type RouteMatch = { route: OfflineRoute; params: Record<string, string> };
|
|
4
|
+
|
|
5
|
+
function splitPath(path: string): string[] {
|
|
6
|
+
const withoutQuery = path.split("?")[0].split("#")[0];
|
|
7
|
+
return withoutQuery.split("/").filter((segment) => segment.length > 0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A Module segment may itself hold slashes ("roots/played"), so each one is expanded. */
|
|
11
|
+
function expandPattern(pattern: PatternSegment[]): string[] {
|
|
12
|
+
return pattern.flatMap((segment) => splitPath(typeof segment === "string" ? segment : segment.name));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function matchPattern(pattern: PatternSegment[], path: string): Record<string, string> | null {
|
|
16
|
+
const expected = expandPattern(pattern);
|
|
17
|
+
const actual = splitPath(path);
|
|
18
|
+
if (expected.length !== actual.length) return null;
|
|
19
|
+
|
|
20
|
+
const params: Record<string, string> = {};
|
|
21
|
+
for (let index = 0; index < expected.length; index++) {
|
|
22
|
+
const segment = expected[index];
|
|
23
|
+
const value = decodeURIComponent(actual[index]);
|
|
24
|
+
if (segment.startsWith(":")) {
|
|
25
|
+
params[segment.slice(1)] = value;
|
|
26
|
+
} else if (segment !== value) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return params;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first route, in the order given, whose method and pattern match. The query string is ignored. */
|
|
34
|
+
export function matchRoute(routes: OfflineRoute[], method: OfflineMethod, path: string): RouteMatch | null {
|
|
35
|
+
for (const route of routes) {
|
|
36
|
+
if (route.method !== method) continue;
|
|
37
|
+
const params = matchPattern(route.pattern, path);
|
|
38
|
+
if (params) return { route, params };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { buildOfflineManifest } from "../build-offline-manifest";
|
|
6
|
+
|
|
7
|
+
async function writeBuild(distDir: string, buildId: string | null, files: string[]) {
|
|
8
|
+
await mkdir(distDir, { recursive: true });
|
|
9
|
+
if (buildId !== null) await writeFile(path.join(distDir, "BUILD_ID"), `${buildId}\n`);
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
const full = path.join(distDir, "static", file);
|
|
12
|
+
await mkdir(path.dirname(full), { recursive: true });
|
|
13
|
+
await writeFile(full, "x");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("buildOfflineManifest", () => {
|
|
18
|
+
let root: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
root = await mkdtemp(path.join(tmpdir(), "nja-offline-manifest-"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await rm(root, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns buildId from <distDir>/BUILD_ID and every file under <distDir>/static as /_next/static/...", async () => {
|
|
29
|
+
const distDir = path.join(root, ".next");
|
|
30
|
+
await writeBuild(distDir, "abc123", [
|
|
31
|
+
"chunks/main.js",
|
|
32
|
+
"css/app.css",
|
|
33
|
+
"abc123/_buildManifest.js",
|
|
34
|
+
"media/font.woff2",
|
|
35
|
+
]);
|
|
36
|
+
// Files outside static/ are never listed.
|
|
37
|
+
await writeFile(path.join(distDir, "server-only.json"), "{}");
|
|
38
|
+
|
|
39
|
+
const manifest = await buildOfflineManifest({ distDir });
|
|
40
|
+
|
|
41
|
+
expect(manifest.buildId).toBe("abc123");
|
|
42
|
+
expect([...manifest.assets].sort()).toEqual(
|
|
43
|
+
[
|
|
44
|
+
"/_next/static/abc123/_buildManifest.js",
|
|
45
|
+
"/_next/static/chunks/main.js",
|
|
46
|
+
"/_next/static/css/app.css",
|
|
47
|
+
"/_next/static/media/font.woff2",
|
|
48
|
+
].sort(),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("honours a custom distDir", async () => {
|
|
53
|
+
await writeBuild(path.join(root, ".next"), "default-build", ["chunks/default.js"]);
|
|
54
|
+
const customDir = path.join(root, ".next-e2e-1");
|
|
55
|
+
await writeBuild(customDir, "e2e-build", ["chunks/e2e.js"]);
|
|
56
|
+
|
|
57
|
+
const manifest = await buildOfflineManifest({ distDir: customDir });
|
|
58
|
+
|
|
59
|
+
expect(manifest).toEqual({ buildId: "e2e-build", assets: ["/_next/static/chunks/e2e.js"] });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("next dev (no BUILD_ID): the id comes from the asset list and changes when a chunk changes", async () => {
|
|
63
|
+
const devDir = path.join(root, ".next", "dev");
|
|
64
|
+
await writeBuild(devDir, null, ["chunks/a_111.js", "development/_buildManifest.js"]);
|
|
65
|
+
|
|
66
|
+
const first = await buildOfflineManifest({ distDir: devDir });
|
|
67
|
+
const again = await buildOfflineManifest({ distDir: devDir });
|
|
68
|
+
expect(first.buildId).toMatch(/^dev-[0-9a-f]{16}$/);
|
|
69
|
+
expect(again.buildId).toBe(first.buildId);
|
|
70
|
+
expect(first.assets).toEqual(["/_next/static/chunks/a_111.js", "/_next/static/development/_buildManifest.js"]);
|
|
71
|
+
|
|
72
|
+
await writeBuild(devDir, null, ["chunks/a_222.js"]);
|
|
73
|
+
const edited = await buildOfflineManifest({ distDir: devDir });
|
|
74
|
+
expect(edited.buildId).not.toBe(first.buildId);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export type OfflineManifest = { buildId: string; assets: string[] };
|
|
6
|
+
|
|
7
|
+
async function listFiles(dir: string): Promise<string[]> {
|
|
8
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
9
|
+
const files: string[] = [];
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const full = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) files.push(...(await listFiles(full)));
|
|
13
|
+
else if (entry.isFile()) files.push(full);
|
|
14
|
+
}
|
|
15
|
+
return files;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Describes the running build for the service worker's offline shell precache
|
|
20
|
+
* (`/offline-manifest.json`): the build id from `<distDir>/BUILD_ID` and every
|
|
21
|
+
* file under `<distDir>/static`, as the `/_next/static/...` URL Next serves it at.
|
|
22
|
+
* `next dev` writes no BUILD_ID (its folder is `.next/dev`): the id is then derived from the
|
|
23
|
+
* asset list, whose chunk names carry content hashes, so an edit gives a new id and the
|
|
24
|
+
* service worker refreshes the shell.
|
|
25
|
+
* `distDir` must be the value the app's next.config uses. `extraAssets` are further same-origin
|
|
26
|
+
* URLs the shell needs with no network (the SQLite files the device worker loads).
|
|
27
|
+
*/
|
|
28
|
+
export async function buildOfflineManifest(params: {
|
|
29
|
+
distDir: string;
|
|
30
|
+
extraAssets?: string[];
|
|
31
|
+
}): Promise<OfflineManifest> {
|
|
32
|
+
const staticDir = path.join(params.distDir, "static");
|
|
33
|
+
const files = await listFiles(staticDir);
|
|
34
|
+
const assets = files
|
|
35
|
+
.map((file) => `/_next/static/${path.relative(staticDir, file).split(path.sep).join("/")}`)
|
|
36
|
+
.concat(params.extraAssets ?? [])
|
|
37
|
+
.sort();
|
|
38
|
+
const buildId = await readBuildId(params.distDir, assets);
|
|
39
|
+
return { buildId, assets };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readBuildId(distDir: string, assets: string[]): Promise<string> {
|
|
43
|
+
try {
|
|
44
|
+
return (await readFile(path.join(distDir, "BUILD_ID"), "utf8")).trim();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
47
|
+
return `dev-${createHash("sha256").update(assets.join("\n")).digest("hex").slice(0, 16)}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { getOfflineState, SERVER_SNAPSHOT, subscribeOfflineState, type OfflineState } from "./state";
|
|
3
|
+
|
|
4
|
+
export function useOfflineState(): OfflineState {
|
|
5
|
+
return useSyncExternalStore(subscribeOfflineState, getOfflineState, () => SERVER_SNAPSHOT);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useNeedsConnection(): boolean {
|
|
9
|
+
return !useOfflineState().online;
|
|
10
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Kept free of React: `directFetch` (every entry, server ones included) imports this module.
|
|
2
|
+
// The hooks live in state-hooks.ts, exported only from the `./offline` entry.
|
|
3
|
+
|
|
4
|
+
export type OfflineState = {
|
|
5
|
+
online: boolean;
|
|
6
|
+
pending: number;
|
|
7
|
+
conflicts: number;
|
|
8
|
+
draining: boolean;
|
|
9
|
+
lockedRootIds: string[];
|
|
10
|
+
/**
|
|
11
|
+
* False once this browser was found unable to keep the device copy (no secure context, no
|
|
12
|
+
* private file storage, or the worker reports the OPFS APIs missing). Offline mode is then off
|
|
13
|
+
* for the rest of the page: `isOfflineConfigured()` answers false and nothing offline runs.
|
|
14
|
+
*/
|
|
15
|
+
available: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Offline mode is on on this device for the signed-in user. Always true once configured
|
|
18
|
+
* without `OfflineConfig.requireOptIn`; otherwise the GM's per-device switch.
|
|
19
|
+
*/
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
/** The running full sync: root records done out of the total, null when no sync runs. */
|
|
22
|
+
syncProgress: { done: number; total: number } | null;
|
|
23
|
+
/** When the last full sync on this device finished (epoch ms), null when never. */
|
|
24
|
+
lastSyncedAt: number | null;
|
|
25
|
+
/** How many root records the last full sync kept on this device, null when unknown. */
|
|
26
|
+
rootCount: number | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const SERVER_SNAPSHOT: OfflineState = {
|
|
30
|
+
online: true,
|
|
31
|
+
pending: 0,
|
|
32
|
+
conflicts: 0,
|
|
33
|
+
draining: false,
|
|
34
|
+
lockedRootIds: [],
|
|
35
|
+
available: true,
|
|
36
|
+
enabled: false,
|
|
37
|
+
syncProgress: null,
|
|
38
|
+
lastSyncedAt: null,
|
|
39
|
+
rootCount: null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let _state: OfflineState = { ...SERVER_SNAPSHOT };
|
|
43
|
+
const _listeners = new Set<(s: OfflineState) => void>();
|
|
44
|
+
let _detachBrowserEvents: (() => void) | null = null;
|
|
45
|
+
|
|
46
|
+
export function getOfflineState(): OfflineState {
|
|
47
|
+
return _state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function subscribeOfflineState(listener: (s: OfflineState) => void): () => void {
|
|
51
|
+
_listeners.add(listener);
|
|
52
|
+
return () => {
|
|
53
|
+
_listeners.delete(listener);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
58
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
59
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
60
|
+
}
|
|
61
|
+
return a === b;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A reconnect attempt: a probe reached the API while the session is offline. The session stays
|
|
65
|
+
// offline (spec § 4: offline "until a sync succeeds") while the drain and the sync run; their
|
|
66
|
+
// reads and the change-set request go to the network meanwhile (`reachesNetworkWhileOffline`).
|
|
67
|
+
// Any network failure (`online: false` set again) ends the attempt.
|
|
68
|
+
let _reconnecting = false;
|
|
69
|
+
const _passThroughUrls = new Set<string>();
|
|
70
|
+
|
|
71
|
+
/** Whether a reconnect attempt (drain then sync, after a probe reached the API) is running. */
|
|
72
|
+
export function isReconnecting(): boolean {
|
|
73
|
+
return _reconnecting;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Starts or ends a reconnect attempt (the offline runtime). */
|
|
77
|
+
export function setReconnecting(value: boolean): void {
|
|
78
|
+
_reconnecting = value;
|
|
79
|
+
if (!value) _passThroughUrls.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Lets one write (the drain's change-set request) reach the network during a reconnect attempt. */
|
|
83
|
+
export function allowWhileReconnecting(url: string): () => void {
|
|
84
|
+
_passThroughUrls.add(url);
|
|
85
|
+
return () => {
|
|
86
|
+
_passThroughUrls.delete(url);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* While the session is offline, whether a request still goes to the network: only during a
|
|
92
|
+
* reconnect attempt, for a GET or a write the attempt let through. Any other write stays local
|
|
93
|
+
* (queued), so it is sent after the waiting changes, in order.
|
|
94
|
+
*/
|
|
95
|
+
export function reachesNetworkWhileOffline(method: string, url: string): boolean {
|
|
96
|
+
if (!_reconnecting) return false;
|
|
97
|
+
return method.toUpperCase() === "GET" || _passThroughUrls.has(url);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The sync and the drain may talk to the API: online, or during a reconnect attempt. */
|
|
101
|
+
export function canReachApi(): boolean {
|
|
102
|
+
return _state.online || _reconnecting;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Applies a partial change. Listeners run once, and only when a value actually changed. */
|
|
106
|
+
export function setOfflineState(patch: Partial<OfflineState>): void {
|
|
107
|
+
if (patch.online === false) setReconnecting(false);
|
|
108
|
+
const changed = (Object.keys(patch) as (keyof OfflineState)[]).some(
|
|
109
|
+
(key) => patch[key] !== undefined && !sameValue(patch[key], _state[key]),
|
|
110
|
+
);
|
|
111
|
+
if (!changed) return;
|
|
112
|
+
|
|
113
|
+
_state = { ..._state, ...patch };
|
|
114
|
+
for (const listener of [..._listeners]) listener(_state);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Follows the browser's offline event. Called by `configureOffline`, never at import, so an app
|
|
119
|
+
* that does not opt in gets no listener. The browser's online event alone never brings the
|
|
120
|
+
* session back online (spec § 4: offline "until a sync succeeds"): the runtime probes the API on
|
|
121
|
+
* that event and goes online only when the probe reaches it.
|
|
122
|
+
*/
|
|
123
|
+
export function attachBrowserOnlineEvents(): void {
|
|
124
|
+
if (_detachBrowserEvents || typeof window === "undefined") return;
|
|
125
|
+
|
|
126
|
+
if (typeof navigator !== "undefined" && typeof navigator.onLine === "boolean") {
|
|
127
|
+
setOfflineState({ online: navigator.onLine });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const onOffline = () => setOfflineState({ online: false });
|
|
131
|
+
window.addEventListener("offline", onOffline);
|
|
132
|
+
_detachBrowserEvents = () => {
|
|
133
|
+
window.removeEventListener("offline", onOffline);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Test helper: back to the initial state, browser events detached, listeners kept. */
|
|
138
|
+
export function resetOfflineState(): void {
|
|
139
|
+
_detachBrowserEvents?.();
|
|
140
|
+
_detachBrowserEvents = null;
|
|
141
|
+
setReconnecting(false);
|
|
142
|
+
_state = { ...SERVER_SNAPSHOT };
|
|
143
|
+
}
|