mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The web UI registers a service worker (`/sw.js`) that caches the app shell for an offline fallback. > - Browsers only re-fetch a worker script on navigation or a ~24h timer, and Paperclip is a parked-tab SPA: a tab can sit open for weeks without one navigation. > - An installed worker — and the shell it cached — can therefore keep serving an old bundle long after a deploy, and the server let `sw.js` inherit the generic 1h static TTL on top of that. > - This pull request adds explicit update checks (tab-visible + hourly), applies a discovered update with one reload while the tab is hidden, and serves `sw.js` with `Cache-Control: no-cache`. > - The benefit is that a deploy reaches every open tab within about an hour, instead of some tabs silently running stale UI indefinitely. ## Linked Issues or Issue Description Refs #11292 (the network-first `sw.js` fallback fix; this PR closes the delivery gap that can keep clients pinned on a pre-#11292 worker). **What happened?** A browser that had an older cache-first worker installed kept rendering a stale app shell — old feature set, old naming — while the server was verified to be running the current release. Nothing on the client checks for a new worker outside navigations, so a parked tab never picked up the fixed worker, and `sw.js` was served with a 1h cache TTL that further delayed update checks. **Expected behavior** Every open tab converges to the deployed bundle shortly after a release, without users unregistering workers in DevTools or hard-reloading. **Steps to reproduce** Install a build's service worker, deploy a newer build, and leave the tab parked (no navigation): the tab keeps running the old bundle indefinitely; the worker update check only happens if the user navigates, and even then a cached `sw.js` can answer it. ## What Changed - New `ui/src/lib/service-worker-updates.ts`: registers `/sw.js`, runs `registration.update()` when the tab becomes visible and on an hourly timer, and on `controllerchange` of a previously-controlled page applies the update with a single reload — only while the tab is hidden, so an update never yanks the page mid-session; a takeover while visible defers the reload to the next hidden transition. First-ever installs never reload. - `ui/src/main.tsx`: replaces the fire-and-forget `register()` with the new module. - New `server/src/static-ui-cache.ts` (`staticUiCacheControl`): `index.html` and `sw.js` are served `Cache-Control: no-cache`; other non-hashed statics keep the 1h default. `server/src/app.ts` uses it in the static middleware. ## Verification - `npx vitest run ui/src/lib/service-worker-updates.test.ts` — 8 tests: registration, hidden-takeover reload (once), deferred reload on visible takeover, no reload on first install, visibility-triggered and timer-triggered update checks, cleanup, no-container no-op. - `npx vitest run server/src/__tests__/static-ui-cache.test.ts` — 3 tests incl. the `sw.js.map` lookalike keeping the default TTL. - `tsc -b` (ui) and `tsc --noEmit` (server) clean; `pnpm check:tokens` clean. ## Risks - Behavioral shift: tabs now reload once, while hidden, after a deploy lands. Unsaved in-page state in a hidden tab is lost at that moment — the same exposure as a browser discarding a background tab, which SPAs must already tolerate. - Self-hosted behavior is otherwise unchanged: same worker script, same registration URL, one added conditional header. - Low risk on the server side: the header change only widens revalidation. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use and extended thinking. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
/**
|
|
* Registers `/sw.js` and keeps the installed worker fresh on a long-lived tab.
|
|
*
|
|
* Browsers only re-fetch a service-worker script on navigation or on a ~24h
|
|
* timer. Paperclip is a parked-tab SPA — a tab can stay open for weeks without
|
|
* a single navigation — so without explicit update checks an old worker (and
|
|
* the app shell it cached) can outlive a deploy indefinitely. The symptom is
|
|
* invisible: the tab just keeps running the old bundle.
|
|
*
|
|
* Two behaviors close the gap:
|
|
* - `registration.update()` runs when the tab becomes visible and on an
|
|
* hourly timer, so parked tabs learn about new workers without navigating.
|
|
* - When a new worker takes control (`controllerchange`), the page reloads
|
|
* once so the fresh shell actually replaces the running bundle — but only
|
|
* while the tab is hidden, so an update landing mid-session never yanks
|
|
* the page out from under the user; a takeover while visible defers the
|
|
* reload to the next time the tab is hidden. First-ever installs skip the
|
|
* reload entirely: an uncontrolled page is already running the code the
|
|
* server just handed it.
|
|
*/
|
|
const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
|
export function startServiceWorkerUpdates(
|
|
options: {
|
|
container?: ServiceWorkerContainer;
|
|
documentRef?: Document;
|
|
reload?: () => void;
|
|
updateIntervalMs?: number;
|
|
} = {},
|
|
): () => void {
|
|
const container =
|
|
options.container ??
|
|
(typeof navigator !== "undefined" && "serviceWorker" in navigator
|
|
? navigator.serviceWorker
|
|
: undefined);
|
|
if (!container) {
|
|
return () => {};
|
|
}
|
|
const documentRef = options.documentRef ?? document;
|
|
const reload = options.reload ?? (() => window.location.reload());
|
|
const updateIntervalMs = options.updateIntervalMs ?? DEFAULT_UPDATE_INTERVAL_MS;
|
|
|
|
// Only a page that is already worker-controlled is running
|
|
// potentially-stale code when the controller changes; a first-ever
|
|
// install taking control is caching the very bundle the page is running,
|
|
// so reloading would be a no-op. The flag is promoted on that first
|
|
// takeover: any later controller change on this (possibly weeks-old) tab
|
|
// does mean newer code exists.
|
|
let wasControlled = Boolean(container.controller);
|
|
let reloaded = false;
|
|
let reloadPending = false;
|
|
const applyUpdate = () => {
|
|
if (reloaded) return;
|
|
reloaded = true;
|
|
reload();
|
|
};
|
|
const onControllerChange = () => {
|
|
if (!wasControlled) {
|
|
wasControlled = true;
|
|
return;
|
|
}
|
|
if (documentRef.visibilityState === "hidden") {
|
|
applyUpdate();
|
|
} else {
|
|
reloadPending = true;
|
|
}
|
|
};
|
|
container.addEventListener("controllerchange", onControllerChange);
|
|
|
|
let registration: ServiceWorkerRegistration | undefined;
|
|
const checkForUpdates = () => {
|
|
// update() rejects while offline or mid-deploy; the next visibility
|
|
// change or timer tick retries, so failures are deliberately swallowed.
|
|
void registration?.update().catch(() => {});
|
|
};
|
|
const onVisibilityChange = () => {
|
|
if (documentRef.visibilityState === "visible") {
|
|
checkForUpdates();
|
|
} else if (reloadPending) {
|
|
applyUpdate();
|
|
}
|
|
};
|
|
documentRef.addEventListener("visibilitychange", onVisibilityChange);
|
|
const intervalId = setInterval(checkForUpdates, updateIntervalMs);
|
|
|
|
void container
|
|
.register("/sw.js")
|
|
.then((reg) => {
|
|
registration = reg;
|
|
})
|
|
.catch(() => {
|
|
// Registration can fail in private windows or hardened browsers; the
|
|
// app works without a worker, it just loses the offline fallback.
|
|
});
|
|
|
|
return () => {
|
|
container.removeEventListener("controllerchange", onControllerChange);
|
|
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
|
|
clearInterval(intervalId);
|
|
};
|
|
}
|