kaafil-react-uikit 0.8.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/dist/admin/index.cjs +364 -348
- package/dist/admin/index.cjs.map +1 -1
- package/dist/admin/index.js +24 -8
- package/dist/admin/index.js.map +1 -1
- package/dist/{chunk-7O3TPYK4.cjs → chunk-2WPNGUX5.cjs} +17 -17
- package/dist/{chunk-7O3TPYK4.cjs.map → chunk-2WPNGUX5.cjs.map} +1 -1
- package/dist/{chunk-DYOAKTGJ.js → chunk-6PQWLUBA.js} +5 -5
- package/dist/{chunk-DYOAKTGJ.js.map → chunk-6PQWLUBA.js.map} +1 -1
- package/dist/{chunk-GKR52SVU.js → chunk-CCK3WPMF.js} +3 -3
- package/dist/{chunk-GKR52SVU.js.map → chunk-CCK3WPMF.js.map} +1 -1
- package/dist/{chunk-7COMKJTM.cjs → chunk-ELJPQSXY.cjs} +9 -9
- package/dist/{chunk-7COMKJTM.cjs.map → chunk-ELJPQSXY.cjs.map} +1 -1
- package/dist/{chunk-TDFM563X.cjs → chunk-FO5VJJUO.cjs} +12 -12
- package/dist/{chunk-TDFM563X.cjs.map → chunk-FO5VJJUO.cjs.map} +1 -1
- package/dist/{chunk-VU5BCK2P.js → chunk-FSSB6J3X.js} +3 -3
- package/dist/{chunk-VU5BCK2P.js.map → chunk-FSSB6J3X.js.map} +1 -1
- package/dist/{chunk-YHGAFRN6.js → chunk-GOSSRXBN.js} +3 -3
- package/dist/{chunk-YHGAFRN6.js.map → chunk-GOSSRXBN.js.map} +1 -1
- package/dist/{chunk-MP4IMAPF.cjs → chunk-IAXVUR7H.cjs} +55 -55
- package/dist/{chunk-MP4IMAPF.cjs.map → chunk-IAXVUR7H.cjs.map} +1 -1
- package/dist/{chunk-4R7QBTNL.cjs → chunk-PFTWPEVV.cjs} +4 -4
- package/dist/{chunk-4R7QBTNL.cjs.map → chunk-PFTWPEVV.cjs.map} +1 -1
- package/dist/chunk-PG7HNBTO.cjs +12 -0
- package/dist/{chunk-LBTAK5JA.cjs.map → chunk-PG7HNBTO.cjs.map} +1 -1
- package/dist/{chunk-ASFUY3IE.cjs → chunk-SMGXVEN3.cjs} +8 -4
- package/dist/chunk-SMGXVEN3.cjs.map +1 -0
- package/dist/{chunk-57DYMIWS.js → chunk-SO4F5LTH.js} +5 -5
- package/dist/{chunk-57DYMIWS.js.map → chunk-SO4F5LTH.js.map} +1 -1
- package/dist/{chunk-ZURCZQQ7.js → chunk-TESIDLJ6.js} +8 -4
- package/dist/chunk-TESIDLJ6.js.map +1 -0
- package/dist/{chunk-Q4B25EZO.js → chunk-TJGE4ZSX.js} +3 -3
- package/dist/{chunk-Q4B25EZO.js.map → chunk-TJGE4ZSX.js.map} +1 -1
- package/dist/{chunk-PQJGV3RE.cjs → chunk-TYBIAPPT.cjs} +83 -83
- package/dist/{chunk-PQJGV3RE.cjs.map → chunk-TYBIAPPT.cjs.map} +1 -1
- package/dist/{chunk-E3KOQYY2.js → chunk-YWMDBY43.js} +3 -3
- package/dist/{chunk-E3KOQYY2.js.map → chunk-YWMDBY43.js.map} +1 -1
- package/dist/core/index.cjs +107 -107
- package/dist/core/index.js +10 -10
- package/dist/manager/index.cjs +240 -240
- package/dist/manager/index.cjs.map +1 -1
- package/dist/manager/index.d.cts +16 -3
- package/dist/manager/index.d.ts +16 -3
- package/dist/manager/index.js +7 -7
- package/dist/manager/index.js.map +1 -1
- package/dist/offline/index.cjs +112 -0
- package/dist/offline/index.cjs.map +1 -0
- package/dist/offline/index.d.cts +129 -0
- package/dist/offline/index.d.ts +129 -0
- package/dist/offline/index.js +108 -0
- package/dist/offline/index.js.map +1 -0
- package/dist/traveller/index.cjs +35 -35
- package/dist/traveller/index.js +5 -5
- package/package.json +6 -1
- package/dist/chunk-ASFUY3IE.cjs.map +0 -1
- package/dist/chunk-LBTAK5JA.cjs +0 -12
- package/dist/chunk-ZURCZQQ7.js.map +0 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/offline/credentialCache.ts
|
|
4
|
+
function isFetchNetworkError(error) {
|
|
5
|
+
return error instanceof TypeError;
|
|
6
|
+
}
|
|
7
|
+
function withCachedCredential(resolve, options) {
|
|
8
|
+
const { store } = options;
|
|
9
|
+
const isUnreachable = options.isUnreachable ?? isFetchNetworkError;
|
|
10
|
+
return async function resolveWithCache() {
|
|
11
|
+
try {
|
|
12
|
+
const fresh = await resolve();
|
|
13
|
+
await store.write(fresh);
|
|
14
|
+
return fresh;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (!isUnreachable(error)) throw error;
|
|
17
|
+
const cached = await store.read();
|
|
18
|
+
if (cached === null) throw error;
|
|
19
|
+
return cached;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function localStorageCredentialStore(scope) {
|
|
24
|
+
const key = `kaafil:credential:${scope}`;
|
|
25
|
+
return {
|
|
26
|
+
read() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = localStorage.getItem(key);
|
|
29
|
+
if (raw === null) return null;
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
write(credential) {
|
|
36
|
+
try {
|
|
37
|
+
localStorage.setItem(key, JSON.stringify(credential));
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
clear() {
|
|
42
|
+
try {
|
|
43
|
+
localStorage.removeItem(key);
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/offline/serviceWorker.ts
|
|
51
|
+
function isKaafilApiHost(url) {
|
|
52
|
+
return url.hostname === "kaafil.in" || url.hostname.endsWith(".kaafil.in");
|
|
53
|
+
}
|
|
54
|
+
function installKaafilOfflineShell(options) {
|
|
55
|
+
const scope = self;
|
|
56
|
+
const { cacheName, appShellUrl, precache } = options;
|
|
57
|
+
const isApiRequest = options.isApiRequest ?? isKaafilApiHost;
|
|
58
|
+
scope.addEventListener("install", (event) => {
|
|
59
|
+
event.waitUntil(
|
|
60
|
+
(async () => {
|
|
61
|
+
const cache = await caches.open(cacheName);
|
|
62
|
+
await cache.addAll([...precache]);
|
|
63
|
+
await scope.skipWaiting();
|
|
64
|
+
})()
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
scope.addEventListener("activate", (event) => {
|
|
68
|
+
event.waitUntil(
|
|
69
|
+
(async () => {
|
|
70
|
+
const names = await caches.keys();
|
|
71
|
+
await Promise.all(
|
|
72
|
+
names.filter((name) => name !== cacheName).map((name) => caches.delete(name))
|
|
73
|
+
);
|
|
74
|
+
await scope.clients.claim();
|
|
75
|
+
})()
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
scope.addEventListener("fetch", (event) => {
|
|
79
|
+
const { request } = event;
|
|
80
|
+
if (request.method !== "GET") return;
|
|
81
|
+
const url = new URL(request.url);
|
|
82
|
+
if (isApiRequest(url)) return;
|
|
83
|
+
if (request.mode === "navigate") {
|
|
84
|
+
event.respondWith(
|
|
85
|
+
(async () => {
|
|
86
|
+
try {
|
|
87
|
+
return await fetch(request);
|
|
88
|
+
} catch {
|
|
89
|
+
const cache = await caches.open(cacheName);
|
|
90
|
+
const shell = await cache.match(appShellUrl);
|
|
91
|
+
if (shell !== void 0) return shell;
|
|
92
|
+
throw new Error(`kaafil offline shell: ${appShellUrl} is not in ${cacheName}`);
|
|
93
|
+
}
|
|
94
|
+
})()
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
event.respondWith(
|
|
99
|
+
(async () => {
|
|
100
|
+
const cache = await caches.open(cacheName);
|
|
101
|
+
const hit = await cache.match(request);
|
|
102
|
+
return hit ?? await fetch(request);
|
|
103
|
+
})()
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
exports.installKaafilOfflineShell = installKaafilOfflineShell;
|
|
109
|
+
exports.localStorageCredentialStore = localStorageCredentialStore;
|
|
110
|
+
exports.withCachedCredential = withCachedCredential;
|
|
111
|
+
//# sourceMappingURL=index.cjs.map
|
|
112
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/offline/credentialCache.ts","../../src/offline/serviceWorker.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["// An OPT-IN cache around the host's own credential resolver.\n//\n// The kit never persists a credential — where a token lives, how long it is\n// kept and what clears it are host security decisions, not a design system's\n// (`SessionShell.tsx`'s own header states this, and it is why\n// `credentialResolver` is a one-shot supplier whose result is handed straight\n// to `client.*.open()` and never stored).\n//\n// That rule is about WHERE the token lives, not about who sequences the\n// fallback. Every integrator hits the same two subtleties on the way to\n// milestone 10, and both are easy to get quietly wrong:\n//\n// 1. Falling back on ANY failure. A 401 or a 500 from a reachable server is\n// a real failure and must still surface — silently serving a cached\n// credential past a revocation is the failure mode that matters.\n// 2. Treating the cache as an authority on freshness. It is not, and this\n// module deliberately cannot be: see `isUnreachable` below.\n//\n// So this helper owns the SEQUENCE and leaves the STORAGE to the host, which\n// keeps the security decision exactly where it was.\n\n/** The shape `KaafilUIKitProvider`'s `credentialResolver` resolves to —\n * structurally identical to `SessionShell.tsx`'s own union, restated here\n * rather than imported because nothing outside `src/core` may reach into the\n * SDK-facing shells (hard rule #1) and this module must stay SDK-free. */\nexport type StoredCredential =\n | { readonly accessToken: string; readonly refreshToken: string; readonly agencyRef: string }\n | { readonly shareToken: string };\n\nexport type CredentialResolver = () => Promise<StoredCredential>;\n\n/**\n * The host's own storage. Sync or async — a `localStorage` implementation and\n * an IndexedDB one both satisfy this.\n *\n * Nothing here is written to `window` (hard rule #22); a storage API is the\n * host's to choose, a global token stash is not.\n */\nexport interface CredentialStore {\n read(): Promise<StoredCredential | null> | StoredCredential | null;\n write(credential: StoredCredential): Promise<void> | void;\n clear(): Promise<void> | void;\n}\n\nexport interface CachedCredentialOptions {\n readonly store: CredentialStore;\n /**\n * Whether a thrown value means **the request never completed** — the only\n * case in which the cached credential may be used.\n *\n * The default recognises exactly one thing: the `TypeError` that `fetch`\n * throws when it cannot reach the network at all. That is deliberately\n * narrow. A host using axios, ky or their own wrapper throws something else\n * for the same condition and MUST supply this — a predicate that returns\n * `true` too eagerly turns a 401 into a silent session extension, which is\n * the exact bug this helper exists to prevent.\n */\n readonly isUnreachable?: (error: unknown) => boolean;\n}\n\n/** `fetch` rejects with a `TypeError` when the request never reached a server.\n * A response with a status — 401, 423, 500 — resolves, and is therefore not\n * this case. */\nfunction isFetchNetworkError(error: unknown): boolean {\n return error instanceof TypeError;\n}\n\n/**\n * Wrap a credential resolver so a reload with no network still opens the\n * surface, instead of stranding the outbox behind a session that cannot mint.\n *\n * ```ts\n * const resolver = withCachedCredential(\n * () => fetch('/api/admin-session', { method: 'POST' }).then((r) => r.json()),\n * { store: localStorageCredentialStore('sharma:desk') },\n * );\n * ```\n *\n * ── WHAT THIS DOES NOT DO, ON PURPOSE ───────────────────────────────────────\n *\n * It stores and restores. It NEVER judges freshness, and it has no opinion on\n * expiry. A cached access token still expires, and offline it cannot be\n * refreshed — a manager who has been offline longer than the token's life will\n * not get in, and that is a real limit to design around, not a bug here. The\n * kit surfaces the outcome through `onSessionExpired` on the provider, which\n * is where a host reacts.\n *\n * That is not only a design preference: \"now\" in this package is\n * `meta.serverTime`, never a local wall clock, and a device that has been\n * offline for hours has no trustworthy `serverTime` to compare against. A\n * helper that checked expiry here would have to read the device clock to do\n * it — the precise thing the architecture forbids, for the precise reason it\n * would be wrong.\n */\nexport function withCachedCredential(\n resolve: CredentialResolver,\n options: CachedCredentialOptions,\n): CredentialResolver {\n const { store } = options;\n const isUnreachable = options.isUnreachable ?? isFetchNetworkError;\n\n return async function resolveWithCache(): Promise<StoredCredential> {\n try {\n const fresh = await resolve();\n await store.write(fresh);\n return fresh;\n } catch (error: unknown) {\n // A server that ANSWERED — 401, 403, 500 — is a real failure and must\n // surface. Only a request that never completed may fall back.\n if (!isUnreachable(error)) throw error;\n\n const cached = await store.read();\n if (cached === null) throw error;\n\n return cached;\n }\n };\n}\n\n/**\n * A `CredentialStore` over `localStorage`, for hosts that have decided that is\n * the right place for their token. Deliberately NOT the default — the kit does\n * not choose where a credential lives.\n *\n * `scope` is an IDENTIFIER (a staff id, a persona name), never the\n * credential's own secret: it becomes a storage key, and a key is readable by\n * anything that can read the store.\n *\n * Every access is wrapped: a private window, cleared site data, or a browser\n * configured to block site storage makes these APIs throw on access rather\n * than return empty, and a credential cache that takes the app down when\n * storage is unavailable is worse than no cache at all.\n */\nexport function localStorageCredentialStore(scope: string): CredentialStore {\n const key = `kaafil:credential:${scope}`;\n\n return {\n read(): StoredCredential | null {\n try {\n const raw = localStorage.getItem(key);\n if (raw === null) return null;\n return JSON.parse(raw) as StoredCredential;\n } catch {\n return null;\n }\n },\n write(credential: StoredCredential): void {\n try {\n localStorage.setItem(key, JSON.stringify(credential));\n } catch {\n // Storage unavailable or full — the surface still opens online, and\n // the offline reload is what degrades. Never fail the mint for this.\n }\n },\n clear(): void {\n try {\n localStorage.removeItem(key);\n } catch {\n // Same reasoning as `write`.\n }\n },\n };\n}\n","// The Kaafil-specific half of a host's service worker.\n//\n// ── WHY THIS IS NOT A DROP-IN SERVICE WORKER ────────────────────────────────\n//\n// Kaafil makes your DATA survive a reload — the write goes to a durable outbox\n// and the read comes back from the snapshot store, both in IndexedDB, both\n// outliving a reload. It does nothing about your APPLICATION: the HTML\n// document, your JS, your CSS. Those come off the network like any other page,\n// so with no service worker a reload while offline gets the browser's offline\n// error page, and the queued writes sit intact behind a document that will not\n// open. The data was never lost; you just cannot reach it.\n//\n// Closing that gap is a service worker, and the CACHING half of it belongs to\n// the host: only your bundler knows your build's hashed filenames, your deploy\n// cadence and your revalidation strategy. Shipping an opinionated one here\n// would fight `vite-plugin-pwa`, `next-pwa` or whatever Workbox config you\n// already have.\n//\n// What this module ships instead is the part the kit genuinely knows: the\n// rules that are true of ANY Kaafil integration regardless of build tool. You\n// supply the precache manifest; this supplies the fetch policy.\n//\n// ── ON CACHING THE UIKIT'S OWN CSS AND JS ───────────────────────────────────\n//\n// There is nothing separate to do. This package is bundled INTO your build, so\n// its CSS and JS are already part of the output your precache manifest covers.\n// There is no Kaafil-specific asset step to add, and no Kaafil CDN URL to\n// allow — if your app shell is cached, so is the UIKit.\n\n/** The subset of the service-worker globals this module touches, declared\n * locally so the package does not have to add `lib: [\"WebWorker\"]` to its\n * tsconfig for one file. */\ninterface ServiceWorkerFetchEvent extends Event {\n readonly request: Request;\n respondWith(response: Promise<Response> | Response): void;\n}\n\ninterface ServiceWorkerLifecycleEvent extends Event {\n waitUntil(promise: Promise<unknown>): void;\n}\n\ninterface ServiceWorkerScope {\n addEventListener(type: 'install', listener: (event: ServiceWorkerLifecycleEvent) => void): void;\n addEventListener(type: 'activate', listener: (event: ServiceWorkerLifecycleEvent) => void): void;\n addEventListener(type: 'fetch', listener: (event: ServiceWorkerFetchEvent) => void): void;\n skipWaiting(): Promise<void>;\n readonly clients: { claim(): Promise<void> };\n}\n\nexport interface KaafilOfflineShellOptions {\n /**\n * The cache this worker owns. Change it to invalidate — `activate` deletes\n * every cache whose name differs, so your version string stays the only\n * revalidation knob and this module never invents a second one.\n */\n readonly cacheName: string;\n /**\n * The document served for a navigation that cannot reach the network. Must\n * be one of `precache`.\n */\n readonly appShellUrl: string;\n /**\n * Your build output, from your own bundler. `vite-plugin-pwa` and Workbox\n * both inject a manifest; map it to URLs and pass it here.\n */\n readonly precache: readonly string[];\n /**\n * Whether a URL is a Kaafil API call, which must NEVER be served from cache.\n * Defaults to any `kaafil.in` host. Override if you proxy the API through\n * your own origin — and if you do, this is the one option you cannot afford\n * to get wrong; see the note on `handleFetch`.\n */\n readonly isApiRequest?: (url: URL) => boolean;\n}\n\nfunction isKaafilApiHost(url: URL): boolean {\n return url.hostname === 'kaafil.in' || url.hostname.endsWith('.kaafil.in');\n}\n\n/**\n * Register the Kaafil offline-shell rules on a service worker you own.\n *\n * ```js title=\"src/sw.ts\"\n * import { installKaafilOfflineShell } from 'kaafil-react-uikit/offline';\n *\n * installKaafilOfflineShell({\n * cacheName: 'sharma-shell-v4',\n * appShellUrl: '/index.html',\n * precache: self.__WB_MANIFEST.map((e) => e.url),\n * });\n * ```\n *\n * Call it from your worker's top level. It adds `install`, `activate` and\n * `fetch` listeners and returns nothing — you are free to add your own\n * listeners alongside, including a `fetch` handler for routes this one\n * declines (it calls `respondWith` only for requests it actually owns).\n */\nexport function installKaafilOfflineShell(options: KaafilOfflineShellOptions): void {\n const scope = self as unknown as ServiceWorkerScope;\n const { cacheName, appShellUrl, precache } = options;\n const isApiRequest = options.isApiRequest ?? isKaafilApiHost;\n\n scope.addEventListener('install', (event) => {\n event.waitUntil(\n (async () => {\n const cache = await caches.open(cacheName);\n await cache.addAll([...precache]);\n await scope.skipWaiting();\n })(),\n );\n });\n\n scope.addEventListener('activate', (event) => {\n event.waitUntil(\n (async () => {\n const names = await caches.keys();\n await Promise.all(\n names.filter((name) => name !== cacheName).map((name) => caches.delete(name)),\n );\n await scope.clients.claim();\n })(),\n );\n });\n\n scope.addEventListener('fetch', (event) => {\n const { request } = event;\n\n // NEVER touch a write. The outbox owns retries and their idempotency (no\n // hook mints its own key, and none accepts one); a service worker that\n // replays a POST is a second retry engine racing the first, and the\n // visible symptom is a duplicate expense nobody can explain.\n if (request.method !== 'GET') return;\n\n const url = new URL(request.url);\n\n // NEVER cache a Kaafil API response. Two different failures, both real: a\n // cached trip read shows a manager stale data they will act on, and a\n // cached SHARE response keeps serving a traveller's itinerary after the\n // token was revoked — a disclosure, not a staleness bug. Reads that should\n // survive offline already do, from the SDK's own snapshot store.\n if (isApiRequest(url)) return;\n\n // A navigation that cannot reach the network falls back to the cached app\n // shell. This single rule is what turns \"reload while offline\" from the\n // browser's error page into a working surface with the outbox intact.\n if (request.mode === 'navigate') {\n event.respondWith(\n (async () => {\n try {\n return await fetch(request);\n } catch {\n const cache = await caches.open(cacheName);\n const shell = await cache.match(appShellUrl);\n if (shell !== undefined) return shell;\n throw new Error(`kaafil offline shell: ${appShellUrl} is not in ${cacheName}`);\n }\n })(),\n );\n return;\n }\n\n // Everything else that was precached is served cache-first. Anything not\n // precached is left alone for the host's own handlers, or the network.\n event.respondWith(\n (async () => {\n const cache = await caches.open(cacheName);\n const hit = await cache.match(request);\n return hit ?? (await fetch(request));\n })(),\n );\n });\n}\n"]}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** The shape `KaafilUIKitProvider`'s `credentialResolver` resolves to —
|
|
2
|
+
* structurally identical to `SessionShell.tsx`'s own union, restated here
|
|
3
|
+
* rather than imported because nothing outside `src/core` may reach into the
|
|
4
|
+
* SDK-facing shells (hard rule #1) and this module must stay SDK-free. */
|
|
5
|
+
type StoredCredential = {
|
|
6
|
+
readonly accessToken: string;
|
|
7
|
+
readonly refreshToken: string;
|
|
8
|
+
readonly agencyRef: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly shareToken: string;
|
|
11
|
+
};
|
|
12
|
+
type CredentialResolver = () => Promise<StoredCredential>;
|
|
13
|
+
/**
|
|
14
|
+
* The host's own storage. Sync or async — a `localStorage` implementation and
|
|
15
|
+
* an IndexedDB one both satisfy this.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here is written to `window` (hard rule #22); a storage API is the
|
|
18
|
+
* host's to choose, a global token stash is not.
|
|
19
|
+
*/
|
|
20
|
+
interface CredentialStore {
|
|
21
|
+
read(): Promise<StoredCredential | null> | StoredCredential | null;
|
|
22
|
+
write(credential: StoredCredential): Promise<void> | void;
|
|
23
|
+
clear(): Promise<void> | void;
|
|
24
|
+
}
|
|
25
|
+
interface CachedCredentialOptions {
|
|
26
|
+
readonly store: CredentialStore;
|
|
27
|
+
/**
|
|
28
|
+
* Whether a thrown value means **the request never completed** — the only
|
|
29
|
+
* case in which the cached credential may be used.
|
|
30
|
+
*
|
|
31
|
+
* The default recognises exactly one thing: the `TypeError` that `fetch`
|
|
32
|
+
* throws when it cannot reach the network at all. That is deliberately
|
|
33
|
+
* narrow. A host using axios, ky or their own wrapper throws something else
|
|
34
|
+
* for the same condition and MUST supply this — a predicate that returns
|
|
35
|
+
* `true` too eagerly turns a 401 into a silent session extension, which is
|
|
36
|
+
* the exact bug this helper exists to prevent.
|
|
37
|
+
*/
|
|
38
|
+
readonly isUnreachable?: (error: unknown) => boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a credential resolver so a reload with no network still opens the
|
|
42
|
+
* surface, instead of stranding the outbox behind a session that cannot mint.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const resolver = withCachedCredential(
|
|
46
|
+
* () => fetch('/api/admin-session', { method: 'POST' }).then((r) => r.json()),
|
|
47
|
+
* { store: localStorageCredentialStore('sharma:desk') },
|
|
48
|
+
* );
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* ── WHAT THIS DOES NOT DO, ON PURPOSE ───────────────────────────────────────
|
|
52
|
+
*
|
|
53
|
+
* It stores and restores. It NEVER judges freshness, and it has no opinion on
|
|
54
|
+
* expiry. A cached access token still expires, and offline it cannot be
|
|
55
|
+
* refreshed — a manager who has been offline longer than the token's life will
|
|
56
|
+
* not get in, and that is a real limit to design around, not a bug here. The
|
|
57
|
+
* kit surfaces the outcome through `onSessionExpired` on the provider, which
|
|
58
|
+
* is where a host reacts.
|
|
59
|
+
*
|
|
60
|
+
* That is not only a design preference: "now" in this package is
|
|
61
|
+
* `meta.serverTime`, never a local wall clock, and a device that has been
|
|
62
|
+
* offline for hours has no trustworthy `serverTime` to compare against. A
|
|
63
|
+
* helper that checked expiry here would have to read the device clock to do
|
|
64
|
+
* it — the precise thing the architecture forbids, for the precise reason it
|
|
65
|
+
* would be wrong.
|
|
66
|
+
*/
|
|
67
|
+
declare function withCachedCredential(resolve: CredentialResolver, options: CachedCredentialOptions): CredentialResolver;
|
|
68
|
+
/**
|
|
69
|
+
* A `CredentialStore` over `localStorage`, for hosts that have decided that is
|
|
70
|
+
* the right place for their token. Deliberately NOT the default — the kit does
|
|
71
|
+
* not choose where a credential lives.
|
|
72
|
+
*
|
|
73
|
+
* `scope` is an IDENTIFIER (a staff id, a persona name), never the
|
|
74
|
+
* credential's own secret: it becomes a storage key, and a key is readable by
|
|
75
|
+
* anything that can read the store.
|
|
76
|
+
*
|
|
77
|
+
* Every access is wrapped: a private window, cleared site data, or a browser
|
|
78
|
+
* configured to block site storage makes these APIs throw on access rather
|
|
79
|
+
* than return empty, and a credential cache that takes the app down when
|
|
80
|
+
* storage is unavailable is worse than no cache at all.
|
|
81
|
+
*/
|
|
82
|
+
declare function localStorageCredentialStore(scope: string): CredentialStore;
|
|
83
|
+
|
|
84
|
+
interface KaafilOfflineShellOptions {
|
|
85
|
+
/**
|
|
86
|
+
* The cache this worker owns. Change it to invalidate — `activate` deletes
|
|
87
|
+
* every cache whose name differs, so your version string stays the only
|
|
88
|
+
* revalidation knob and this module never invents a second one.
|
|
89
|
+
*/
|
|
90
|
+
readonly cacheName: string;
|
|
91
|
+
/**
|
|
92
|
+
* The document served for a navigation that cannot reach the network. Must
|
|
93
|
+
* be one of `precache`.
|
|
94
|
+
*/
|
|
95
|
+
readonly appShellUrl: string;
|
|
96
|
+
/**
|
|
97
|
+
* Your build output, from your own bundler. `vite-plugin-pwa` and Workbox
|
|
98
|
+
* both inject a manifest; map it to URLs and pass it here.
|
|
99
|
+
*/
|
|
100
|
+
readonly precache: readonly string[];
|
|
101
|
+
/**
|
|
102
|
+
* Whether a URL is a Kaafil API call, which must NEVER be served from cache.
|
|
103
|
+
* Defaults to any `kaafil.in` host. Override if you proxy the API through
|
|
104
|
+
* your own origin — and if you do, this is the one option you cannot afford
|
|
105
|
+
* to get wrong; see the note on `handleFetch`.
|
|
106
|
+
*/
|
|
107
|
+
readonly isApiRequest?: (url: URL) => boolean;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Register the Kaafil offline-shell rules on a service worker you own.
|
|
111
|
+
*
|
|
112
|
+
* ```js title="src/sw.ts"
|
|
113
|
+
* import { installKaafilOfflineShell } from 'kaafil-react-uikit/offline';
|
|
114
|
+
*
|
|
115
|
+
* installKaafilOfflineShell({
|
|
116
|
+
* cacheName: 'sharma-shell-v4',
|
|
117
|
+
* appShellUrl: '/index.html',
|
|
118
|
+
* precache: self.__WB_MANIFEST.map((e) => e.url),
|
|
119
|
+
* });
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* Call it from your worker's top level. It adds `install`, `activate` and
|
|
123
|
+
* `fetch` listeners and returns nothing — you are free to add your own
|
|
124
|
+
* listeners alongside, including a `fetch` handler for routes this one
|
|
125
|
+
* declines (it calls `respondWith` only for requests it actually owns).
|
|
126
|
+
*/
|
|
127
|
+
declare function installKaafilOfflineShell(options: KaafilOfflineShellOptions): void;
|
|
128
|
+
|
|
129
|
+
export { type CachedCredentialOptions, type CredentialResolver, type CredentialStore, type KaafilOfflineShellOptions, type StoredCredential, installKaafilOfflineShell, localStorageCredentialStore, withCachedCredential };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** The shape `KaafilUIKitProvider`'s `credentialResolver` resolves to —
|
|
2
|
+
* structurally identical to `SessionShell.tsx`'s own union, restated here
|
|
3
|
+
* rather than imported because nothing outside `src/core` may reach into the
|
|
4
|
+
* SDK-facing shells (hard rule #1) and this module must stay SDK-free. */
|
|
5
|
+
type StoredCredential = {
|
|
6
|
+
readonly accessToken: string;
|
|
7
|
+
readonly refreshToken: string;
|
|
8
|
+
readonly agencyRef: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly shareToken: string;
|
|
11
|
+
};
|
|
12
|
+
type CredentialResolver = () => Promise<StoredCredential>;
|
|
13
|
+
/**
|
|
14
|
+
* The host's own storage. Sync or async — a `localStorage` implementation and
|
|
15
|
+
* an IndexedDB one both satisfy this.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here is written to `window` (hard rule #22); a storage API is the
|
|
18
|
+
* host's to choose, a global token stash is not.
|
|
19
|
+
*/
|
|
20
|
+
interface CredentialStore {
|
|
21
|
+
read(): Promise<StoredCredential | null> | StoredCredential | null;
|
|
22
|
+
write(credential: StoredCredential): Promise<void> | void;
|
|
23
|
+
clear(): Promise<void> | void;
|
|
24
|
+
}
|
|
25
|
+
interface CachedCredentialOptions {
|
|
26
|
+
readonly store: CredentialStore;
|
|
27
|
+
/**
|
|
28
|
+
* Whether a thrown value means **the request never completed** — the only
|
|
29
|
+
* case in which the cached credential may be used.
|
|
30
|
+
*
|
|
31
|
+
* The default recognises exactly one thing: the `TypeError` that `fetch`
|
|
32
|
+
* throws when it cannot reach the network at all. That is deliberately
|
|
33
|
+
* narrow. A host using axios, ky or their own wrapper throws something else
|
|
34
|
+
* for the same condition and MUST supply this — a predicate that returns
|
|
35
|
+
* `true` too eagerly turns a 401 into a silent session extension, which is
|
|
36
|
+
* the exact bug this helper exists to prevent.
|
|
37
|
+
*/
|
|
38
|
+
readonly isUnreachable?: (error: unknown) => boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a credential resolver so a reload with no network still opens the
|
|
42
|
+
* surface, instead of stranding the outbox behind a session that cannot mint.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const resolver = withCachedCredential(
|
|
46
|
+
* () => fetch('/api/admin-session', { method: 'POST' }).then((r) => r.json()),
|
|
47
|
+
* { store: localStorageCredentialStore('sharma:desk') },
|
|
48
|
+
* );
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* ── WHAT THIS DOES NOT DO, ON PURPOSE ───────────────────────────────────────
|
|
52
|
+
*
|
|
53
|
+
* It stores and restores. It NEVER judges freshness, and it has no opinion on
|
|
54
|
+
* expiry. A cached access token still expires, and offline it cannot be
|
|
55
|
+
* refreshed — a manager who has been offline longer than the token's life will
|
|
56
|
+
* not get in, and that is a real limit to design around, not a bug here. The
|
|
57
|
+
* kit surfaces the outcome through `onSessionExpired` on the provider, which
|
|
58
|
+
* is where a host reacts.
|
|
59
|
+
*
|
|
60
|
+
* That is not only a design preference: "now" in this package is
|
|
61
|
+
* `meta.serverTime`, never a local wall clock, and a device that has been
|
|
62
|
+
* offline for hours has no trustworthy `serverTime` to compare against. A
|
|
63
|
+
* helper that checked expiry here would have to read the device clock to do
|
|
64
|
+
* it — the precise thing the architecture forbids, for the precise reason it
|
|
65
|
+
* would be wrong.
|
|
66
|
+
*/
|
|
67
|
+
declare function withCachedCredential(resolve: CredentialResolver, options: CachedCredentialOptions): CredentialResolver;
|
|
68
|
+
/**
|
|
69
|
+
* A `CredentialStore` over `localStorage`, for hosts that have decided that is
|
|
70
|
+
* the right place for their token. Deliberately NOT the default — the kit does
|
|
71
|
+
* not choose where a credential lives.
|
|
72
|
+
*
|
|
73
|
+
* `scope` is an IDENTIFIER (a staff id, a persona name), never the
|
|
74
|
+
* credential's own secret: it becomes a storage key, and a key is readable by
|
|
75
|
+
* anything that can read the store.
|
|
76
|
+
*
|
|
77
|
+
* Every access is wrapped: a private window, cleared site data, or a browser
|
|
78
|
+
* configured to block site storage makes these APIs throw on access rather
|
|
79
|
+
* than return empty, and a credential cache that takes the app down when
|
|
80
|
+
* storage is unavailable is worse than no cache at all.
|
|
81
|
+
*/
|
|
82
|
+
declare function localStorageCredentialStore(scope: string): CredentialStore;
|
|
83
|
+
|
|
84
|
+
interface KaafilOfflineShellOptions {
|
|
85
|
+
/**
|
|
86
|
+
* The cache this worker owns. Change it to invalidate — `activate` deletes
|
|
87
|
+
* every cache whose name differs, so your version string stays the only
|
|
88
|
+
* revalidation knob and this module never invents a second one.
|
|
89
|
+
*/
|
|
90
|
+
readonly cacheName: string;
|
|
91
|
+
/**
|
|
92
|
+
* The document served for a navigation that cannot reach the network. Must
|
|
93
|
+
* be one of `precache`.
|
|
94
|
+
*/
|
|
95
|
+
readonly appShellUrl: string;
|
|
96
|
+
/**
|
|
97
|
+
* Your build output, from your own bundler. `vite-plugin-pwa` and Workbox
|
|
98
|
+
* both inject a manifest; map it to URLs and pass it here.
|
|
99
|
+
*/
|
|
100
|
+
readonly precache: readonly string[];
|
|
101
|
+
/**
|
|
102
|
+
* Whether a URL is a Kaafil API call, which must NEVER be served from cache.
|
|
103
|
+
* Defaults to any `kaafil.in` host. Override if you proxy the API through
|
|
104
|
+
* your own origin — and if you do, this is the one option you cannot afford
|
|
105
|
+
* to get wrong; see the note on `handleFetch`.
|
|
106
|
+
*/
|
|
107
|
+
readonly isApiRequest?: (url: URL) => boolean;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Register the Kaafil offline-shell rules on a service worker you own.
|
|
111
|
+
*
|
|
112
|
+
* ```js title="src/sw.ts"
|
|
113
|
+
* import { installKaafilOfflineShell } from 'kaafil-react-uikit/offline';
|
|
114
|
+
*
|
|
115
|
+
* installKaafilOfflineShell({
|
|
116
|
+
* cacheName: 'sharma-shell-v4',
|
|
117
|
+
* appShellUrl: '/index.html',
|
|
118
|
+
* precache: self.__WB_MANIFEST.map((e) => e.url),
|
|
119
|
+
* });
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* Call it from your worker's top level. It adds `install`, `activate` and
|
|
123
|
+
* `fetch` listeners and returns nothing — you are free to add your own
|
|
124
|
+
* listeners alongside, including a `fetch` handler for routes this one
|
|
125
|
+
* declines (it calls `respondWith` only for requests it actually owns).
|
|
126
|
+
*/
|
|
127
|
+
declare function installKaafilOfflineShell(options: KaafilOfflineShellOptions): void;
|
|
128
|
+
|
|
129
|
+
export { type CachedCredentialOptions, type CredentialResolver, type CredentialStore, type KaafilOfflineShellOptions, type StoredCredential, installKaafilOfflineShell, localStorageCredentialStore, withCachedCredential };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/offline/credentialCache.ts
|
|
2
|
+
function isFetchNetworkError(error) {
|
|
3
|
+
return error instanceof TypeError;
|
|
4
|
+
}
|
|
5
|
+
function withCachedCredential(resolve, options) {
|
|
6
|
+
const { store } = options;
|
|
7
|
+
const isUnreachable = options.isUnreachable ?? isFetchNetworkError;
|
|
8
|
+
return async function resolveWithCache() {
|
|
9
|
+
try {
|
|
10
|
+
const fresh = await resolve();
|
|
11
|
+
await store.write(fresh);
|
|
12
|
+
return fresh;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (!isUnreachable(error)) throw error;
|
|
15
|
+
const cached = await store.read();
|
|
16
|
+
if (cached === null) throw error;
|
|
17
|
+
return cached;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function localStorageCredentialStore(scope) {
|
|
22
|
+
const key = `kaafil:credential:${scope}`;
|
|
23
|
+
return {
|
|
24
|
+
read() {
|
|
25
|
+
try {
|
|
26
|
+
const raw = localStorage.getItem(key);
|
|
27
|
+
if (raw === null) return null;
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
write(credential) {
|
|
34
|
+
try {
|
|
35
|
+
localStorage.setItem(key, JSON.stringify(credential));
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
clear() {
|
|
40
|
+
try {
|
|
41
|
+
localStorage.removeItem(key);
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/offline/serviceWorker.ts
|
|
49
|
+
function isKaafilApiHost(url) {
|
|
50
|
+
return url.hostname === "kaafil.in" || url.hostname.endsWith(".kaafil.in");
|
|
51
|
+
}
|
|
52
|
+
function installKaafilOfflineShell(options) {
|
|
53
|
+
const scope = self;
|
|
54
|
+
const { cacheName, appShellUrl, precache } = options;
|
|
55
|
+
const isApiRequest = options.isApiRequest ?? isKaafilApiHost;
|
|
56
|
+
scope.addEventListener("install", (event) => {
|
|
57
|
+
event.waitUntil(
|
|
58
|
+
(async () => {
|
|
59
|
+
const cache = await caches.open(cacheName);
|
|
60
|
+
await cache.addAll([...precache]);
|
|
61
|
+
await scope.skipWaiting();
|
|
62
|
+
})()
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
scope.addEventListener("activate", (event) => {
|
|
66
|
+
event.waitUntil(
|
|
67
|
+
(async () => {
|
|
68
|
+
const names = await caches.keys();
|
|
69
|
+
await Promise.all(
|
|
70
|
+
names.filter((name) => name !== cacheName).map((name) => caches.delete(name))
|
|
71
|
+
);
|
|
72
|
+
await scope.clients.claim();
|
|
73
|
+
})()
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
scope.addEventListener("fetch", (event) => {
|
|
77
|
+
const { request } = event;
|
|
78
|
+
if (request.method !== "GET") return;
|
|
79
|
+
const url = new URL(request.url);
|
|
80
|
+
if (isApiRequest(url)) return;
|
|
81
|
+
if (request.mode === "navigate") {
|
|
82
|
+
event.respondWith(
|
|
83
|
+
(async () => {
|
|
84
|
+
try {
|
|
85
|
+
return await fetch(request);
|
|
86
|
+
} catch {
|
|
87
|
+
const cache = await caches.open(cacheName);
|
|
88
|
+
const shell = await cache.match(appShellUrl);
|
|
89
|
+
if (shell !== void 0) return shell;
|
|
90
|
+
throw new Error(`kaafil offline shell: ${appShellUrl} is not in ${cacheName}`);
|
|
91
|
+
}
|
|
92
|
+
})()
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
event.respondWith(
|
|
97
|
+
(async () => {
|
|
98
|
+
const cache = await caches.open(cacheName);
|
|
99
|
+
const hit = await cache.match(request);
|
|
100
|
+
return hit ?? await fetch(request);
|
|
101
|
+
})()
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { installKaafilOfflineShell, localStorageCredentialStore, withCachedCredential };
|
|
107
|
+
//# sourceMappingURL=index.js.map
|
|
108
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/offline/credentialCache.ts","../../src/offline/serviceWorker.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// An OPT-IN cache around the host's own credential resolver.\n//\n// The kit never persists a credential — where a token lives, how long it is\n// kept and what clears it are host security decisions, not a design system's\n// (`SessionShell.tsx`'s own header states this, and it is why\n// `credentialResolver` is a one-shot supplier whose result is handed straight\n// to `client.*.open()` and never stored).\n//\n// That rule is about WHERE the token lives, not about who sequences the\n// fallback. Every integrator hits the same two subtleties on the way to\n// milestone 10, and both are easy to get quietly wrong:\n//\n// 1. Falling back on ANY failure. A 401 or a 500 from a reachable server is\n// a real failure and must still surface — silently serving a cached\n// credential past a revocation is the failure mode that matters.\n// 2. Treating the cache as an authority on freshness. It is not, and this\n// module deliberately cannot be: see `isUnreachable` below.\n//\n// So this helper owns the SEQUENCE and leaves the STORAGE to the host, which\n// keeps the security decision exactly where it was.\n\n/** The shape `KaafilUIKitProvider`'s `credentialResolver` resolves to —\n * structurally identical to `SessionShell.tsx`'s own union, restated here\n * rather than imported because nothing outside `src/core` may reach into the\n * SDK-facing shells (hard rule #1) and this module must stay SDK-free. */\nexport type StoredCredential =\n | { readonly accessToken: string; readonly refreshToken: string; readonly agencyRef: string }\n | { readonly shareToken: string };\n\nexport type CredentialResolver = () => Promise<StoredCredential>;\n\n/**\n * The host's own storage. Sync or async — a `localStorage` implementation and\n * an IndexedDB one both satisfy this.\n *\n * Nothing here is written to `window` (hard rule #22); a storage API is the\n * host's to choose, a global token stash is not.\n */\nexport interface CredentialStore {\n read(): Promise<StoredCredential | null> | StoredCredential | null;\n write(credential: StoredCredential): Promise<void> | void;\n clear(): Promise<void> | void;\n}\n\nexport interface CachedCredentialOptions {\n readonly store: CredentialStore;\n /**\n * Whether a thrown value means **the request never completed** — the only\n * case in which the cached credential may be used.\n *\n * The default recognises exactly one thing: the `TypeError` that `fetch`\n * throws when it cannot reach the network at all. That is deliberately\n * narrow. A host using axios, ky or their own wrapper throws something else\n * for the same condition and MUST supply this — a predicate that returns\n * `true` too eagerly turns a 401 into a silent session extension, which is\n * the exact bug this helper exists to prevent.\n */\n readonly isUnreachable?: (error: unknown) => boolean;\n}\n\n/** `fetch` rejects with a `TypeError` when the request never reached a server.\n * A response with a status — 401, 423, 500 — resolves, and is therefore not\n * this case. */\nfunction isFetchNetworkError(error: unknown): boolean {\n return error instanceof TypeError;\n}\n\n/**\n * Wrap a credential resolver so a reload with no network still opens the\n * surface, instead of stranding the outbox behind a session that cannot mint.\n *\n * ```ts\n * const resolver = withCachedCredential(\n * () => fetch('/api/admin-session', { method: 'POST' }).then((r) => r.json()),\n * { store: localStorageCredentialStore('sharma:desk') },\n * );\n * ```\n *\n * ── WHAT THIS DOES NOT DO, ON PURPOSE ───────────────────────────────────────\n *\n * It stores and restores. It NEVER judges freshness, and it has no opinion on\n * expiry. A cached access token still expires, and offline it cannot be\n * refreshed — a manager who has been offline longer than the token's life will\n * not get in, and that is a real limit to design around, not a bug here. The\n * kit surfaces the outcome through `onSessionExpired` on the provider, which\n * is where a host reacts.\n *\n * That is not only a design preference: \"now\" in this package is\n * `meta.serverTime`, never a local wall clock, and a device that has been\n * offline for hours has no trustworthy `serverTime` to compare against. A\n * helper that checked expiry here would have to read the device clock to do\n * it — the precise thing the architecture forbids, for the precise reason it\n * would be wrong.\n */\nexport function withCachedCredential(\n resolve: CredentialResolver,\n options: CachedCredentialOptions,\n): CredentialResolver {\n const { store } = options;\n const isUnreachable = options.isUnreachable ?? isFetchNetworkError;\n\n return async function resolveWithCache(): Promise<StoredCredential> {\n try {\n const fresh = await resolve();\n await store.write(fresh);\n return fresh;\n } catch (error: unknown) {\n // A server that ANSWERED — 401, 403, 500 — is a real failure and must\n // surface. Only a request that never completed may fall back.\n if (!isUnreachable(error)) throw error;\n\n const cached = await store.read();\n if (cached === null) throw error;\n\n return cached;\n }\n };\n}\n\n/**\n * A `CredentialStore` over `localStorage`, for hosts that have decided that is\n * the right place for their token. Deliberately NOT the default — the kit does\n * not choose where a credential lives.\n *\n * `scope` is an IDENTIFIER (a staff id, a persona name), never the\n * credential's own secret: it becomes a storage key, and a key is readable by\n * anything that can read the store.\n *\n * Every access is wrapped: a private window, cleared site data, or a browser\n * configured to block site storage makes these APIs throw on access rather\n * than return empty, and a credential cache that takes the app down when\n * storage is unavailable is worse than no cache at all.\n */\nexport function localStorageCredentialStore(scope: string): CredentialStore {\n const key = `kaafil:credential:${scope}`;\n\n return {\n read(): StoredCredential | null {\n try {\n const raw = localStorage.getItem(key);\n if (raw === null) return null;\n return JSON.parse(raw) as StoredCredential;\n } catch {\n return null;\n }\n },\n write(credential: StoredCredential): void {\n try {\n localStorage.setItem(key, JSON.stringify(credential));\n } catch {\n // Storage unavailable or full — the surface still opens online, and\n // the offline reload is what degrades. Never fail the mint for this.\n }\n },\n clear(): void {\n try {\n localStorage.removeItem(key);\n } catch {\n // Same reasoning as `write`.\n }\n },\n };\n}\n","// The Kaafil-specific half of a host's service worker.\n//\n// ── WHY THIS IS NOT A DROP-IN SERVICE WORKER ────────────────────────────────\n//\n// Kaafil makes your DATA survive a reload — the write goes to a durable outbox\n// and the read comes back from the snapshot store, both in IndexedDB, both\n// outliving a reload. It does nothing about your APPLICATION: the HTML\n// document, your JS, your CSS. Those come off the network like any other page,\n// so with no service worker a reload while offline gets the browser's offline\n// error page, and the queued writes sit intact behind a document that will not\n// open. The data was never lost; you just cannot reach it.\n//\n// Closing that gap is a service worker, and the CACHING half of it belongs to\n// the host: only your bundler knows your build's hashed filenames, your deploy\n// cadence and your revalidation strategy. Shipping an opinionated one here\n// would fight `vite-plugin-pwa`, `next-pwa` or whatever Workbox config you\n// already have.\n//\n// What this module ships instead is the part the kit genuinely knows: the\n// rules that are true of ANY Kaafil integration regardless of build tool. You\n// supply the precache manifest; this supplies the fetch policy.\n//\n// ── ON CACHING THE UIKIT'S OWN CSS AND JS ───────────────────────────────────\n//\n// There is nothing separate to do. This package is bundled INTO your build, so\n// its CSS and JS are already part of the output your precache manifest covers.\n// There is no Kaafil-specific asset step to add, and no Kaafil CDN URL to\n// allow — if your app shell is cached, so is the UIKit.\n\n/** The subset of the service-worker globals this module touches, declared\n * locally so the package does not have to add `lib: [\"WebWorker\"]` to its\n * tsconfig for one file. */\ninterface ServiceWorkerFetchEvent extends Event {\n readonly request: Request;\n respondWith(response: Promise<Response> | Response): void;\n}\n\ninterface ServiceWorkerLifecycleEvent extends Event {\n waitUntil(promise: Promise<unknown>): void;\n}\n\ninterface ServiceWorkerScope {\n addEventListener(type: 'install', listener: (event: ServiceWorkerLifecycleEvent) => void): void;\n addEventListener(type: 'activate', listener: (event: ServiceWorkerLifecycleEvent) => void): void;\n addEventListener(type: 'fetch', listener: (event: ServiceWorkerFetchEvent) => void): void;\n skipWaiting(): Promise<void>;\n readonly clients: { claim(): Promise<void> };\n}\n\nexport interface KaafilOfflineShellOptions {\n /**\n * The cache this worker owns. Change it to invalidate — `activate` deletes\n * every cache whose name differs, so your version string stays the only\n * revalidation knob and this module never invents a second one.\n */\n readonly cacheName: string;\n /**\n * The document served for a navigation that cannot reach the network. Must\n * be one of `precache`.\n */\n readonly appShellUrl: string;\n /**\n * Your build output, from your own bundler. `vite-plugin-pwa` and Workbox\n * both inject a manifest; map it to URLs and pass it here.\n */\n readonly precache: readonly string[];\n /**\n * Whether a URL is a Kaafil API call, which must NEVER be served from cache.\n * Defaults to any `kaafil.in` host. Override if you proxy the API through\n * your own origin — and if you do, this is the one option you cannot afford\n * to get wrong; see the note on `handleFetch`.\n */\n readonly isApiRequest?: (url: URL) => boolean;\n}\n\nfunction isKaafilApiHost(url: URL): boolean {\n return url.hostname === 'kaafil.in' || url.hostname.endsWith('.kaafil.in');\n}\n\n/**\n * Register the Kaafil offline-shell rules on a service worker you own.\n *\n * ```js title=\"src/sw.ts\"\n * import { installKaafilOfflineShell } from 'kaafil-react-uikit/offline';\n *\n * installKaafilOfflineShell({\n * cacheName: 'sharma-shell-v4',\n * appShellUrl: '/index.html',\n * precache: self.__WB_MANIFEST.map((e) => e.url),\n * });\n * ```\n *\n * Call it from your worker's top level. It adds `install`, `activate` and\n * `fetch` listeners and returns nothing — you are free to add your own\n * listeners alongside, including a `fetch` handler for routes this one\n * declines (it calls `respondWith` only for requests it actually owns).\n */\nexport function installKaafilOfflineShell(options: KaafilOfflineShellOptions): void {\n const scope = self as unknown as ServiceWorkerScope;\n const { cacheName, appShellUrl, precache } = options;\n const isApiRequest = options.isApiRequest ?? isKaafilApiHost;\n\n scope.addEventListener('install', (event) => {\n event.waitUntil(\n (async () => {\n const cache = await caches.open(cacheName);\n await cache.addAll([...precache]);\n await scope.skipWaiting();\n })(),\n );\n });\n\n scope.addEventListener('activate', (event) => {\n event.waitUntil(\n (async () => {\n const names = await caches.keys();\n await Promise.all(\n names.filter((name) => name !== cacheName).map((name) => caches.delete(name)),\n );\n await scope.clients.claim();\n })(),\n );\n });\n\n scope.addEventListener('fetch', (event) => {\n const { request } = event;\n\n // NEVER touch a write. The outbox owns retries and their idempotency (no\n // hook mints its own key, and none accepts one); a service worker that\n // replays a POST is a second retry engine racing the first, and the\n // visible symptom is a duplicate expense nobody can explain.\n if (request.method !== 'GET') return;\n\n const url = new URL(request.url);\n\n // NEVER cache a Kaafil API response. Two different failures, both real: a\n // cached trip read shows a manager stale data they will act on, and a\n // cached SHARE response keeps serving a traveller's itinerary after the\n // token was revoked — a disclosure, not a staleness bug. Reads that should\n // survive offline already do, from the SDK's own snapshot store.\n if (isApiRequest(url)) return;\n\n // A navigation that cannot reach the network falls back to the cached app\n // shell. This single rule is what turns \"reload while offline\" from the\n // browser's error page into a working surface with the outbox intact.\n if (request.mode === 'navigate') {\n event.respondWith(\n (async () => {\n try {\n return await fetch(request);\n } catch {\n const cache = await caches.open(cacheName);\n const shell = await cache.match(appShellUrl);\n if (shell !== undefined) return shell;\n throw new Error(`kaafil offline shell: ${appShellUrl} is not in ${cacheName}`);\n }\n })(),\n );\n return;\n }\n\n // Everything else that was precached is served cache-first. Anything not\n // precached is left alone for the host's own handlers, or the network.\n event.respondWith(\n (async () => {\n const cache = await caches.open(cacheName);\n const hit = await cache.match(request);\n return hit ?? (await fetch(request));\n })(),\n );\n });\n}\n"]}
|