fix(web): worker update consent, keel.web strict, and the write boundary as a module - #555
Merged
Conversation
…ed, and keel.web is strict Two defects in code shipped last week, both found by running a checklist and a type checker against it rather than by reading it again. ── THE WORKER TOOK OVER A PAGE IT DID NOT RENDER ────────────────────────────── `install` called `skipWaiting()` unconditionally, and `activate` called `clients.claim()`. The argument for that -- written into the module -- was that the version-keyed cache made it safe: new worker, new cache, old one deleted. That reasoning is sound about CACHES and silent about the page already on screen. `skipWaiting` + `claim` takes over a document parsed and rendered by the OLD build's JavaScript and answers its later requests from the NEW build's cache. One page, two builds, nothing on screen to say so. keel had a second line of defence that made it hard to notice: a new build means the process restarted, which means a new session token, which means every `/api/*` call from the old page is a 403 the banner reports. The window was narrow and loud rather than wide and quiet. It was still a window, and a hazard covered by an unrelated layer is not a hazard that has been dealt with. Now the new worker installs its cache and STAYS WAITING. `main.js` notices, the footer offers "A newer build is ready -- reload", and only a click sends `SKIP_WAITING`. The reload waits for `controllerchange` rather than firing straight after the message, because the takeover has to land first or the reload fetches the old build again and leaves the offer standing. The offer is in the footer beside the build line, NOT in the engine banner: that banner is the page's one `aria-live` region and it answers "is keel running", so an upgrade notice there would interrupt a screen reader mid-table to say something that can wait indefinitely. ── AND A SECOND BUG, FOUND BY DRIVING THE FIRST FIX ─────────────────────────── The first spelling of `watchForUpdate` returned early when there was no controller -- meaning that on a FIRST visit, the one load where there reliably is none, the `updatefound` listener was never attached at all. The controller check belongs on the OFFER (do not announce a first install as an update), never on the watching. Found in a browser, not in review. ── `keel/web` IS `--strict`, AND WAS TWO ERRORS AWAY ────────────────────────── Measured rather than assumed: `mypy --strict keel/web` reported two errors, both in `build_server`, and both the same defect. Three-argument `type()` is declared to return plain `type`, so it produced an `Any` that flowed out of a function annotated `-> KeelServer` -- under a `# type: ignore[return-value]` that did not name the error mypy was actually reporting (`no-any-return`), a suppression that silences nothing while looking like it silences something. Rewritten as nested subclasses: the types are checked rather than asserted, and `keel.web.*` now carries the strict flag block. It is the layer where that matters most -- everything under it is reachable from a socket. `test_strictly_typed_packages_ship_a_py_typed_marker` caught the consequence immediately, which is the pin working: PEP 561's marker is a promise to whoever INSTALLS a distribution, and `keel.web` is an internal subpackage with no installer of its own. The rule now reads distribution ROOTS, with a mutation guard so the filter cannot quietly empty the list it parametrises over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
…ient's own shape `keel/web/static/js/actions.js`: this client's `data-manager.js`. ── WHOSE SHAPE, AND WHY IT IS THE RIGHT ONE TO COPY ─────────────────────────── The design spec's reference implementation is youperiod.app, and `js/external/`'s README already names it. Its client is five modules with one job each: `main.js` attaches event listeners, `data-manager.js` owns the storage boundary behind a `get`/`set` pair, `utils.js` holds the helpers. keel's client was built to that shape -- `api.js` the single `fetch` wrapper, `render.js` the only view builder, `format.js` the helpers, `js/external/` deliberately EMPTY where the reference admits three vetted libraries. Then #540 gave the browser a write surface and put all of it in `main.js`: the session write token, the submit handler, the memory of what each action reported, and the choice of which server field to show. That is the module the reference keeps emptiest, and it had quietly grown a second job -- the read path had a boundary module and the write path did not. ── WHAT THE BOUNDARY HIDES ──────────────────────────────────────────────────── A caller needs an action's key and the operator's answers. It no longer needs to know that a write carries a session-scoped HMAC token beside `X-Keel-Client`, that the token arrives on `/api/setup` and nowhere else, that the result distinguishes "done" from "already done" through a `changed` flag rather than an error, or that the sentence comes from `data.message.display` on success and `error.detail` on refusal. `main.js`'s listener is now what that file is for: read the form, show the answer, repaint. ── THE TOKEN HAS ONE HOME, AND THE TEST SAYS SO IN THOSE TERMS ──────────────── Pinned as "where is it HELD", not "where does the word appear". `api.js` names it because it is the parameter it puts in a header -- that module is the only one allowed to open a connection -- but it takes it as an argument and keeps nothing. `actions.js` is the one module with a variable holding it between calls. A credential that acquires a second home is one the next thing added there will read. Two assertions had to be rewritten before they were true rather than merely red: the header name is a string literal, so `_code_only` (which blanks literals) cannot see it, and `actions.js` NAMES the header in a comment listing what it hides -- so the honest check is behavioural, that the boundary assembles no request at all. ── A BUG THE MOVE REMOVED RATHER THAN FIXED ─────────────────────────────────── The outcome memory was a `Map<string, Node>`, and a DOM node can be in one place in a document at a time -- so every restore had to `cloneNode` or the second rebuild would move the only copy out of the map's reach. It is a `Map<string, string>` now. The bug that invited is not fixed; it can no longer be written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Three skills run against
keel/web:pwa-best-practices,python-pro,improve-codebase-architecture.Two real bugs in last week's code, one
Anyleak, and one module split — with the architecturechosen to match youperiod.app, which the design spec
already names as its reference.
1. The worker took over a page it did not render
installcalledskipWaiting()unconditionally andactivatecalledclients.claim(). Theargument for that was written into the module: the version-keyed cache makes it safe — new worker,
new cache, old one deleted. Sound about caches, silent about the page already on screen.
skipWaiting+claimtakes over a document rendered by the old build and answers its laterrequests from the new build's cache.
keel had a second line of defence that made it hard to notice — a new build means a restarted
process, a new session token, and a 403 the banner reports — so the window was narrow and loud
rather than wide and quiet. A hazard covered by an unrelated layer is not a hazard dealt with.
The worker now waits; the footer offers "A newer build is ready — reload"; only a click sends
SKIP_WAITING, and the reload waits forcontrollerchangeso the takeover lands first. The offeris in the footer, not the engine banner — that banner is the page's one
aria-liveregion andanswers "is keel running".
Driving that fix found a second bug in it:
watchForUpdatereturned early with no controller,so on a first visit — the one load where there reliably is none — the
updatefoundlistener wasnever attached. The controller check belongs on the offer, not the watching.
Also checked from the same list:
idwas changed/static/→/in #540, which orphans installs.No release has ever shipped the manifest, so it orphaned none. Now that one has, it's frozen.
2.
keel/webis--strict, and was two errors awayBoth errors were one defect: three-argument
type()returns plaintype, leaking anAnyout of afunction annotated
-> KeelServer, under a# type: ignore[return-value]that did not name theerror mypy was reporting (
no-any-return) — a suppression that silences nothing while lookinglike it does. Rewritten as nested subclasses;
keel.web.*is now gated strict, which is where itmatters most since all of it is reachable from a socket.
test_strictly_typed_packages_ship_a_py_typed_markercaught the consequence, which is the pinworking: PEP 561's marker is a promise to whoever installs a distribution, and
keel.webis aninternal subpackage with no installer. A marker at
keel/py.typedwould promise a guarantee forkeel.*, stillignore_errors— what that test's own docstring warns against. It now readsdistribution roots, with a mutation guard so the filter can't quietly empty its own parametrise
list.
3. The write boundary is a module
The spec's reference client is five modules with one job each:
main.jsattaches event listeners,data-manager.jsowns the storage boundary behind aget/setpair. keel was built to that shape—
api.jsthe singlefetchwrapper,render.jsthe only view builder,format.jsthe helpers,js/external/deliberately empty where the reference admits three vetted libraries.Then #540 gave the browser a write surface and left all of it in
main.js: the token, the submithandler, the outcome memory, the sentence-picking. That's the module the reference keeps emptiest —
the read path had a boundary and the write path did not.
js/actions.jsis keel'sdata-manager.js. Callers need a key and the operator's answers; they nolonger need to know about the HMAC token, that it arrives on
/api/setupalone, thatchangeddistinguishes "done" from "already done" rather than signalling failure, or which field carries the
sentence.
main.js's listener is now what that file is for: read the form, show the answer, repaint.The token is pinned by where it's held, not where the word appears.
api.jsnames it becauseit's the parameter it puts in a header — the only module allowed to open a connection — but keeps
nothing. Two assertions had to be rewritten before they were true rather than merely red: the header
name is a string literal that
_code_onlyblanks, andactions.jsnames the header in a commentlisting what it hides, so the honest check is behavioural — the boundary assembles no request at all.
And a bug the move removed rather than fixed: the outcome memory was a
Map<string, Node>, anda DOM node lives in one place at a time, so every restore had to
cloneNodeor the second rebuildmoved the only copy out of the map's reach. It's a
Map<string, string>now; that bug can no longerbe written.
Verification
ruff,mypyand the full suite green (4271 passed, 3 skipped), 9 new tests.In a real Chromium — none of the below is provable by reading files:
config→databaseon a fresh deployment, real files written, idempotent re-run reports "already done — nothing to change", outcomes survive a pollOn the audit scripts
audit-service-worker.tsstill reports three failures, and all three are one blind spot:DEFAULT_SOURCE_DIRSis["app","src","components","lib","pages"], so it never seeskeel/web/static/js/. It reports "no registration" and "no update UI" for things that exist and areverified above;
--root keel/web/staticdoesn't help, sincejs/isn't in that list either. Thechecklist earned its place —
sw-update-ux-missingwas read straight out ofsw.jsand was real —but the scripts can't verify a fix in a repo laid out this way.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2