{"_id":"@astrapi69/pwa-update","_rev":"2-86e2219ec7d58aab89403f514d240de2","name":"@astrapi69/pwa-update","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@astrapi69/pwa-update","version":"0.1.0","keywords":["pwa","service-worker","update","version","workbox","ios","standalone","skip-waiting"],"author":{"name":"Asterios Raptis"},"license":"MIT","_id":"@astrapi69/pwa-update@0.1.0","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"homepage":"https://github.com/astrapi69/pwa-update-kit#readme","bugs":{"url":"https://github.com/astrapi69/pwa-update-kit/issues"},"dist":{"shasum":"fb41f7e4ece6f2d3bf349641b7141ec7a829caf3","tarball":"https://registry.npmjs.org/@astrapi69/pwa-update/-/pwa-update-0.1.0.tgz","fileCount":8,"integrity":"sha512-3Rko7iSlz1ZpxnNnqHToyXsHmDqN1IRHkojHfWk8W2wuRlHs0CtCysKmwoR4cYQwISH4V7YGdaEOA07I2Xiztw==","signatures":[{"sig":"MEYCIQCFrLwuD3C7pQM10W/eAcqNEFOLPevYW8x/96j1BGO1OQIhALlBgpy2xxGSuKuF84cWa1hrxdQSPEbAwRKDZjRZ1eF9","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":223510},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"gitHead":"0325af9d8eba62d04c7fa0b4e30fd1ff3c49167a","scripts":{"build":"tsup"},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"repository":{"url":"git+https://github.com/astrapi69/pwa-update-kit.git","type":"git","directory":"packages/core"},"_npmVersion":"11.16.0","description":"Framework-agnostic PWA update detection: version.json manifest comparison, service-worker activation with capped-backoff retries, accept/dismiss suppression, and encoded platform quirks (iOS standalone full-restart, CDN edge-cache window)","directories":{},"sideEffects":false,"_nodeVersion":"24.15.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/pwa-update_0.1.0_1784560851907_0.5303631425955013","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@astrapi69/pwa-update","version":"0.2.0","description":"Framework-agnostic PWA update detection: version.json manifest comparison, service-worker activation with capped-backoff retries, accept/dismiss suppression, and encoded platform quirks (iOS standalone full-restart, CDN edge-cache window)","license":"MIT","author":{"name":"Asterios Raptis"},"repository":{"type":"git","url":"git+https://github.com/astrapi69/pwa-update-kit.git","directory":"packages/core"},"keywords":["pwa","service-worker","update","version","workbox","ios","standalone","skip-waiting"],"type":"module","main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"sideEffects":false,"scripts":{"build":"tsup"},"publishConfig":{"access":"public"},"engines":{"node":">=20"},"gitHead":"62dbd99417758804d00fec5871bfe57ce9e5b435","_id":"@astrapi69/pwa-update@0.2.0","bugs":{"url":"https://github.com/astrapi69/pwa-update-kit/issues"},"homepage":"https://github.com/astrapi69/pwa-update-kit#readme","_nodeVersion":"24.15.0","_npmVersion":"11.16.0","dist":{"integrity":"sha512-NodlFdLFJfb2MWHzgnGJs3zPKzev4KKmDD/qBm/tC7A3XsbSUd8p3/oV2ZpwwIotLQIpXB4JbPs+omYwmAzpgA==","shasum":"8f2474a64737e07d893b96c7bb104ff3aab0c46c","tarball":"https://registry.npmjs.org/@astrapi69/pwa-update/-/pwa-update-0.2.0.tgz","fileCount":8,"unpackedSize":242540,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIDjRDKpBZxSw4vaiRmlNH4YcrIEzDb9OxfDh8LBejPN+AiAseHuAnsbvvQbOZr2SPAlrbZXlt9THAGv12XXHTjy+Xw=="}]},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"directories":{},"maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/pwa-update_0.2.0_1784641075575_0.35295980215939804"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-20T15:20:51.730Z","modified":"2026-07-21T13:37:55.893Z","0.1.0":"2026-07-20T15:20:52.048Z","0.2.0":"2026-07-21T13:37:55.737Z"},"bugs":{"url":"https://github.com/astrapi69/pwa-update-kit/issues"},"author":{"name":"Asterios Raptis"},"license":"MIT","homepage":"https://github.com/astrapi69/pwa-update-kit#readme","keywords":["pwa","service-worker","update","version","workbox","ios","standalone","skip-waiting"],"repository":{"type":"git","url":"git+https://github.com/astrapi69/pwa-update-kit.git","directory":"packages/core"},"description":"Framework-agnostic PWA update detection: version.json manifest comparison, service-worker activation with capped-backoff retries, accept/dismiss suppression, and encoded platform quirks (iOS standalone full-restart, CDN edge-cache window)","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"readme":"# @astrapi69/pwa-update\n\nFramework-agnostic PWA update detection: compare the running build against a\ndeployed `version.json`, drive the service-worker activation, and never nag a\nuser who already pressed Update.\n\nZero dependencies. No build-tool coupling — the running build, the manifest\nURL and the storage namespace are all parameters.\n\n```bash\nnpm install @astrapi69/pwa-update\n```\n\nReact UI: [`@astrapi69/pwa-update-react`](https://www.npmjs.com/package/@astrapi69/pwa-update-react).\nVite build half: [`@astrapi69/vite-plugin-build-version`](https://www.npmjs.com/package/@astrapi69/vite-plugin-build-version).\n\n## Quick start\n\n```ts\nimport { createUpdateStore } from \"@astrapi69/pwa-update\";\n\nconst store = createUpdateStore({\n    build: { version: __APP_VERSION__, buildHash: __BUILD_HASH__ },\n    manifestUrl: `${import.meta.env.BASE_URL}version.json`,\n    storageNamespace: \"my-app\",\n});\n\nstore.ensureInit(navigator.onLine);              // passive detection\nstore.subscribe(() => render(store.getSnapshot()));\n\n// explicit check (a settings button)\nawait store.checkNow();\n\n// user pressed Update\nstore.apply();\n```\n\nYour build must emit the matching manifest:\n\n```json\n{ \"version\": \"2.4.0\", \"buildHash\": \"a1b2c3d\", \"buildDate\": \"2026-07-20T10:00:00Z\" }\n```\n\nKeep it OUT of your service-worker precache globs so it is always fetched\nfresh rather than served from a stale precache.\n\n## Three things a host usually needs\n\n### Unsaved work: flush before the reload (`onBeforeApply`)\n\nAn app holding unsaved state — an editor buffer, a draft — must write it out\nbefore the page reloads. Doing that in `beforeunload` is best-effort only:\nthe event cannot await an async IndexedDB write. Pass a hook instead; it is\n**awaited** before the activation starts.\n\n```ts\ncreateUpdateStore({\n    build, manifestUrl,\n    onBeforeApply: () => flushEditorToIndexedDb(),   // awaited\n});\n```\n\nA rejection is swallowed: a failed flush must never strand the user on a\nstale build with a dead Update button.\n\n### No deployed manifest: SW-only mode (`manifestUrl: null`)\n\nNot every deployment can serve a static `version.json`. Pass `null` and\ndetection rests entirely on the service-worker cycle — a quiet cycle then\nreports `current`, never `error`, because there is nothing to fetch.\n\n```ts\ncreateUpdateStore({ build, manifestUrl: null, storageNamespace: \"my-app\" });\n```\n\n### Long-lived tabs: proactive polling (`polling`)\n\nThe baseline (start + foreground return) suits an app the user opens and\ncloses. An app someone keeps open for hours would never notice a deploy, so\nrepeated checks are opt-in:\n\n```ts\ncreateUpdateStore({\n    build, manifestUrl,\n    polling: { intervalMs: 60 * 60 * 1000, onFocus: true },\n});\n\nconst stop = store.startPolling(() => navigator.onLine);   // React: automatic\n```\n\nTicks route through the same throttle as the foreground re-check, so a value\nbelow `foregroundRecheckThrottleMs` simply polls at the throttle rate.\n\n## Platform quirks this package encodes\n\nThis is the part you cannot get from a generic version-compare snippet. Each\nitem below cost a production incident to learn.\n\n### 1. An installed iOS PWA needs a full app restart\n\nOn iOS/WKWebView in standalone display mode, a freshly installed service\nworker frequently does **not** take control on `skipWaiting()` + reload — the\nway it does on every other platform. It activates reliably only after the app\nis fully closed and reopened.\n\nAn update UI that assumes the reload is enough leaves iOS users pressing a\nbutton that visibly does nothing. So \"needs a full restart\" is a named,\nfirst-class property here, not an internal detail:\n\n```ts\ncreateUpdateStore({\n    build,\n    manifestUrl,\n    quirks: {\n        // default: detectIosStandalone\n        needsFullRestart: () => myOwnPredicate(),\n    },\n});\n\nstore.getSnapshot().needsFullRestart; // -> show \"close the app and reopen it\"\n```\n\nIt is a **predicate**, not a boolean flag, so what is actually being decided\nstays visible — and it is part of the state, so a UI cannot silently drop the\nhint during a refactor.\n\n### 2. A backgrounded PWA stops polling — re-check on foreground\n\nThe same iOS suspension means the manifest poll and the worker both freeze\nwhile the app is in the background. Returning to the foreground is the only\nreliable moment to re-detect a new build:\n\n```ts\ndocument.addEventListener(\"visibilitychange\", () => {\n    if (document.visibilityState === \"visible\") store.maybeRecheck(navigator.onLine);\n});\n```\n\n`maybeRecheck` throttles itself (default 15 min). Pick a value at or above\nyour host's `version.json` cache TTL — checking more often than the edge cache\nrefreshes yields no new signal. GitHub Pages serves `max-age=600`.\n\n### 3. The CDN keeps serving the old `sw.js` for a while\n\nRight after a deploy the manifest can already report a newer build while the\nedge still hands out the previous `sw.js` — so the worker cycle produces no\nwaiting worker. Reporting \"up to date\" there is a lie; offering an apply\nbutton is a dead control.\n\n`checkNow()` reports **`preparing`** for exactly this window. Surface it as\n\"a new build is being prepared, check again shortly\".\n\nRelated: manifest fetches carry both `cache: \"no-store\"` **and** a\ncache-buster query param. `no-store` bypasses the browser cache and the\nservice worker but not a CDN edge cache.\n\n### 4. The build hash is the truth, not the version string\n\nOn a rolling channel (a \"latest\" preview deploy) the version string never\nchanges between deploys — only the hash moves. Suppression keyed on the\nversion alone mutes the update banner **forever** after one accepted update.\n`AcceptanceGuard` records version *and* hash, so a same-version deploy with a\nnewer hash re-offers the update once the quiet window passes.\n\n### 5. Never reload onto a stale build\n\n`activateInBackground()` retries the skip-waiting handshake on a capped\nbackoff and reloads **only** when a fresh worker actually takes control. If it\nnever takes within the budget it gives up **silently** — no reload, no banner.\nA forced reload onto the old precache would just make the banner reappear, and\nthe user would press Update again, forever.\n\n### 6. Accept and dismiss are different intents\n\n- **Dismiss** (\"Later\") — re-offered on the next app start.\n- **Accept** (\"Update\") — suppressed for a quiet window *and* for the exact\n  build accepted, across reloads, backed by three redundant layers\n  (session flag, timestamp, accepted build). A stale reload cannot re-nag.\n\n### 7. A stale deploy also breaks lazy routes\n\nOld hashed chunks are purged while a stale `index.html` still references them,\nso navigating to a not-yet-loaded route throws \"Failed to fetch dynamically\nimported module\". `isChunkLoadError` / `shouldReloadForChunkError` recognise\nthat family; the React package ships the `lazyWithReload` wrapper.\n\n## API\n\n| Export | Purpose |\n|---|---|\n| `createUpdateStore(options)` | The store: passive detection, explicit check, apply/dismiss, banner visibility |\n| `store.startPolling(isOnline)` | Start the configured interval / focus polling; returns a stop function |\n| `checkForUpdateReliable(deps)` | One-pass check: manifest **and** worker cycle, awaited together |\n| `activateInBackground(options)` | Capped-backoff activation that never reloads onto a stale build |\n| `activateAndReload(options)` | Activation with a safety-net reload |\n| `awaitServiceWorkerUpdate(...)` | Await the worker install cycle with a timeout |\n| `AcceptanceGuard` | Accept-suppression rules, reusable for a custom surface |\n| `isUpdateAvailable`, `parseVersionManifest`, `fetchLatestVersion`, `knownBuildHash` | Pure manifest helpers |\n| `detectIosStandalone`, `isIosDevice`, `isIosStandalone`, `isStandaloneDisplay` | Platform detection |\n| `isChunkLoadError`, `shouldReloadForChunkError` | Stale-deploy chunk failures |\n| `NamespacedStore`, `defaultLocalStore`, `defaultSessionStore` | Namespaced, never-throwing storage |\n\n### Storage\n\nThree small facts are persisted (accepted build, last check time), all\ndevice-local UI state. Keys are namespaced; every access is guarded, so\nSafari private mode degrades to \"no persistence\" instead of throwing. Inject\nyour own stores for tests or SSR:\n\n```ts\ncreateUpdateStore({ build, manifestUrl, storage: { local, session } });\n```\n\n## License\n\nMIT © Asterios Raptis\n","readmeFilename":"README.md"}