@runfusion/fusion 0.74.0-beta.2 → 0.74.0-beta.4
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/bin.js +5416 -3166
- package/dist/child-process-worker.js +3540 -1656
- package/dist/client/.vite/manifest.json +272 -272
- package/dist/client/assets/AgentDetailView-BGXTnuBR.js +13 -0
- package/dist/client/assets/AgentDetailView-CH11Ydji.css +1 -0
- package/dist/client/assets/{AgentPermissionPolicyEditor-BycC7HhP.js → AgentPermissionPolicyEditor-DswM202p.js} +1 -1
- package/dist/client/assets/AgentsView-CEpZDrmC.js +24 -0
- package/dist/client/assets/{ChatView-DAsZY1We.js → ChatView-BXXX0q6G.js} +3 -3
- package/dist/client/assets/CommandCenter-CqjN1-9X.js +97 -0
- package/dist/client/assets/DevServerView-BUO7JRi6.js +1 -0
- package/dist/client/assets/{DirectoryPicker-DsCk3Nsw.js → DirectoryPicker-CiP4h5C7.js} +1 -1
- package/dist/client/assets/{DocumentsView-8EI6KNBY.js → DocumentsView-DwLD0h2S.js} +1 -1
- package/dist/client/assets/EvalsView-C9juKffD.js +1 -0
- package/dist/client/assets/{ExperimentalAgentOnboardingModal-ce6DROKD.js → ExperimentalAgentOnboardingModal-DYvw9TEh.js} +1 -1
- package/dist/client/assets/{GoalsView-BVlTtw-u.js → GoalsView-BD1AkfVN.js} +1 -1
- package/dist/client/assets/{InsightsView-BKQeROkb.js → InsightsView-Q0mu759R.js} +1 -1
- package/dist/client/assets/{MemoryView-DCzTnxsV.js → MemoryView-DCTqPpCG.js} +1 -1
- package/dist/client/assets/{PiExtensionsManager-C94HwDz5.js → PiExtensionsManager-CSe_uGfa.js} +1 -1
- package/dist/client/assets/{PluginManager-C3DegklW.js → PluginManager-DPm8YQC0.js} +1 -1
- package/dist/client/assets/{PullRequestView-CrE89UL7.js → PullRequestView-BpJgNDLH.js} +2 -2
- package/dist/client/assets/{ReportModal-BD1ZUDdV.js → ReportModal-iGmTZqiF.js} +1 -1
- package/dist/client/assets/ResearchView-BfgKB-6z.js +2 -0
- package/dist/client/assets/{SecretsView-6qK8Idb7.js → SecretsView-CqvQLK7z.js} +1 -1
- package/dist/client/assets/SessionTerminal-A9di8GAo.js +2 -0
- package/dist/client/assets/SettingsModal-BsCUsAXe.js +21 -0
- package/dist/client/assets/{SettingsModal-BkOSlfyr.js → SettingsModal-D-iyPGZE.js} +1 -1
- package/dist/client/assets/SettingsModal-DmfMotcd.css +1 -0
- package/dist/client/assets/{SettingsTextareaRow-Btyx5kaR.js → SettingsTextareaRow-DwgHxMMY.js} +1 -1
- package/dist/client/assets/{SetupWizardModal-Blhx-dRt.js → SetupWizardModal-CrSnLN44.js} +2 -2
- package/dist/client/assets/{SkillsView-DGE5kYc3.js → SkillsView-C_STy7KI.js} +1 -1
- package/dist/client/assets/{TodoView-YSGI7ATd.js → TodoView-BJJNGNLq.js} +1 -1
- package/dist/client/assets/{WorkflowNodeEditor-CZiEThu2.js → WorkflowNodeEditor-Jp5i_Vxu.js} +3 -3
- package/dist/client/assets/{WorkflowNodeEditor-nCx4nqoL.css → WorkflowNodeEditor-sZ96V3Kd.css} +1 -1
- package/dist/client/assets/{agent-import-generation-DQeNZMAm.js → agent-import-generation-DbVrl8ou.js} +1 -1
- package/dist/client/assets/{app-Dn1u7zJ9.js → app-BYs5Cx-j.js} +2 -2
- package/dist/client/assets/{app-DaikOPcV.js → app-CTV8PCYh.js} +3 -3
- package/dist/client/assets/{app-DJIiVHdA.js → app-CwdZxrJI.js} +2 -2
- package/dist/client/assets/{app-hQw_x2KJ.js → app-DE-MPJxA.js} +2 -2
- package/dist/client/assets/{app-DxTbBEMH.js → app-e8c4wb5t.js} +2 -2
- package/dist/client/assets/{app-DMZ_RusZ.js → app-yKugq_Ld.js} +2 -2
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-DhaJNKa4.js → architectureDiagram-3BPJPVTR-Ctz8GX1N.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-pKpg7gJU.js → blockDiagram-GPEHLZMM-WI_YPphw.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-DVPXKJnr.js → c4Diagram-AAUBKEIU-BLwS5Zfh.js} +1 -1
- package/dist/client/assets/channel-CMMrQrQu.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-CHt90T5z.js → chunk-2J33WTMH-DeXlX12-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-DsWFYy0a.js → chunk-4BX2VUAB-zvCEraJR.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-D8XiXEo8.js → chunk-55IACEB6-BgZq96DR.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-DlUYBn_Y.js → chunk-727SXJPM-CayoVGXL.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-BSdPOr9q.js → chunk-AQP2D5EJ-BmaktpVP.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-B9IAeBld.js → chunk-FMBD7UC4-qYWgZPF8.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-CmxnYQrR.js → chunk-ND2GUHAM-DIovauCc.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-Dxp6Po_j.js → chunk-QZHKN3VN-DXWnr9LE.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CqxyehbN.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CqxyehbN.js +1 -0
- package/dist/client/assets/common-13IRj_PO.js +1 -0
- package/dist/client/assets/common-BBynATth.js +1 -0
- package/dist/client/assets/common-BG-vw0tL.js +1 -0
- package/dist/client/assets/common-DSo46rnH.js +1 -0
- package/dist/client/assets/common-DbOCE-0i.js +1 -0
- package/dist/client/assets/common-DnXF4Jmj.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BNl3bhpS.js → cose-bilkent-S5V4N54A-Dee9mTjK.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-DFs7WCcz.js → dagre-BM42HDAG-CU-v2zoi.js} +1 -1
- package/dist/client/assets/{dashboard-view-DnoTcnIo.js → dashboard-view-BM790i02.js} +1 -1
- package/dist/client/assets/{dashboard-view-ZGxLEL9r.js → dashboard-view-CG4rzThC.js} +1 -1
- package/dist/client/assets/{dashboard-view-vc8kgLMs.js → dashboard-view-COary9sK.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-C2PVozvu.js → diagram-2AECGRRQ-DIqdnJ5B.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-DFJ5b9H8.js → diagram-5GNKFQAL-ChpdUlpb.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-D335CW-c.js → diagram-KO2AKTUF-BxmzOp-c.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DSfhroAW.js → diagram-LMA3HP47-DLEx-0rl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-8iGveM-Z.js → diagram-OG6HWLK6-DEpkJ3cG.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-Xc56MEEi.js → erDiagram-TEJ5UH35-C3Rjr78a.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-DjCU9WbD.js → flowDiagram-I6XJVG4X-aPbBbeiH.js} +1 -1
- package/dist/client/assets/{folder-open-BRrB44UX.js → folder-open-BnNbfyzc.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CXQE1bea.js → ganttDiagram-6RSMTGT7-BEzAwvJ4.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII--mrSABLC.js → gitGraphDiagram-PVQCEYII-Y4hB2swQ.js} +1 -1
- package/dist/client/assets/index-8wtfjIkg.js +2675 -0
- package/dist/client/assets/index-Bp3UBLOz.css +1 -0
- package/dist/client/assets/{infoDiagram-5YYISTIA-CTS_nljP.js → infoDiagram-5YYISTIA-pd3J7fRT.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CpV5x3gk.js → ishikawaDiagram-YF4QCWOH-B968vfOH.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-BK2QCMnt.js → journeyDiagram-JHISSGLW-V4crhYKX.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-B4srpspY.js → kanban-definition-UN3LZRKU-Cl8LxR6k.js} +1 -1
- package/dist/client/assets/{mermaid.core-B8DVAt21.js → mermaid.core-rb_bKdHH.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-CsmmiPpA.js → mindmap-definition-RKZ34NQL-Cnu-LPHz.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-BahLDqf4.js → pieDiagram-4H26LBE5-C6hPYepr.js} +1 -1
- package/dist/client/assets/{puzzle-BFPQsuIa.js → puzzle-C8AvcmuM.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Bo3u0wTU.js → quadrantDiagram-W4KKPZXB-CUImgAi-.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-DYkYsz7d.js → requirementDiagram-4Y6WPE33-DzDLxIYz.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-BCbh0BSY.js → sankeyDiagram-5OEKKPKP-DSr_oIy8.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-C2AP9GlE.js → sequenceDiagram-3UESZ5HK-DO2E98Hv.js} +1 -1
- package/dist/client/assets/{shield-alert-tlZOi02c.js → shield-alert-Byjz1VmT.js} +1 -1
- package/dist/client/assets/{standing-instructions-template-C0Rzv9Mr.js → standing-instructions-template-Cf9fZ8bv.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-CARkH0ug.js → stateDiagram-AJRCARHV-BYPlDS4u.js} +1 -1
- package/dist/client/assets/{stateDiagram-v2-BHNVJYJU-C2He043y.js → stateDiagram-v2-BHNVJYJU-Ce_6vh1Q.js} +1 -1
- package/dist/client/assets/{timeline-definition-PNZ67QCA-MvbYneEM.js → timeline-definition-PNZ67QCA-CG1FeaKV.js} +1 -1
- package/dist/client/assets/{upload-DWK0F17Z.js → upload-yPsE_DRN.js} +1 -1
- package/dist/client/assets/{users-1AkDfM14.js → users-C_YmWx9d.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-DOjzzLqc.js → vennDiagram-CIIHVFJN-JLUXqFyb.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-Di70vhu2.js → wardley-L42UT6IY-D_UBzrZ2.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-nK6ARhDv.js → wardleyDiagram-YWT4CUSO-BzwMrdpL.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-C7_y76B7.js → xychartDiagram-2RQKCTM6-C5TjbAcZ.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +633 -13
- package/dist/client/version.json +1 -1
- package/dist/extension.js +3999 -2008
- package/package.json +1 -1
- package/skill/fusion/references/engine-tools.md +1 -1
- package/dist/client/assets/AgentDetailView-CoeSgMXS.css +0 -1
- package/dist/client/assets/AgentDetailView-DH4Cn5iG.js +0 -13
- package/dist/client/assets/AgentsView-Dkij8Sm8.js +0 -24
- package/dist/client/assets/CommandCenter-jTzBo-pg.js +0 -97
- package/dist/client/assets/DevServerView-CxlacYJp.js +0 -1
- package/dist/client/assets/EvalsView-kYnaIk11.js +0 -1
- package/dist/client/assets/ResearchView-eMiRw1EM.js +0 -2
- package/dist/client/assets/SessionTerminal-DtFLyUaK.js +0 -2
- package/dist/client/assets/SettingsModal-BTfZlghk.css +0 -1
- package/dist/client/assets/SettingsModal-DSq-_qwo.js +0 -21
- package/dist/client/assets/channel-xFBfK0sX.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-BFBVD968.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-BFBVD968.js +0 -1
- package/dist/client/assets/common-BDp2y6CF.js +0 -1
- package/dist/client/assets/common-BnEo5fLN.js +0 -1
- package/dist/client/assets/common-Bzr_Y8h4.js +0 -1
- package/dist/client/assets/common-CvwyFzfv.js +0 -1
- package/dist/client/assets/common-CztCXRkz.js +0 -1
- package/dist/client/assets/common-DPe2mbb7.js +0 -1
- package/dist/client/assets/index-C2xsZoNG.js +0 -2671
- package/dist/client/assets/index-Dv-95s50.css +0 -1
package/dist/client/sw.js
CHANGED
|
@@ -1,4 +1,489 @@
|
|
|
1
|
-
|
|
1
|
+
/*
|
|
2
|
+
FNXC:PWAOffline 2026-07-26-18:05:
|
|
3
|
+
Bumped v6 -> v7 to EVACUATE already-persisted credentials. Until this change every successful GET /api/*
|
|
4
|
+
response was written to Cache Storage, including GET /api/settings and /api/settings/global, whose bodies
|
|
5
|
+
carry plaintext `daemonToken`, `githubAuthToken`, `gitlabAuthToken`, and `ntfyAccessToken`. Adding the
|
|
6
|
+
allow-list below stops NEW writes but cannot reach entries an installed worker already wrote; `activate`
|
|
7
|
+
deletes every cache whose key !== CACHE_NAME, so the rename is the only mechanism that removes them from
|
|
8
|
+
durable origin storage on an existing install. Any future change to what may be cached must bump this too.
|
|
9
|
+
*/
|
|
10
|
+
const CACHE_NAME = "fusion-cache-v7";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
FNXC:PWAOffline 2026-07-26-10:12:
|
|
14
|
+
Mobile browsers (iOS Safari tabs, iOS installed PWAs, Chrome Android) discard backgrounded pages under memory pressure. When the user returns, the tab is re-navigated from scratch, so restore cost IS the perceived bug: a network-first bundle refetch pulls ~2.8MB raw / 750KB gzipped of entry chunk (14MB across ~130 chunks) over a just-waking radio before anything paints, producing the white splash.
|
|
15
|
+
|
|
16
|
+
Vite emits content-hashed asset filenames (`[name]-[hash].[ext]`), so a hashed URL's bytes can never change — a new build emits a NEW url. Those are therefore safe to serve cache-first: a cache hit is authoritative, and a post-deploy hash simply misses and is fetched. HASHED_ASSET_PATTERN is the conservative gate for that guarantee; anything under /assets/ that does not prove it carries a hash stays network-first.
|
|
17
|
+
|
|
18
|
+
Conservatism rules for the pattern: the trailing dash-delimited segment of the basename must be >=8 chars of Vite's base64url hash alphabet (dash permitted only as the final character, which keeps the segment genuinely trailing instead of letting the match span earlier dashes) AND must contain at least one uppercase letter or digit. Human-authored kebab-case basenames (`vendor-runtime.js`, `foo-bar-baz.css`) fail the mixed-alphabet check and stay network-first. A real hash fails the pattern only when it embeds a dash before its last character (~10% of hashes) or is all-lowercase-alpha ((28/64)^8, ~0.1%); both are harmless downgrades to the previous network-first behavior, never a stale-serve.
|
|
19
|
+
*/
|
|
20
|
+
const HASHED_ASSET_PATTERN = /-(?=[A-Za-z0-9_-]*[A-Z0-9])[A-Za-z0-9_]{7,}[A-Za-z0-9_-]?\.[A-Za-z0-9]+$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* FNXC:PWAOffline 2026-07-26-10:18:
|
|
24
|
+
* Cache-first eligibility. Two admissible classes:
|
|
25
|
+
* 1. Content-hashed build assets under /assets/ — immutable by construction (see HASHED_ASSET_PATTERN).
|
|
26
|
+
* 2. Font requests (`request.destination === "font"`, e.g. the preloaded /fonts/SymbolsNerdFontMono-Regular.ttf) — not hash-named, but immutable in practice; a replaced font is picked up on the next CACHE_NAME bump. Blocking first paint on a font refetch over a waking radio is not worth that staleness window.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately NOT admissible to the CACHE-FIRST path: the navigation shell, /api/*, and non-hashed
|
|
29
|
+
* scripts/styles. (Corrected 2026-07-26-18:05: the previous wording — "Deliberately NOT admissible" —
|
|
30
|
+
* read as a blanket exclusion from the cache. It never was one: those classes are merely network-FIRST
|
|
31
|
+
* and were still written to the same cache as an offline fallback. That misreading is how credential-
|
|
32
|
+
* bearing /api/ responses sat in durable storage unnoticed. What may be WRITTEN for /api/ is decided
|
|
33
|
+
* solely by isCacheableApiUrl() below, not by this predicate.)
|
|
34
|
+
*
|
|
35
|
+
* @param {URL} url
|
|
36
|
+
* @param {Request} request
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isImmutableAssetRequest(url, request) {
|
|
40
|
+
if (request.destination === "font") {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return url.pathname.startsWith("/assets/") && HASHED_ASSET_PATTERN.test(url.pathname);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
FNXC:PWAOffline 2026-07-26-14:05:
|
|
48
|
+
Cache-first hashed assets made cache SIZE load-bearing, and nothing evicted within a generation — `activate` only drops caches whose key !== CACHE_NAME, and CACHE_NAME is a hand-bumped literal. Fusion is self-hosted and rebuilt constantly (Command Center "Rebuild + restart" reloads the page onto a new build), each build emitting ~130 fresh hashed chunks (~14MB). Every prior build's chunks stayed resident forever, so a week of daily rebuilds parks ~100MB of dead chunks on the origin. On iOS the origin quota is enforced per-origin and eviction is ALL-OR-NOTHING for the bucket: blowing it wipes localStorage too, taking out the SWR board snapshot and the `kb-dashboard-*` preferences that the rest of the restore work depends on. So the unbounded cache does not merely waste disk — it can destroy the very state that makes restore cheap.
|
|
49
|
+
|
|
50
|
+
Chosen bound: an insertion-ordered entry cap over hashed /assets/ entries only.
|
|
51
|
+
- Why not "keep only what the current index.html references": index.html names only the entry chunk; the other ~129 are lazy imports discovered inside JS. Reconstructing that graph in the SW means parsing bundles — fragile, and a mis-parse strands the user on a failed dynamic import.
|
|
52
|
+
- Why not a CACHE_NAME-per-build bump: correct in principle, but CACHE_NAME is a literal in a static, untemplated file. Doing it properly needs build-time templating of sw.js (a Vite plugin emitting the build hash) — real build wiring, not a one-line change, and out of scope here. Recorded as the eventual better answer rather than faked with a hardcoded hash.
|
|
53
|
+
- Why insertion order is the right recency proxy: hashed URLs are immutable, so an entry is only ever inserted once, at the moment its build first ran. Cache API `keys()` is specified to return entries in insertion order, so oldest-first == oldest-build-first. Evicting from the front removes dead builds before live ones, with no metadata bookkeeping to persist or corrupt.
|
|
54
|
+
|
|
55
|
+
Partial safety against evicting an asset the RUNNING build still needs: every hashed URL this service-worker SESSION has served (hit or miss) is recorded in `sessionReferencedAssets` and is exempt from eviction while it remains in that set (itself bounded — see MAX_SESSION_REFERENCED_ASSETS).
|
|
56
|
+
|
|
57
|
+
CORRECTION 2026-07-26-18:05 — the previous wording ("A chunk the current page has already loaded is thereby pinned") asserted a guarantee this code does not deliver, and the claim must not be reintroduced. `sessionReferencedAssets` is an in-memory Set in the worker's global scope, and browsers idle-terminate a service worker after ~30s of no events — the normal state of a BACKGROUNDED tab, i.e. exactly the scenario this feature exists for. The worker that wakes to serve the restored tab is a cold start with an EMPTY set, so on the first prune after every wake NOTHING is pinned.
|
|
58
|
+
|
|
59
|
+
What actually bounds the residual risk, and why it was left as-is rather than papered over:
|
|
60
|
+
- Eviction is oldest-INSERTED-first, and the running build's chunks are the most recently inserted, so the natural ordering already protects them in the common case. The pin set only adds protection in the uncommon inversion where a live build's chunks are older than some other build's — e.g. a long-lived tab on build N while another tab loaded build N+1.
|
|
61
|
+
- Durably persisting the set would re-pin a PREVIOUS build's chunks on every restart with no way to tell which build is now live, converting a bounded eviction risk into an unbounded pinning one. Not worth it for the inversion case.
|
|
62
|
+
- The failure mode is a re-fetch, not a break: an evicted chunk still on the server is fetched again. The only unrecoverable case (chunk gone from the server) is the stale-chunk case `versionCheck.ts`'s handleChunkLoadError/isStaleChunkError already handles, and it would occur with no cache at all.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_IMMUTABLE_CACHE_ENTRIES = 200;
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
FNXC:PWAOffline 2026-07-26-14:05:
|
|
68
|
+
The eviction-exemption set must itself be bounded, or it re-opens the hole it exists to make safe: a
|
|
69
|
+
service worker that survives many rebuilds (they are cheap to keep alive under active use) would
|
|
70
|
+
accumulate every build's served chunks as permanently-exempt and the cap could never bite. Capping it
|
|
71
|
+
drop-oldest makes the resident set provably bounded at MAX_IMMUTABLE_CACHE_ENTRIES +
|
|
72
|
+
MAX_SESSION_REFERENCED_ASSETS in the worst case rather than unbounded. Dropping the oldest exemption
|
|
73
|
+
is safe for the same reason eviction is: a chunk evicted but still needed is re-fetched from the
|
|
74
|
+
network, and the only unrecoverable case (build gone from the server) is the stale-chunk case
|
|
75
|
+
versionCheck.ts handles and would occur with no cache at all.
|
|
76
|
+
*/
|
|
77
|
+
const MAX_SESSION_REFERENCED_ASSETS = 200;
|
|
78
|
+
|
|
79
|
+
/** @type {Set<string>} URLs of hashed assets served during this SW session; exempt from eviction. Set iteration is insertion-ordered, so the first entry is the oldest. */
|
|
80
|
+
const sessionReferencedAssets = new Set();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} requestUrl
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
function rememberSessionReferencedAsset(requestUrl) {
|
|
87
|
+
sessionReferencedAssets.delete(requestUrl);
|
|
88
|
+
sessionReferencedAssets.add(requestUrl);
|
|
89
|
+
while (sessionReferencedAssets.size > MAX_SESSION_REFERENCED_ASSETS) {
|
|
90
|
+
const oldest = sessionReferencedAssets.values().next();
|
|
91
|
+
if (oldest.done) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
sessionReferencedAssets.delete(oldest.value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
FNXC:PWAOffline 2026-07-26-15:40:
|
|
100
|
+
The hashed-asset cap above left the OTHER unbounded writer in this file untouched: every GET /api/ response is put into the same CACHE_NAME and nothing ever evicted those entries. The dashboard issues a large and open-ended set of distinct /api/ URLs (per project, per task, per query string), so the API half grew without limit — the same iOS all-or-nothing per-origin quota hazard the asset cap exists to prevent, where blowing the bucket wipes localStorage (SWR board snapshot, `kb-dashboard-*` prefs) alongside the caches.
|
|
101
|
+
|
|
102
|
+
Size is only half the defect. This cache is the OFFLINE FALLBACK: on a network failure a cached entry is served transparently to the app, which cannot tell it apart from a live response. With no expiry, an hours-old task list could be handed to the board during a brief radio blip — a correctness bug worse than the failed fetch it papers over. So the API entries get BOTH a count cap and a freshness bound.
|
|
103
|
+
|
|
104
|
+
Why not "stop caching /api/ entirely": the fallback is cheap and does earn a narrow keep. Warm hydration is NOT what depends on it — the app hydrates from its own localStorage/session snapshots — but a mobile tab restored onto a waking radio issues its first API burst before the connection settles, and a seconds-to-minutes-old response there is strictly better than an error state. That value evaporates fast, hence a short TTL rather than an unbounded fallback.
|
|
105
|
+
|
|
106
|
+
Freshness accounting, in precedence order:
|
|
107
|
+
1. `apiCacheTimestamps` — recorded at put time, same clock as the read, exact.
|
|
108
|
+
2. the response's `Date` header — Fusion serves its own API from Node, which always sets it, so a service worker that restarted (the common case after a discard) can still prove age.
|
|
109
|
+
3. otherwise UNKNOWN, which counts as stale. Fail-closed: an entry whose age cannot be proven is never served. The cost is one failed request that would have failed anyway without a cache; the alternative is exactly the unbounded-staleness bug being fixed.
|
|
110
|
+
A negative age (client clock behind the server's) is also treated as unprovable rather than clamped to fresh, so clock skew cannot mint an immortal entry.
|
|
111
|
+
*/
|
|
112
|
+
const MAX_API_CACHE_ENTRIES = 100;
|
|
113
|
+
|
|
114
|
+
/** Freshness bound for the /api/ offline fallback. Long enough to cover a radio blip, short enough that no stale board can surface. */
|
|
115
|
+
const MAX_API_CACHE_AGE_MS = 5 * 60 * 1000;
|
|
116
|
+
|
|
117
|
+
/*
|
|
118
|
+
FNXC:PWAOffline 2026-07-26-18:05:
|
|
119
|
+
WHICH /api/ responses may be written at all. Previously: every successful GET /api/*. That included
|
|
120
|
+
GET /api/settings and /api/settings/global, which return `daemonToken`, `githubAuthToken`,
|
|
121
|
+
`gitlabAuthToken`, and `ntfyAccessToken` as plaintext — so dashboard credentials were persisted to
|
|
122
|
+
durable Cache Storage, survived logout, token rotation, and project switch (nothing in the app touched
|
|
123
|
+
the caches API until the purge added in swrCache.ts), and were readable by any script on the origin.
|
|
124
|
+
The entry cap and TTL added earlier bound size and staleness; neither is a confidentiality control.
|
|
125
|
+
|
|
126
|
+
ALLOW-list, not deny-list. A deny-list fails OPEN: every endpoint added later is cached until someone
|
|
127
|
+
remembers to exclude it, which is exactly how the credential endpoints got here. The allow-list fails
|
|
128
|
+
closed — a new endpoint is network-only until someone argues it into this list.
|
|
129
|
+
|
|
130
|
+
What earns a place: only reads whose offline fallback has real value on the mobile-restore path this
|
|
131
|
+
whole feature targets (a restored tab issues its first API burst before the radio settles, where a
|
|
132
|
+
seconds-old body beats an error state) AND whose bodies carry no credential, secret, or token.
|
|
133
|
+
- `/api/tasks` and `/api/tasks/<id>`: the board and the card the user was looking at.
|
|
134
|
+
- `/api/projects`: the project list the board is keyed by.
|
|
135
|
+
Everything else — settings, secrets, auth, agents, git, chat, artifacts, attachments — goes straight to
|
|
136
|
+
the network with no cache write and no fallback read. The cost is an error state on a blip for those
|
|
137
|
+
views; the alternative is deciding, endpoint by endpoint forever, whether a body is sensitive.
|
|
138
|
+
*/
|
|
139
|
+
const API_CACHE_ALLOWLIST = [
|
|
140
|
+
/^\/api\/tasks$/,
|
|
141
|
+
/^\/api\/tasks\/[^/]+$/,
|
|
142
|
+
/^\/api\/projects$/,
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/*
|
|
146
|
+
FNXC:PWAOffline 2026-07-26-18:05:
|
|
147
|
+
Defense in depth over the allow-list, not a substitute for it. `appendTokenQuery` (auth.ts) puts the
|
|
148
|
+
dashboard bearer token in the URL as `fn_token=<token>` for transports that cannot set headers
|
|
149
|
+
(TaskDetailModal attachments, artifactMediaUrl, file downloads). A cache key IS the URL, so caching such
|
|
150
|
+
a request would persist the token itself as a durable key. None of those paths are allow-listed today;
|
|
151
|
+
this guard makes that a property of the code rather than of the current allow-list's shape.
|
|
152
|
+
*/
|
|
153
|
+
const CREDENTIAL_QUERY_PARAMS = ["fn_token", "token", "ticket", "access_token"];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* FNXC:PWAOffline 2026-07-26-18:05:
|
|
157
|
+
* @param {string} requestUrl
|
|
158
|
+
* @returns {boolean} true when this URL may be written to / read from the offline fallback cache.
|
|
159
|
+
*/
|
|
160
|
+
function isCacheableApiUrl(requestUrl) {
|
|
161
|
+
try {
|
|
162
|
+
const url = new URL(requestUrl);
|
|
163
|
+
for (const param of CREDENTIAL_QUERY_PARAMS) {
|
|
164
|
+
if (url.searchParams.has(param)) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return API_CACHE_ALLOWLIST.some((pattern) => pattern.test(url.pathname));
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/*
|
|
175
|
+
FNXC:PWAOffline 2026-07-26-18:05:
|
|
176
|
+
Durable put-time stamp. `apiCacheTimestamps` is a plain Map in the service worker's global scope, and
|
|
177
|
+
browsers idle-terminate a service worker after ~30s — which is precisely what happens while the tab is
|
|
178
|
+
backgrounded, the scenario this feature exists for. So on the next wake the SW cold-starts with that Map
|
|
179
|
+
EMPTY and every surviving entry's age had to be proven from the server `Date` header alone. That header
|
|
180
|
+
is a real fallback but it is the ORIGIN's send time, not our put time, and an intermediary or a response
|
|
181
|
+
replayed from an HTTP cache can make it arbitrarily older than the entry. Stamping our own header at put
|
|
182
|
+
time makes age provable across a worker restart with the same clock the read uses.
|
|
183
|
+
|
|
184
|
+
Fails soft by design: if `Response`/`Headers` are unavailable, the status cannot carry a body (204/304),
|
|
185
|
+
or the body read throws, the caller falls back to putting the plain clone and age falls back to `Date`.
|
|
186
|
+
A failure here must never cost the response itself.
|
|
187
|
+
*/
|
|
188
|
+
const SW_CACHED_AT_HEADER = "x-fusion-sw-cached-at";
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {Response} response
|
|
192
|
+
* @returns {Promise<Response|undefined>} a stamped copy, or undefined when stamping is not possible.
|
|
193
|
+
*/
|
|
194
|
+
async function buildStampedResponse(response) {
|
|
195
|
+
if (typeof Response !== "function" || typeof Headers !== "function") {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
if (!response || !response.headers || typeof response.clone !== "function") {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
// Response's constructor rejects a body for these statuses; leave them to the plain-clone path.
|
|
202
|
+
if (response.status === 204 || response.status === 205 || response.status === 304 || !response.status) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const headers = new Headers(response.headers);
|
|
206
|
+
headers.set(SW_CACHED_AT_HEADER, String(Date.now()));
|
|
207
|
+
const body = await response.clone().arrayBuffer();
|
|
208
|
+
return new Response(body, {
|
|
209
|
+
status: response.status,
|
|
210
|
+
statusText: response.statusText,
|
|
211
|
+
headers,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/*
|
|
216
|
+
FNXC:PWAOffline 2026-07-26-15:40:
|
|
217
|
+
Unlike hashed assets (written only on the cold path, when a new build arrives), EVERY api response is a
|
|
218
|
+
cache put, so scheduling a full keys() scan per put would put an O(cache) walk behind ordinary polling.
|
|
219
|
+
Pruning every Nth put bounds the API entry count at limit + N instead of limit exactly, which is fine:
|
|
220
|
+
the cap is a quota guard, not a contract. Expiry is NOT lazy in the way that matters — it is enforced on
|
|
221
|
+
every read of the fallback path; the prune-side expiry sweep only reclaims disk.
|
|
222
|
+
*/
|
|
223
|
+
const API_PRUNE_PUT_INTERVAL = 10;
|
|
224
|
+
|
|
225
|
+
/** @type {Map<string, number>} api URL -> epoch ms of its cache put. Insertion-ordered, so the first entry is the oldest. */
|
|
226
|
+
const apiCacheTimestamps = new Map();
|
|
227
|
+
|
|
228
|
+
let apiPutsSincePrune = 0;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {string} requestUrl
|
|
232
|
+
* @returns {void}
|
|
233
|
+
*/
|
|
234
|
+
function rememberApiCacheTimestamp(requestUrl) {
|
|
235
|
+
apiCacheTimestamps.delete(requestUrl);
|
|
236
|
+
apiCacheTimestamps.set(requestUrl, Date.now());
|
|
237
|
+
while (apiCacheTimestamps.size > MAX_API_CACHE_ENTRIES) {
|
|
238
|
+
const oldest = apiCacheTimestamps.keys().next();
|
|
239
|
+
if (oldest.done) {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
apiCacheTimestamps.delete(oldest.value);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {string} requestUrl
|
|
248
|
+
* @param {Response|undefined} cachedResponse
|
|
249
|
+
* @returns {number|undefined} epoch ms the entry was cached, or undefined when unprovable.
|
|
250
|
+
*/
|
|
251
|
+
function readApiCachedAtMillis(requestUrl, cachedResponse) {
|
|
252
|
+
const remembered = apiCacheTimestamps.get(requestUrl);
|
|
253
|
+
if (typeof remembered === "number") {
|
|
254
|
+
return remembered;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const headers = cachedResponse && cachedResponse.headers ? cachedResponse.headers : null;
|
|
258
|
+
// FNXC:PWAOffline 2026-07-26-18:05: our own put-time stamp outranks `Date` — it survives the
|
|
259
|
+
// SW idle-termination that empties apiCacheTimestamps and is on the same clock as this read.
|
|
260
|
+
const stamped = headers ? headers.get(SW_CACHED_AT_HEADER) : null;
|
|
261
|
+
if (stamped) {
|
|
262
|
+
const parsedStamp = Number(stamped);
|
|
263
|
+
if (Number.isFinite(parsedStamp)) {
|
|
264
|
+
return parsedStamp;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const dateHeader = headers ? headers.get("date") : null;
|
|
268
|
+
if (dateHeader) {
|
|
269
|
+
const parsed = Date.parse(dateHeader);
|
|
270
|
+
if (Number.isFinite(parsed)) {
|
|
271
|
+
return parsed;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch (headerError) {
|
|
275
|
+
console.warn("[sw] api cache date header read failed", headerError);
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {number|undefined} cachedAtMillis
|
|
282
|
+
* @returns {boolean} true only when the entry's age is provable AND within the freshness bound.
|
|
283
|
+
*/
|
|
284
|
+
function isFreshApiCacheEntry(cachedAtMillis) {
|
|
285
|
+
if (typeof cachedAtMillis !== "number" || !Number.isFinite(cachedAtMillis)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const age = Date.now() - cachedAtMillis;
|
|
289
|
+
return age >= 0 && age <= MAX_API_CACHE_AGE_MS;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} requestUrl
|
|
294
|
+
* @returns {boolean}
|
|
295
|
+
*/
|
|
296
|
+
function isApiCacheUrl(requestUrl) {
|
|
297
|
+
try {
|
|
298
|
+
const pathname = new URL(requestUrl).pathname;
|
|
299
|
+
// /api/events* never reaches the cache (SSE bypasses the SW), so it is not a prunable class.
|
|
300
|
+
return pathname.startsWith("/api/") && pathname !== "/api/events" && !pathname.startsWith("/api/events/");
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Prune runs are single-flight; overlapping cold-path misses must not scan/delete concurrently. */
|
|
307
|
+
let immutablePruneInFlight = false;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* FNXC:PWAOffline 2026-07-26-14:05:
|
|
311
|
+
* URL-only classifier for prunable entries. Deliberately does NOT reuse isImmutableAssetRequest():
|
|
312
|
+
* that one consults `request.destination`, which is not guaranteed to survive a round trip through
|
|
313
|
+
* the Cache API on a `keys()` result. Fonts therefore fall outside the prunable set — there are a
|
|
314
|
+
* handful of them and they are not the growth term.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} requestUrl
|
|
317
|
+
* @returns {boolean}
|
|
318
|
+
*/
|
|
319
|
+
function isPrunableAssetUrl(requestUrl) {
|
|
320
|
+
try {
|
|
321
|
+
const pathname = new URL(requestUrl).pathname;
|
|
322
|
+
return pathname.startsWith("/assets/") && HASHED_ASSET_PATTERN.test(pathname);
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/*
|
|
329
|
+
FNXC:PWAOffline 2026-07-26-15:40:
|
|
330
|
+
Both bounded classes (hashed assets, /api/ responses) share ONE policy-driven sweep over ONE keys()
|
|
331
|
+
scan rather than two parallel implementations walking the cache separately. A policy declares which
|
|
332
|
+
URLs it owns, its entry cap, which entries are pinned, and which are provably expired.
|
|
333
|
+
|
|
334
|
+
`isExpired` is deliberately allowed to answer only for entries whose age is PROVABLE. An /api/ entry
|
|
335
|
+
with no recorded timestamp is never served (see isFreshApiCacheEntry) but is also not force-evicted
|
|
336
|
+
here; the cap reclaims it in insertion order. This keeps the sweep from deleting entries it cannot
|
|
337
|
+
reason about — e.g. those written by a previous SW session — as a side effect of an asset prune.
|
|
338
|
+
|
|
339
|
+
Amended 2026-07-26-18:05: be explicit that after an idle-termination cold start BOTH in-memory
|
|
340
|
+
structures are empty, so a prune then sees zero pinned assets and zero provably-expired API entries and
|
|
341
|
+
degrades to a pure oldest-first cap sweep. That is correct behavior, not a gap: the cap is the quota
|
|
342
|
+
guard, and the freshness guarantee lives on the READ path (isFreshApiCacheEntry via the durable
|
|
343
|
+
SW_CACHED_AT_HEADER stamp), which never depends on these maps surviving. `isExpired` deliberately does
|
|
344
|
+
not open each entry to read that header — that would turn the sweep into an O(cache) match() storm on
|
|
345
|
+
the hot path to reclaim disk it will reclaim by cap anyway.
|
|
346
|
+
|
|
347
|
+
Entries outside every policy (the navigation shell, unhashed assets, fonts, icons) are a small fixed
|
|
348
|
+
set and stay untouched, as before.
|
|
349
|
+
|
|
350
|
+
@type {Array<{name: string, limit: number, owns: (url: string) => boolean, isPinned: (url: string) => boolean, isExpired: (url: string) => boolean, forget: (url: string) => void}>}
|
|
351
|
+
*/
|
|
352
|
+
const CACHE_PRUNE_POLICIES = [
|
|
353
|
+
{
|
|
354
|
+
name: "immutable-asset",
|
|
355
|
+
limit: MAX_IMMUTABLE_CACHE_ENTRIES,
|
|
356
|
+
owns: isPrunableAssetUrl,
|
|
357
|
+
isPinned: (url) => sessionReferencedAssets.has(url),
|
|
358
|
+
isExpired: () => false,
|
|
359
|
+
forget: () => undefined,
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: "api",
|
|
363
|
+
limit: MAX_API_CACHE_ENTRIES,
|
|
364
|
+
owns: isApiCacheUrl,
|
|
365
|
+
isPinned: () => false,
|
|
366
|
+
isExpired: (url) => {
|
|
367
|
+
const cachedAt = apiCacheTimestamps.get(url);
|
|
368
|
+
return typeof cachedAt === "number" && !isFreshApiCacheEntry(cachedAt);
|
|
369
|
+
},
|
|
370
|
+
forget: (url) => {
|
|
371
|
+
apiCacheTimestamps.delete(url);
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* FNXC:PWAOffline 2026-07-26-14:05 (generalized 2026-07-26-15:40):
|
|
378
|
+
* Evict per policy until each bounded class is back under its cap: provably-expired entries first
|
|
379
|
+
* (they can never be served, so they are pure reclaimed quota), then oldest-inserted, skipping
|
|
380
|
+
* anything pinned. If every over-cap entry is pinned the sweep simply does less work than requested —
|
|
381
|
+
* never evicting a live chunk is more important than hitting the cap exactly. Fully defensive: it is
|
|
382
|
+
* invoked fire-and-forget so a throw, a rejected delete, or a slow keys() scan can never delay or
|
|
383
|
+
* fail the fetch response it was triggered from.
|
|
384
|
+
*
|
|
385
|
+
* @param {Cache} cache
|
|
386
|
+
* @returns {Promise<void>}
|
|
387
|
+
*/
|
|
388
|
+
async function pruneBoundedCacheEntries(cache) {
|
|
389
|
+
if (immutablePruneInFlight) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
immutablePruneInFlight = true;
|
|
393
|
+
try {
|
|
394
|
+
const keys = await cache.keys();
|
|
395
|
+
const buckets = CACHE_PRUNE_POLICIES.map(() => ({ total: 0, expired: [], evictable: [] }));
|
|
396
|
+
|
|
397
|
+
for (const cachedRequest of keys) {
|
|
398
|
+
if (!cachedRequest) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
for (let index = 0; index < CACHE_PRUNE_POLICIES.length; index += 1) {
|
|
402
|
+
const policy = CACHE_PRUNE_POLICIES[index];
|
|
403
|
+
if (!policy.owns(cachedRequest.url)) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const bucket = buckets[index];
|
|
407
|
+
bucket.total += 1;
|
|
408
|
+
if (policy.isExpired(cachedRequest.url)) {
|
|
409
|
+
bucket.expired.push(cachedRequest);
|
|
410
|
+
} else if (!policy.isPinned(cachedRequest.url)) {
|
|
411
|
+
bucket.evictable.push(cachedRequest);
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (let index = 0; index < CACHE_PRUNE_POLICIES.length; index += 1) {
|
|
418
|
+
const policy = CACHE_PRUNE_POLICIES[index];
|
|
419
|
+
const bucket = buckets[index];
|
|
420
|
+
let overflow = bucket.total - policy.limit;
|
|
421
|
+
|
|
422
|
+
// Expired entries are dropped regardless of overflow; they are unservable dead weight.
|
|
423
|
+
for (const cachedRequest of bucket.expired) {
|
|
424
|
+
try {
|
|
425
|
+
await cache.delete(cachedRequest);
|
|
426
|
+
policy.forget(cachedRequest.url);
|
|
427
|
+
overflow -= 1;
|
|
428
|
+
} catch (deleteError) {
|
|
429
|
+
console.warn(`[sw] ${policy.name} expired eviction failed`, deleteError);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const cachedRequest of bucket.evictable) {
|
|
434
|
+
if (overflow <= 0) {
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
await cache.delete(cachedRequest);
|
|
439
|
+
policy.forget(cachedRequest.url);
|
|
440
|
+
overflow -= 1;
|
|
441
|
+
} catch (deleteError) {
|
|
442
|
+
console.warn(`[sw] ${policy.name} eviction failed`, deleteError);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
console.warn("[sw] cache prune failed", error);
|
|
448
|
+
} finally {
|
|
449
|
+
immutablePruneInFlight = false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* FNXC:PWAOffline 2026-07-26-14:05:
|
|
455
|
+
* Fire-and-forget prune trigger. Runs on the cold path only (after a cache MISS populated a new
|
|
456
|
+
* entry, i.e. exactly when a new build is arriving) and on activate. Not awaited: awaiting would put
|
|
457
|
+
* an O(cache) keys() scan in front of each of a new build's ~130 first-load chunk responses.
|
|
458
|
+
*
|
|
459
|
+
* @param {Cache} cache
|
|
460
|
+
* @returns {void}
|
|
461
|
+
*/
|
|
462
|
+
function schedulePrune(cache) {
|
|
463
|
+
try {
|
|
464
|
+
void pruneBoundedCacheEntries(cache);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
console.warn("[sw] cache prune scheduling failed", error);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* FNXC:PWAOffline 2026-07-26-15:40:
|
|
472
|
+
* Throttled prune trigger for the /api/ hot path — see API_PRUNE_PUT_INTERVAL. The counter advances
|
|
473
|
+
* before the guard so a throwing prune cannot wedge the interval.
|
|
474
|
+
*
|
|
475
|
+
* @param {Cache} cache
|
|
476
|
+
* @returns {void}
|
|
477
|
+
*/
|
|
478
|
+
function scheduleApiPrune(cache) {
|
|
479
|
+
apiPutsSincePrune += 1;
|
|
480
|
+
if (apiPutsSincePrune < API_PRUNE_PUT_INTERVAL) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
apiPutsSincePrune = 0;
|
|
484
|
+
schedulePrune(cache);
|
|
485
|
+
}
|
|
486
|
+
|
|
2
487
|
const APP_SHELL_URLS = [
|
|
3
488
|
"/",
|
|
4
489
|
"/index.html",
|
|
@@ -8,10 +493,38 @@ const APP_SHELL_URLS = [
|
|
|
8
493
|
"/icons/icon-512.png",
|
|
9
494
|
];
|
|
10
495
|
|
|
496
|
+
/*
|
|
497
|
+
FNXC:PWAOffline 2026-07-26-18:05:
|
|
498
|
+
Operator-triggered Cache Storage purge, the service-worker half of `clearAllLocalCache()` (swrCache.ts).
|
|
499
|
+
Settings -> "Clear all cached data" wipes localStorage and then immediately reloads the page, which can
|
|
500
|
+
abort an in-page `caches.delete()` mid-flight. The service worker is NOT torn down by that reload, so
|
|
501
|
+
routing the purge through it is what makes the delete actually land. The page still performs its own
|
|
502
|
+
direct purge as well: a page with no controlling worker (first load, dev without SW) would otherwise get
|
|
503
|
+
no purge at all. Both paths are idempotent — deleting an absent cache is a no-op.
|
|
504
|
+
*/
|
|
505
|
+
const PURGE_CACHES_MESSAGE = "PURGE_CACHES";
|
|
506
|
+
|
|
11
507
|
self.addEventListener("message", (event) => {
|
|
12
508
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
|
13
509
|
self.skipWaiting();
|
|
14
510
|
}
|
|
511
|
+
if (event.data && event.data.type === PURGE_CACHES_MESSAGE) {
|
|
512
|
+
const purge = (async () => {
|
|
513
|
+
try {
|
|
514
|
+
const keys = await caches.keys();
|
|
515
|
+
await Promise.all(keys.map((key) => caches.delete(key)));
|
|
516
|
+
// In-memory bookkeeping describes entries that no longer exist; leaving it would let a
|
|
517
|
+
// subsequent read believe a deleted URL still has a provable put time.
|
|
518
|
+
apiCacheTimestamps.clear();
|
|
519
|
+
sessionReferencedAssets.clear();
|
|
520
|
+
} catch (error) {
|
|
521
|
+
console.warn("[sw] cache purge failed", error);
|
|
522
|
+
}
|
|
523
|
+
})();
|
|
524
|
+
if (event.waitUntil) {
|
|
525
|
+
event.waitUntil(purge);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
15
528
|
});
|
|
16
529
|
|
|
17
530
|
self.addEventListener("install", (event) => {
|
|
@@ -35,6 +548,14 @@ self.addEventListener("activate", (event) => {
|
|
|
35
548
|
.filter((key) => key !== CACHE_NAME)
|
|
36
549
|
.map((key) => caches.delete(key)),
|
|
37
550
|
);
|
|
551
|
+
// FNXC:PWAOffline 2026-07-26-14:05: cross-generation eviction above only fires on a
|
|
552
|
+
// CACHE_NAME bump; this bounds the CURRENT generation on every SW activation too.
|
|
553
|
+
try {
|
|
554
|
+
const cache = await caches.open(CACHE_NAME);
|
|
555
|
+
await pruneBoundedCacheEntries(cache);
|
|
556
|
+
} catch (pruneError) {
|
|
557
|
+
console.warn("[sw] activate prune failed", pruneError);
|
|
558
|
+
}
|
|
38
559
|
await self.clients.claim();
|
|
39
560
|
} catch (error) {
|
|
40
561
|
console.warn("[sw] activate cleanup failed", error);
|
|
@@ -64,8 +585,10 @@ self.addEventListener("fetch", (event) => {
|
|
|
64
585
|
const isBuiltAssetRequest =
|
|
65
586
|
url.pathname.startsWith("/assets/") ||
|
|
66
587
|
request.destination === "script" ||
|
|
67
|
-
request.destination === "style"
|
|
68
|
-
|
|
588
|
+
request.destination === "style";
|
|
589
|
+
// NOTE: `request.destination === "font"` used to be listed here. Fonts are
|
|
590
|
+
// now claimed by isImmutableAssetRequest() above, so repeating them would be
|
|
591
|
+
// an unreachable branch.
|
|
69
592
|
|
|
70
593
|
// EventSource requests stay open indefinitely. Waiting on cache.put() for an
|
|
71
594
|
// infinite response body prevents the browser from ever receiving the stream
|
|
@@ -75,9 +598,10 @@ self.addEventListener("fetch", (event) => {
|
|
|
75
598
|
return;
|
|
76
599
|
}
|
|
77
600
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
601
|
+
/*
|
|
602
|
+
FNXC:PWAOffline 2026-07-26-10:24:
|
|
603
|
+
The navigation shell MUST stay network-first. index.html is the only unhashed document in the graph, so it is the single source of truth for which hashed asset URLs are current. Keeping it fresh is precisely what makes cache-first hashed assets safe: after a deploy the fresh shell names new hashes, those miss the cache, and are fetched. Serving the shell from cache would pin the tab to a previous build's hashes indefinitely. Cache remains an offline fallback only.
|
|
604
|
+
*/
|
|
81
605
|
if (isNavigationRequest) {
|
|
82
606
|
event.respondWith((async () => {
|
|
83
607
|
try {
|
|
@@ -100,22 +624,76 @@ self.addEventListener("fetch", (event) => {
|
|
|
100
624
|
return;
|
|
101
625
|
}
|
|
102
626
|
|
|
627
|
+
/*
|
|
628
|
+
FNXC:PWAOffline 2026-07-26-15:40 (amended 2026-07-26-18:05):
|
|
629
|
+
/api/ stays network-first; the cache is a fallback for a failed request only, and ONLY for the
|
|
630
|
+
allow-listed, credential-free URLs isCacheableApiUrl() admits. Three bounds apply here
|
|
631
|
+
(rationale at MAX_API_CACHE_ENTRIES): the put records a timestamp and throttle-schedules the shared
|
|
632
|
+
prune so the entry count cannot grow without limit, and the fallback read refuses any entry whose
|
|
633
|
+
age is not provably within MAX_API_CACHE_AGE_MS — an expired or unprovable entry is deleted and the
|
|
634
|
+
original network error is rethrown, so the app sees a failed request instead of silently rendering
|
|
635
|
+
stale data it cannot distinguish from live data.
|
|
636
|
+
*/
|
|
103
637
|
if (isApiRequest) {
|
|
638
|
+
/*
|
|
639
|
+
FNXC:PWAOffline 2026-07-26-18:05:
|
|
640
|
+
Confidentiality gate, evaluated BEFORE any cache is opened: a non-allow-listed /api/ URL is passed
|
|
641
|
+
through untouched — no put, and no fallback read either. The read side matters as much as the write
|
|
642
|
+
side: an entry left behind by an older worker generation must not become servable just because this
|
|
643
|
+
generation stopped writing it. Returning without respondWith() lets the browser perform the request
|
|
644
|
+
directly, which is also the cheapest possible path.
|
|
645
|
+
*/
|
|
646
|
+
if (!isCacheableApiUrl(request.url)) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
104
649
|
event.respondWith((async () => {
|
|
105
650
|
try {
|
|
106
651
|
const networkResponse = await fetch(request);
|
|
652
|
+
/*
|
|
653
|
+
FNXC:PWAOffline 2026-07-26-18:05:
|
|
654
|
+
Only `response.ok` is cached — the same rule the immutable-asset branch already applied, which
|
|
655
|
+
this branch was missing. Caching a failure body makes the offline fallback actively wrong rather
|
|
656
|
+
than merely stale: a 401 taken right after a token rotation, or a 500 from a restarting daemon,
|
|
657
|
+
would be replayed on the next network blip as a response the app cannot distinguish from live,
|
|
658
|
+
and an error body carries none of the freshness value the fallback exists for.
|
|
659
|
+
*/
|
|
660
|
+
if (!networkResponse || !networkResponse.ok) {
|
|
661
|
+
return networkResponse;
|
|
662
|
+
}
|
|
107
663
|
try {
|
|
108
664
|
const cache = await caches.open(CACHE_NAME);
|
|
109
|
-
|
|
665
|
+
// FNXC:PWAOffline 2026-07-26-18:05: stamp the put time into the stored entry so a
|
|
666
|
+
// cold-started worker (see buildStampedResponse) can still prove this entry's age.
|
|
667
|
+
let entryToCache = networkResponse.clone();
|
|
668
|
+
try {
|
|
669
|
+
const stamped = await buildStampedResponse(networkResponse);
|
|
670
|
+
if (stamped) {
|
|
671
|
+
entryToCache = stamped;
|
|
672
|
+
}
|
|
673
|
+
} catch (stampError) {
|
|
674
|
+
console.warn("[sw] api cache stamp failed", stampError);
|
|
675
|
+
}
|
|
676
|
+
await cache.put(request, entryToCache);
|
|
677
|
+
rememberApiCacheTimestamp(request.url);
|
|
678
|
+
scheduleApiPrune(cache);
|
|
110
679
|
} catch (cacheError) {
|
|
111
680
|
console.warn("[sw] api cache put failed", cacheError);
|
|
112
681
|
}
|
|
113
682
|
return networkResponse;
|
|
114
683
|
} catch (networkError) {
|
|
115
684
|
try {
|
|
116
|
-
const
|
|
685
|
+
const cache = await caches.open(CACHE_NAME);
|
|
686
|
+
const cachedResponse = await cache.match(request);
|
|
117
687
|
if (cachedResponse) {
|
|
118
|
-
|
|
688
|
+
if (isFreshApiCacheEntry(readApiCachedAtMillis(request.url, cachedResponse))) {
|
|
689
|
+
return cachedResponse;
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
await cache.delete(request);
|
|
693
|
+
apiCacheTimestamps.delete(request.url);
|
|
694
|
+
} catch (deleteError) {
|
|
695
|
+
console.warn("[sw] api stale fallback eviction failed", deleteError);
|
|
696
|
+
}
|
|
119
697
|
}
|
|
120
698
|
} catch (cacheError) {
|
|
121
699
|
console.warn("[sw] api cache lookup failed", cacheError);
|
|
@@ -126,10 +704,52 @@ self.addEventListener("fetch", (event) => {
|
|
|
126
704
|
return;
|
|
127
705
|
}
|
|
128
706
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
707
|
+
/*
|
|
708
|
+
FNXC:PWAOffline 2026-07-26-10:31:
|
|
709
|
+
Immutable assets are served CACHE-FIRST: a hit returns without touching the network, so a discarded-and-restored mobile tab repaints from local storage instead of re-downloading the bundle over a waking radio. On a miss we fetch, populate, and return.
|
|
710
|
+
|
|
711
|
+
This replaces the previous network-first-for-everything rule, which existed out of a stale-JS fear. That fear does not apply here: the URL is content-hashed, so its bytes are immutable and a hit can never be "stale" — a rebuild produces a different URL, which misses. The fear DOES apply to the navigation shell, which is why that branch above is left network-first.
|
|
712
|
+
|
|
713
|
+
Only `response.ok` is cached. A hashed URL that 404s (deploy mid-flight, partially uploaded build) must never be pinned into an immutable cache entry, because nothing would ever evict it before the next CACHE_NAME bump.
|
|
714
|
+
*/
|
|
715
|
+
if (isImmutableAssetRequest(url, request)) {
|
|
716
|
+
event.respondWith((async () => {
|
|
717
|
+
// FNXC:PWAOffline 2026-07-26-14:05: recorded BEFORE the hit/miss branch so an asset the
|
|
718
|
+
// running build is using is eviction-exempt whether it came from cache or network.
|
|
719
|
+
rememberSessionReferencedAsset(request.url);
|
|
720
|
+
try {
|
|
721
|
+
const cache = await caches.open(CACHE_NAME);
|
|
722
|
+
const cachedResponse = await cache.match(request);
|
|
723
|
+
if (cachedResponse) {
|
|
724
|
+
return cachedResponse;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const networkResponse = await fetch(request);
|
|
728
|
+
if (networkResponse && networkResponse.ok) {
|
|
729
|
+
try {
|
|
730
|
+
await cache.put(request, networkResponse.clone());
|
|
731
|
+
schedulePrune(cache);
|
|
732
|
+
} catch (cacheError) {
|
|
733
|
+
console.warn("[sw] immutable asset cache put failed", cacheError);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return networkResponse;
|
|
737
|
+
} catch (error) {
|
|
738
|
+
console.warn("[sw] immutable asset cache flow failed", error);
|
|
739
|
+
const fallback = await caches.match(request);
|
|
740
|
+
if (fallback) {
|
|
741
|
+
return fallback;
|
|
742
|
+
}
|
|
743
|
+
return fetch(request);
|
|
744
|
+
}
|
|
745
|
+
})());
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Non-hashed built assets (unhashed scripts/styles, anything under /assets/
|
|
750
|
+
// that cannot prove it carries a content hash) keep the network-first path:
|
|
751
|
+
// their URL is not a content identity, so a cached copy can genuinely go
|
|
752
|
+
// stale and blank the app after an update. Cache stays an offline fallback.
|
|
133
753
|
if (isBuiltAssetRequest) {
|
|
134
754
|
event.respondWith((async () => {
|
|
135
755
|
try {
|