Repository navigation
fix(ppr): shell serve-gate hardening — buildVersion gate, payload integrity check, tail-failure recapture (#684 SSR-03/04) - #687
Conversation
commit: |
…e-commit payload integrity check, tail-failure recapture (#684 SSR-03/SSR-04) A persistent shell store (KV/runtime-cache) survives deploys, but isValidShellHit gated only on reactVersion — an app redeploy with the same React left a stale build's prelude+postponed live under the same key, and resuming it against the new build's Flight tree-mismatches AFTER the 200 + full static prelude flushed: a visually complete page that never hydrates, re-served on every request until TTL (no eviction path, no recapture on failure). Corrupt stored payloads hit the same post-commit failure mode. Three coordinated guards, all degrading to a working axis-1 MISS whose background recapture overwrites the bad entry (self-healing): - ShellCacheEntry.buildVersion, stamped at capture from the new required ShellCaptureDescriptor.buildVersion (= HandlerContext.version, the @rangojs/router:version build stamp); isValidShellHit(entry, buildVersion) now gates on reactVersion AND buildVersion; pre-field entries miss. Carried through the CF (bv) and Vercel (bv) shell envelopes — dropping it in either direction would make every persisted HIT an infinite MISS. - hasIntactShellPayload: pre-commit base64/JSON integrity check; a corrupt entry reports via reportCacheError and serves MISS instead of a dead page. - serveShellHit's stream catch schedules a healing recapture: a parseable-but-mismatched postponed blob or hard render error above the holes throws post-commit; without this the same entry re-fails every request until it ages out. Red-before-green: 8 tests fail on main (5 serve-gate + 1 capture stamp + 2 KV envelope round-trips), all green with the fix. Dev + production e2e via the new /shell-cache/__corrupt fault injector in test-app.
ac36fc9 to
475c6a0
Compare
|
Added to this PR after the fact: |
…ty-and-build-version # Conflicts: # packages/rangojs-router/docs/internal/feature-file-map.md
Closes out the SSR-03 and SSR-04 investigate items of #684 (verdicts + full trace in the issue comment).
Problem statement
A PPR shell entry stores React's
postponedstate, which encodes hole positions against one exact tree. The serve gate validated onlyentry.reactVersion === React.version— so with a persistent shared store (KV / Vercel runtime-cache, which survive deploys), an app redeploy that keeps the same React version left a stale build's shell live under the same key. Resuming it against the new build's Flight tree-mismatches insideresume()after the 200 + full static prelude already flushed: the visitor gets a visually complete page that never hydrates, and because nothing evicts the entry and a failed tail scheduled no recapture, every subsequent request re-serves the same broken page until TTL (default 300s; consumer-set can be hours). A corrupt stored payload (store-layer fault: truncatedpostponed, undecodableprelude) hit the same post-commit failure mode.VercelCacheStoreis the sharpest exposure: it has no default version prefix, so naivegetCache()wiring crosses deploys silently; CFCacheStore'sv/{version}/keyspace rotation mitigates by default but is disabled by a pinnedoptions.version. TheShellCacheEntrydoc even promised "never mixed across a React upgrade or a build change" — documented, unenforced.Before vs. after
Before (deploy new app code, same React, warm persistent store):
After:
What changed (three coordinated guards, all self-healing to MISS + recapture)
buildVersionvalidity gate.ShellCacheEntry.buildVersionis stamped at capture from the new requiredShellCaptureDescriptor.buildVersion(=HandlerContext.version, the@rangojs/router:versionbuild stamp — new hex value every build, bumped on dev RSC edits).isValidShellHit(entry, buildVersion)now gates on reactVersion AND buildVersion; an entry without the field (stored pre-field) is a miss (pre-release, no compat shim). Gate-not-key by design: a mismatch recaptures over the same key (the established reactVersion doctrine) instead of orphaning dead KV entries per deploy.hasIntactShellPayloadpre-commit integrity check (rsc/shell-serve.ts): try-decode the base64 prelude + try-parse the postponed JSON at the HIT gate, before anything commits. Corrupt entries report viareportCacheErrorand degrade to MISS. Cost: one duplicate decode/parse per HIT, sub-ms.serveShellHitstream catch): the pre-commit gates cannot catch a parseable-but-mismatched blob or a hard render error above the holes — those still throw post-commit. The catch now schedulesscheduleShellCaptureso the entry heals on the next request instead of persisting its full TTL. A client disconnect landing in the same catch schedules a spurious-but-idempotent recapture, bounded by the existing stampede guard + backoff.Store envelopes:
bvadded toKVShellEnvelope(CF) andVercelShellEnvelope(+ validator) — dropping it in either direction would turn every persisted HIT into an infinite MISS, so both round-trips are pinned by tests.Semantics table
reportCacheError+ recapturereportCacheError+ recaptureConsumer example
A Vercel deployment with the documented store wiring is now safe across deploys even without a version namespace:
Code-change summary
src/cache/types.ts—ShellCacheEntry.buildVersion?+ doc now states both enforced gates.src/rsc/shell-serve.ts—isValidShellHit(entry, buildVersion), newhasIntactShellPayload(entry).src/rsc/rsc-rendering.ts— gate call passesctx.version; corrupt-entry branch reports + falls to the existing MISS path; descriptor carriesbuildVersion;serveShellHittakes the descriptor and schedules recapture in its stream catch.src/rsc/shell-capture.ts—ShellCaptureDescriptor.buildVersion(required) stamped into the stored entry.src/cache/cf/cf-cache-store.ts,src/cache/vercel/vercel-cache-store.ts—bvenvelope field, both directions (+ Vercel validator)./shell-cache/__corrupttest-only fault injector (e2e/test-app/src/urls/shell-cache.tsx) +router.named-routes.gen.ts(one line).ppr-shell-resume.md(entry sketch, serve plumbing, upgrade matrix + new redeploy row),feature-map.md,feature-file-map.md.Location rationale: the gate stays in
shell-serve.ts(the serve-plumbing owner); stamping rides the descriptor becausecaptureAndStoreShelldeliberately has noHandlerContextand the descriptor is the existing capture-policy carrier.Test plan
git stashof the src fix): 8 failed — 5 serve-gate (rsc-rendering-shell-ppr.test.ts: buildVersion mismatch, missing buildVersion, corrupt postponed, corrupt prelude, tail-failure recapture), 1 capture stamp (shell-capture.test.ts), 2 KV envelope round-trips (cf-cache-store-shell.test.ts). With the fix: 74 passed in the same three files.bvKV round-trip both stores.shell-cache.test.ts, both existing describes): corrupt-postponed and stale-buildVersion entries each degrade to a working MISS (page HTML intact) and heal back to HIT via recapture —2 passedin--project=dev,2 passedin--project=production.pnpm run typecheck— 16/16 tasks clean.pnpm run test:unit:all— router 4987 passed | 6 todo; all consumer suites green.pnpm run lint0/0;pnpm run formatclean on all tracked files;pnpm check:e2e-bucketingOK.*.gen.ts: onlytest-app/src/router.named-routes.gen.ts(+1 line, the fixture route); no other app's routes changed.Notes
__corruptfixture mirrorsbuildShellKeyfor single-search-param URLs; it is a test-app-only endpoint, not shipped surface.