Repository navigation
feat(plugin): render.raw.assumeShared — the raw cache is storing nothing, because the origin calls a shared document personal (plugin v0.81.0, console v0.15.0) - #193
Conversation
…alls personal, where the CDN already shares it; v0.81.0 The raw cache refuses any response carrying `Set-Cookie` (`has-cookie`) or `Cache-Control: private` (`no-store`). Both are heuristics for one question: is this response the same for every crawler? Both can be false. Where a CDN in front already serves ONE cached copy of a route's documents to every visitor, with none of the session cookies in its cache key, those headers are session bootstrap and say nothing about the body. Measured on a live deployment an hour after the feature went live: `has-cookie` 13,106, `not-200` 94, **`stored` 0**, against 33,572 misses on the enabled route. Sampling that route's origin responses: `Set-Cookie` on 8 of 8 and `private` on 4 of 8 — so the cookie check alone refuses everything, and removing only it would still leave half refused as `no-store`. Two cold visitors fetching the same URL differ ONLY inside one bot-manager telemetry block (a counter, a request id, a signed per-request token); product links, prices and title are byte-identical. `assumeShared` (default false, so nothing changes for anyone) suppresses exactly those two sharedness heuristics. A literal `no-store` is still refused — that is the origin saying do not keep this at all, a different statement from "not for shared caches" — as are not-200, staging, content-type, oversize and the rest. The alarm survives the override. `has-cookie` climbing is documented as the one outcome worth an alert, so a document stored under this setting counts as `stored-unshared` rather than `stored`: still one emit per attempt, with the share the origin calls personal left visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces an assumeShared configuration option to the prerender plugin, allowing users to override default refusal logic for documents marked as private or containing cookies, provided the origin is verified to serve shared content. The changes include updates to the configuration schema, cache refusal logic, and metrics tracking to distinguish between standard stored documents and those stored under the new assumeShared policy. The review feedback suggests using case-insensitive regular expressions with word boundaries for more robust and efficient header parsing in rawCache.js instead of full-string lowercasing.
| export const unsharedHint = (resource) => { | ||
| if (resource?.hadSetCookie) return 'set-cookie'; | ||
| const cacheControl = String(resource?.headers?.['cache-control'] ?? '').toLowerCase(); | ||
| if (cacheControl.includes('private')) return 'private'; | ||
| return null; | ||
| }; |
There was a problem hiding this comment.
Using .includes('private') on a lowercased string can be fragile and introduces redundant lowercasing overhead. Instead, we can use a case-insensitive regular expression with word boundaries (/\bprivate\b/i) to perform a more robust check. This avoids matching partial substrings and eliminates the need to lowercase the entire string when the header is absent.
| export const unsharedHint = (resource) => { | |
| if (resource?.hadSetCookie) return 'set-cookie'; | |
| const cacheControl = String(resource?.headers?.['cache-control'] ?? '').toLowerCase(); | |
| if (cacheControl.includes('private')) return 'private'; | |
| return null; | |
| }; | |
| export const unsharedHint = (resource) => { | |
| if (resource?.hadSetCookie) return 'set-cookie'; | |
| const cacheControl = resource?.headers?.['cache-control']; | |
| if (cacheControl && /\bprivate\b/i.test(cacheControl)) return 'private'; | |
| return null; | |
| }; |
There was a problem hiding this comment.
Taken, and the reasoning is right even though the suggested fix does not close it — so this ended up stronger than proposed.
/\bprivate\b/i matches inside X-Private-Header and inside x-private-hint exactly as includes does, because - is a word boundary. Verified against all three shapes:
cache-control |
includes('private') |
/\bprivate\b/i |
|---|---|---|
no-cache="X-Private-Header", max-age=600 |
true | true |
x-private-hint=1, max-age=600 |
true | true |
Both directives that matter here may carry a quoted field-name argument — no-cache="X-Private-Header" is legal per RFC 9111 §5.2.2 — so the only thing that separates an argument from a directive is reading the directive names. Fixed in 4e70015 with cacheControlDirectives(): split the list on commas outside quotes, take the name before =, lowercase. Your efficiency point is handled too — an absent or empty header returns an empty set immediately, with no coercion and no lowercasing.
Worth being precise about the severity: over-matching here refuses to store, which is the conservative direction, so this was never a safety hole. It is an accuracy one, and it matters more than it did before this PR — no-store is now the one cache-control refusal that survives assumeShared, so a phantom one silently costs a deployment stores it cannot account for and cannot distinguish from a real instruction.
Test added and verified to fail against the substring implementation and pass against the parser, covering the quoted-argument false positives, PRIVATE/No-Store casing, an array-valued repeated header, and absent/empty values.
There was a problem hiding this comment.
Follow-up, because the fix above was superseded two commits later and this thread should not point at the intermediate version.
cacheControlDirectives() as first written failed open: on any header it could not parse it answered from whatever survived the parse, so it refused less than the substring test it replaced. Measured against origin/main across 1,440 input combinations with the new option off, six shapes went from refused to stored — private;max-age=60, no-store;private, an unbalanced quote, a stray trailing quote, "private", "no-store". The first is an origin plainly declaring the response unshared, on the default path, with no flag set anywhere.
0844fe7 fixes that. The parser now returns null when it cannot parse, and hasCacheControlDirective falls back to the conservative substring test there, so the stated invariant holds: the parser may only ever remove a false positive. ; is accepted as a separator and a quote is stripped from a directive name (neither is legal, both are real, and the charitable reading is the one that keeps refusing). Re-run across the same 1,440 inputs leaves 8 differences, all four distinct values being the intended well-formed fixes your finding was about.
So your finding was right, the mechanism took two attempts, and there is now a test table pinning all six malformed shapes.
| const cacheControl = String(headers['cache-control'] ?? '').toLowerCase(); | ||
| // `no-store` is the origin instructing caches not to keep this response AT ALL, and | ||
| // `assumeShared` does NOT override it: that setting answers "is this response the same for every | ||
| // crawler", which is a different question from "may it be kept". `private` is the explicit form | ||
| // of the sharedness claim, so that one yields. | ||
| // | ||
| // `no-cache` is deliberately NOT refused: it means revalidate-before-use, not do-not-store, and | ||
| // refusing it would exclude most correctly-configured HTML. | ||
| const cacheControl = String(headers['cache-control'] ?? '').toLowerCase(); | ||
| if (cacheControl.includes('private') || cacheControl.includes('no-store')) return 'no-store'; | ||
| if (cacheControl.includes('no-store')) return 'no-store'; | ||
| if (unshared === 'private' && !policy.assumeShared) return 'no-store'; |
There was a problem hiding this comment.
Similarly, we can avoid redundant string coercion and lowercasing of the cache-control header here by reusing the header value and checking it with a case-insensitive regular expression with word boundaries (/\bno-store\b/i). This is more robust and efficient.
| const cacheControl = String(headers['cache-control'] ?? '').toLowerCase(); | |
| // `no-store` is the origin instructing caches not to keep this response AT ALL, and | |
| // `assumeShared` does NOT override it: that setting answers "is this response the same for every | |
| // crawler", which is a different question from "may it be kept". `private` is the explicit form | |
| // of the sharedness claim, so that one yields. | |
| // | |
| // `no-cache` is deliberately NOT refused: it means revalidate-before-use, not do-not-store, and | |
| // refusing it would exclude most correctly-configured HTML. | |
| const cacheControl = String(headers['cache-control'] ?? '').toLowerCase(); | |
| if (cacheControl.includes('private') || cacheControl.includes('no-store')) return 'no-store'; | |
| if (cacheControl.includes('no-store')) return 'no-store'; | |
| if (unshared === 'private' && !policy.assumeShared) return 'no-store'; | |
| const cacheControl = headers['cache-control']; | |
| // no-store is the origin instructing caches not to keep this response AT ALL, and | |
| // assumeShared does NOT override it: that setting answers "is this response the same for every | |
| // crawler", which is a different question from "may it be kept". private is the explicit form | |
| // of the sharedness claim, so that one yields. | |
| // | |
| // no-cache is deliberately NOT refused: it means revalidate-before-use, not do-not-store, and | |
| // refusing it would exclude most correctly-configured HTML. | |
| if (cacheControl && /\bno-store\b/i.test(cacheControl)) return 'no-store'; | |
| if (unshared === 'private' && !policy.assumeShared) return 'no-store'; |
There was a problem hiding this comment.
Same fix covers this one, and this is the line where it actually earns its keep.
no-cache="no-store" is legal and includes('no-store') reads it as a no-store directive. That matters more after this PR than before it: no-store is now the only cache-control refusal that survives assumeShared, so a false positive here is a store the deployment loses with a reason it cannot distinguish from the origin genuinely saying "do not keep this".
4e70015 replaces both checks with cacheControlDirectives(headers['cache-control']).has('no-store'), which parses the directive names once and answers exactly. The early return on an absent header covers the redundant-coercion point you raised.
There was a problem hiding this comment.
Code Review
This pull request introduces a new configuration option assumeShared to the prerender plugin, allowing documents with Set-Cookie or Cache-Control: private to be cached under specific deployment conditions. It also updates metrics to track these as stored-unshared and adds comprehensive unit tests to validate the new behavior. No review comments were provided, and the changes are well-implemented.
… field name is not a directive (review)
Gemini's review flagged `.includes('private')` / `.includes('no-store')` as fragile. It is, and the
suggested `/\bprivate\b/i` does not fix it: `-` is a word boundary, so that regex matches inside
`X-Private-Header` and `x-private-hint` exactly as `includes` does. Verified all three cases.
Both directives may carry a quoted field-name argument — `no-cache="X-Private-Header"` is legal per
RFC 9111 §5.2.2 — so only reading the directive NAMES distinguishes an argument from a directive.
`cacheControlDirectives()` splits the list on commas outside quotes and takes the name before `=`,
lowercased; an absent header returns early with no allocation, answering the review's other point.
Not a safety fix: over-matching refuses to store, which is the conservative direction. It is an
accuracy one, and it matters more than it used to because `no-store` is now the ONE cache-control
refusal that survives `assumeShared` — a phantom one silently costs a deployment stores it cannot
account for and cannot tell from a real instruction.
A repeated header arriving as an array is handled by construction: joining with commas is the list
form this parses. Test verified to fail against the substring implementation and pass against this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…header, and the console read a store as a refusal; console v0.15.0
A pre-merge review of this branch found six things. Two would have shipped real defects.
BLOCKER, and I introduced it in the review fix. Replacing `includes('private')` with a directive
parser swapped over-refusal (conservative for a cache) for UNDER-refusal on any header the parser
could not read. Measured against origin/main across 1,440 input combinations with `assumeShared`
FALSE — the default path this PR claims is untouched — six shapes went from refused to STORED:
`private;max-age=60`, `no-store;private`, an unbalanced quote, a stray trailing quote, `"private"`,
`"no-store"`. The first is an origin plainly declaring the response unshared, and it was about to be
replayed to every crawler with no flag set anywhere.
The parser now states the invariant it has to hold: it may only ever remove a FALSE POSITIVE. It
accepts `;` as a separator and strips a quote from a directive name (neither is legal, both are real
and the charitable reading keeps refusing), and on an unbalanced quote it returns null and
`hasCacheControlDirective` defers to the substring test — so the result is never less refusing than
before the parser existed. Re-run: 8 differences remain across the same 1,440, all four distinct
values being the intended well-formed false positives. Test table added for all six shapes.
CONSOLE, a release-ordering hazard. `refused = total - stored` counted only the literal `stored`
outcome, so `stored-unshared` would have rendered as Stored 0 / Refused 100% with the warning lit,
and listed the outcome as "something this console does not know about" — the inverse of the truth,
at the exact moment the option makes the cache work. The coverage guard cannot catch it: it scans
the third `recordAnalytics` slot and `rawCache` puts the outcome in the fourth.
Three documentation claims were also wrong or overstated, and are corrected rather than softened:
- "THE ALARM SURVIVES" was false on the only deployment this is for. `Set-Cookie` is on 8 of 8
responses there, so `stored-unshared` is pinned at 100% from the first minute and no threshold on
it detects anything. It is a census; the body diff is the detector. Said so in the option text,
the metrics catalog and METRICS.md.
- The verification recipe could not see the personalization axis this origin actually has. Two cold
fetches from one machine share source IP, `accept-language` and `referer`, all of which the origin
fetch forwards, while the cache key is url + device — so the recipe was blind by construction to
geo/locale variance, which is exactly what a retail origin personalizes. It now says to vary the
vantage point, and notes that nothing reads `Vary`.
- A stored `private` is relayed on every raw hit. Not a change (the origin proxy already relays
`cache-control` on a miss), but it means no edge offload on those hits. Documented.
Also: the metrics catalog's detail-slot description claimed "Other series: null" while `raw_cache`
puts its outcome there — the slot the console reads this panel from. Pre-existing; corrected here.
plugin 1180/1180, console 277/277, browser 297/297 (untouched), lint and format clean. Both new
tests verified to fail without their fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed 4e70015 and 0844fe7. The second one matters: a pre-merge review found that the directive parser I added for the first round of feedback failed open on any Same commit teaches the console /gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces the render.raw.assumeShared configuration option, which allows the prerender plugin to store documents even when the origin marks them as personal via Set-Cookie or Cache-Control: private. To support this, a robust Cache-Control directive parser has been implemented to replace unsafe substring matching, and a new stored-unshared metric outcome has been added. The management console UI and tests have been updated to correctly classify these as stored documents rather than refusals, preventing false alarms. There are no review comments provided, so I have no feedback to address.
The raw-document cache is enabled on a live deployment and storing nothing. This adds the one option that lets a deployment override an origin's claim about itself, on evidence, and teaches the console to read the result.
@harperfast/prerenderv0.81.0,@harperfast/prerender-consolev0.15.0.What the metric said
One hour on the enabled route, read straight off
prerender_ops:has-cookienot-200storedagainst 33,572 misses on that route. This is exactly the failure the metric's own documentation predicts — "an enabled route that is filling nothing looks exactly like a disabled one unless the reason is recorded" — and the reason was recorded.
Why the refusal is wrong here
storeRefusalrefuses onhadSetCookie, andhadSetCookieexists only becausesanitizeOriginResponseHeadersalready dropped the header. The cookie can never reach a stored page. The check is protecting against a personalized body, using the cookie as the best available hint.That hint is false where a CDN already serves one cached copy of these documents to every visitor with none of the session cookies in its cache key. Measured, two cold visitors fetching the same listing URL:
<title>Dropping the cookie check alone would have fixed half: sampling 8 responses on that route,
Set-Cookiewas present on 8 of 8 andCache-Control: privateon 4 of 8. Refusal order is not-200 → staging → has-cookie → content-type → no-store (which matchesprivate), so removing only the first leaves theprivatehalf refused. Both are the same claim in two forms, so the option covers both.What it does
render.raw.assumeShared, default false.Set-CookieandCache-Control: private.no-storeis still refused — the origin instructing caches not to keep the response at all is a different statement from "not for shared caches".stored-unshared, notstored, so the assumption stays visible.unsharedHint()is the single source of truth for both the refusal and the outcome name.What that series is, precisely: a census, not an alarm. On an origin that sets a cookie on every response it is pinned at 100% from the first minute and cannot rise, so no threshold on it detects anything. The detector is re-running the body diff. The option text, the metrics catalog and METRICS.md all say this — an earlier draft of this PR claimed the alarm "survives", which was false on the only deployment this is for.
Review — six findings, and two of them were real defects
No outside-model coverage was available on this machine (
agynot installed,codex/geminipresent but unauthenticated), so this was the repo's CI reviewer plus a fresh-context same-family pass. Stating that rather than implying independent coverage.A blocker I introduced mid-review. The CI reviewer flagged
.includes('private')as fragile. It is — but its suggested/\bprivate\b/idoes not fix it, because-is a word boundary, so that regex matches insideX-Private-Headerandx-private-hintexactly asincludesdoes. I replaced it with a directive parser, and that swapped over-refusal (conservative for a cache) for under-refusal on any header it could not read. Measured againstorigin/mainacross 1,440 input combinations withassumeSharedfalse — the default path this PR claims is untouched — six shapes went from refused to stored:cache-controlprivate;max-age=60no-store;privateno-cache="x, no-storemax-age=600, no-store""private""no-store"The first is an origin plainly declaring the response unshared, about to be replayed to every crawler with no flag set anywhere. The parser now states the invariant it must hold — it may only ever remove a false positive — accepts
;and a quoted directive name, and on an unbalanced quote defers to the substring test. Re-run across the same 1,440 inputs: 8 differences remain, all four distinct values being the deliberate well-formed false-positive fixes (no-cache="X-Private-Header",x-private-hint=1,privately-held,no-cache="no-store").So the honest form of "no behaviour change by default" is: no deployment's behaviour changes except that four well-formed
cache-controlshapes that were wrongly refused are now stored. That is the accuracy fix, and it is the only default-path change.The console would have reported the feature as 100% broken the moment it worked.
refused = total - storedcounted only the literalstoredoutcome, sostored-unsharedrendered as Stored 0 / Refused 100% with the warning lit, and appeared in the refusal table as "an outcome this console does not know about". The coverage guard cannot catch this class: it scans the thirdrecordAnalyticsslot andrawCacheputs the outcome in the fourth. Fixed, with a test that fails without it. This is why the console bump ships in the same PR — settingassumeSharedanywhere before console 0.15.0 makes the panel lie.Two more claims corrected rather than softened. The verification recipe could not see the personalization axis this origin actually has: two cold fetches from one machine share source IP,
accept-languageandreferer, all of which the origin fetch forwards, while the cache key is url + device — blind by construction to geo and locale variance, which is what a retail origin personalizes. It now says to vary the vantage point, and notes that nothing readsVary. And a storedprivateis relayed on every raw hit; not a change, since the origin proxy already relayscache-controlon a miss, but it means no edge offload on those hits.One pre-existing doc bug fixed in passing: the metrics catalog's detail-slot description said "Other series: null" while
raw_cacheputs its outcome there — the slot the console reads this panel from.Verification
plugin 1180/1180, console 277/277, browser 297/297 (untouched by this branch), lint and
format:checkclean. Both new regression tests verified to fail against their unfixed source. This repo has no PR CI, so that is local verification rather than a gate.Versions:
mainis plugin 0.80.0 / console 0.14.0; the only other open PR reserving a number is #178 (0.75.0). This takes plugin 0.81.0 and console 0.15.0.Deploy note
Loading this needs one worker recycle per node — the drained rolling
restart_service http_workersrecipe, not a container restart. The config flag live-reloads but is inert until the code is present. Consumer config: HarperFast/kohls-pr#118.🤖 Generated with Claude Code