-
Notifications
You must be signed in to change notification settings - Fork 0
feat(pwa): retirement kill-switch, offline-version binding guard, and dev teardown flag #826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
3d9ee5f
feat(pwa): commit retirement kill-switch, offline-version binding gua…
claude 2b1c365
docs: record PWA hardening phase review in branch ledger
claude 57a2f6b
Merge branch 'main' into claude/clinical-kb-pwa-review-asi3wb
BigSimmo 8406bc4
fix: exact-match the owned worker URL in ?pwa-dev=0 teardown
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /* Clinical KB PWA retirement worker (docs/pwa.md rule 6). | ||
| * | ||
| * This file is NOT registered during normal operation. To retire the PWA, ship | ||
| * this file's CONTENT as /sw.js (replace public/sw.js with it) in a single | ||
| * deployment. /sw.js is served no-store and registered with | ||
| * `updateViaCache: "none"`, so installed clients fetch the replacement on | ||
| * their next update check; it then deletes every owned cache, unregisters | ||
| * itself, and the site continues as a plain web application. Never remove the | ||
| * /sw.js route instead — installed workers can outlive a 404. | ||
| */ | ||
|
|
||
| const CACHE_PREFIX = "clinical-kb-pwa-"; | ||
|
|
||
| self.addEventListener("install", (event) => { | ||
| // Retirement is the documented exception to the no-auto-skipWaiting rule: | ||
| // the tear-down worker must replace the caching worker without waiting for | ||
| // every tab to close. | ||
| event.waitUntil(self.skipWaiting()); | ||
| }); | ||
|
|
||
| self.addEventListener("activate", (event) => { | ||
| event.waitUntil( | ||
| (async () => { | ||
| let names = []; | ||
| try { | ||
| names = await caches.keys(); | ||
| } catch { | ||
| // CacheStorage may be unavailable; unregistration must still proceed. | ||
| } | ||
| await Promise.allSettled( | ||
| names.filter((name) => name.startsWith(CACHE_PREFIX)).map((name) => caches.delete(name)), | ||
| ); | ||
| try { | ||
| await self.registration.unregister(); | ||
| } catch { | ||
| // Even if unregistration fails, this worker has no fetch handler, so | ||
| // every request already goes straight to the network. | ||
| } | ||
| try { | ||
| await self.clients.claim(); | ||
| } catch { | ||
| // Best-effort: pages this worker never claims stop being controlled on | ||
| // their next navigation anyway. | ||
| } | ||
| })(), | ||
| ); | ||
| }); | ||
|
|
||
| // Deliberately no fetch listener: retirement means the network handles every | ||
| // request and nothing is ever served from or written to CacheStorage. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { readFileSync } from "node:fs"; | ||
| import { resolve } from "node:path"; | ||
| import { createContext, runInContext } from "node:vm"; | ||
|
|
||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| const KILL_SWITCH_SOURCE = readFileSync(resolve(process.cwd(), "public/sw-kill-switch.js"), "utf8"); | ||
| const WORKER_SOURCE = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); | ||
|
|
||
| class ExtendableEventHarness { | ||
| private readonly lifetimePromises: Promise<unknown>[] = []; | ||
|
|
||
| waitUntil(promise: Promise<unknown>): void { | ||
| this.lifetimePromises.push(Promise.resolve(promise)); | ||
| } | ||
|
|
||
| async settle(): Promise<void> { | ||
| let settledCount = 0; | ||
| while (settledCount < this.lifetimePromises.length) { | ||
| const pending = this.lifetimePromises.slice(settledCount); | ||
| settledCount = this.lifetimePromises.length; | ||
| await Promise.all(pending); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function createKillSwitchHarness(seededCacheNames: string[], { keysUnavailable = false } = {}) { | ||
| const listeners = new Map<string, Array<(event: unknown) => void>>(); | ||
| const names = new Set(seededCacheNames); | ||
| const deletedNames: string[] = []; | ||
| const openedNames: string[] = []; | ||
| const unregister = vi.fn(async () => true); | ||
| const claimClients = vi.fn(async () => undefined); | ||
| const skipWaiting = vi.fn(async () => undefined); | ||
|
|
||
| const cacheStorage = { | ||
| async delete(name: string): Promise<boolean> { | ||
| deletedNames.push(name); | ||
| return names.delete(name); | ||
| }, | ||
| async keys(): Promise<string[]> { | ||
| if (keysUnavailable) throw new Error("CacheStorage unavailable"); | ||
| return [...names]; | ||
| }, | ||
| async open(name: string): Promise<never> { | ||
| openedNames.push(name); | ||
| throw new Error("The retirement worker must never open or write caches."); | ||
| }, | ||
| }; | ||
|
|
||
| const workerGlobal = { | ||
| addEventListener(type: string, listener: (event: unknown) => void) { | ||
| const registered = listeners.get(type) ?? []; | ||
| registered.push(listener); | ||
| listeners.set(type, registered); | ||
| }, | ||
| clients: { claim: claimClients }, | ||
| registration: { unregister }, | ||
| skipWaiting, | ||
| }; | ||
|
|
||
| runInContext(KILL_SWITCH_SOURCE, createContext({ caches: cacheStorage, console, self: workerGlobal }), { | ||
| filename: "public/sw-kill-switch.js", | ||
| }); | ||
|
|
||
| return { | ||
| claimClients, | ||
| deletedNames, | ||
| listeners, | ||
| openedNames, | ||
| skipWaiting, | ||
| unregister, | ||
| remainingCacheNames: () => [...names], | ||
| async dispatch(type: string): Promise<void> { | ||
| const event = new ExtendableEventHarness(); | ||
| for (const listener of listeners.get(type) ?? []) listener(event); | ||
| await event.settle(); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| describe("PWA retirement kill-switch worker", () => { | ||
| it("targets exactly the cache prefix owned by the production worker", () => { | ||
| const killSwitchPrefix = KILL_SWITCH_SOURCE.match(/const CACHE_PREFIX = "([^"]+)";/)?.[1]; | ||
| const workerPrefix = WORKER_SOURCE.match(/const CACHE_PREFIX = "([^"]+)";/)?.[1]; | ||
|
|
||
| expect(killSwitchPrefix).toBeTruthy(); | ||
| expect(killSwitchPrefix).toBe(workerPrefix); | ||
| }); | ||
|
|
||
| it("activates immediately as the documented retirement exception to no-auto-skipWaiting", async () => { | ||
| const harness = createKillSwitchHarness([]); | ||
|
|
||
| await harness.dispatch("install"); | ||
|
|
||
| expect(harness.skipWaiting).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("deletes every owned cache generation, including unknown future versions, and leaves foreign caches", async () => { | ||
| const harness = createKillSwitchHarness([ | ||
| "clinical-kb-pwa-shell-2026-07-15-v1", | ||
| "clinical-kb-pwa-static-2026-07-15-v1", | ||
| "clinical-kb-pwa-static-2099-12-31-v9", | ||
| "unrelated-application-cache", | ||
| ]); | ||
|
|
||
| await harness.dispatch("activate"); | ||
|
|
||
| expect([...harness.deletedNames].sort()).toEqual([ | ||
| "clinical-kb-pwa-shell-2026-07-15-v1", | ||
| "clinical-kb-pwa-static-2026-07-15-v1", | ||
| "clinical-kb-pwa-static-2099-12-31-v9", | ||
| ]); | ||
| expect(harness.remainingCacheNames()).toEqual(["unrelated-application-cache"]); | ||
| expect(harness.unregister).toHaveBeenCalledTimes(1); | ||
| expect(harness.claimClients).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("registers no fetch handler and never opens or writes CacheStorage", async () => { | ||
| const harness = createKillSwitchHarness(["clinical-kb-pwa-shell-2026-07-15-v1"]); | ||
|
|
||
| await harness.dispatch("install"); | ||
| await harness.dispatch("activate"); | ||
|
|
||
| expect(harness.listeners.has("fetch")).toBe(false); | ||
| expect(harness.openedNames).toEqual([]); | ||
| }); | ||
|
|
||
| it("still unregisters when CacheStorage enumeration is unavailable", async () => { | ||
| const harness = createKillSwitchHarness(["clinical-kb-pwa-shell-2026-07-15-v1"], { keysUnavailable: true }); | ||
|
|
||
| await harness.dispatch("activate"); | ||
|
|
||
| expect(harness.deletedNames).toEqual([]); | ||
| expect(harness.unregister).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.