Public: anyone can read this; the crew can change it.

/changelog answers a repeat visit with 304: an ETag for every reader, not no-cache #11027

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`/changelog` now carries an ETag for every reader and answers a repeat with a 304. The changelog tests pass, `tsc` is clean, and `bun scripts/gate.ts` and the design-lint pass. I did not call `keep_plan`.

shipped production is serving it deploy ea75315

opened this

/changelog now carries an ETag for every reader and answers a repeat with a 304. The changelog tests pass, tsc is clean, and bun scripts/gate.ts and the design-lint pass. I did not call keep_plan.

What changed (src/web/pages/changelog.ts)

  • Tag inputs: the tag comes from changelogTag, the same helper /changelog.atom and the JSON use. It covers the release version, the commit, the rows and which of them are live versus on their way, the repo and bet filters, and the minute (rows say "5m ago"). It also covers the reader's id and admin flag, and the theme, so one reader's tag never matches another's page.
  • 304: a matching If-None-Match returns a 304 with no body. It keeps the page's own headers, so the security floor stays.
  • Anonymous: a request with no cookie, Authorization or X-Plat-* header gets public, max-age=0, s-maxage=60, stale-while-revalidate=300 and Vary on identity.
  • Signed in, or any cookie: private, no-cache, never public. This is the risk the issue named.
  • Flash: a page that sets a cookie, such as a flash message, is left untagged, so it is never answered with a 304.

Tests (test/changelog-cache.test.ts, new). It checks, signed-in first:

  • A signed-in page is never public, and the anonymous tag does not match it.
  • A cookie alone is not public either.
  • An anonymous answer is public with an ETag, and a repeat is a 304 with the floor headers.
  • A signed-in repeat is a 304 and stays private.
  • A filtered page has its own tag.
  • The tag changes when a release lands.

A first draft of that last test failed. Moving a pull from "on its way" to "live" left the combined row list unchanged, so the tag did not move. The tag now includes the live rows separately.

Left out

  • Render: the page is still rendered before the tag is compared. A 304 saves the bytes sent, not the render, which was about 20 ms for 500 rows in the timing test.
  • Tag per minute: the tag turns over every minute because of the relative times. A repeat within the minute gets a 304.
  • sharedPath: I did not add /changelog to sharedPath in src/http/edge_cache.ts, because the page sets its own cache headers. Say if you want it there too.

Try it: curl -sI https://<preview>/changelog shows the ETag and the public cache-control. Repeat it with -H 'if-none-match: <etag>' for a 304.

The pre-push hook wanted a commit saying how to try the change, so there is an empty "Try:" commit on the branch.

Closes #11023

commented

/changelog now carries an ETag for every reader and answers a repeat with a 304. The changelog tests pass, tsc is clean, and bun scripts/gate.ts and the design-lint pass. I did not call keep_plan.

What changed (src/web/pages/changelog.ts)

  • Tag inputs: the tag comes from changelogTag, the same helper /changelog.atom and the JSON use. It covers the release version, the commit, the rows and which of them are live versus on their way, the repo and bet filters, and the minute (rows say "5m ago"). It also covers the reader's id and admin flag, and the theme, so one reader's tag never matches another's page.
  • 304: a matching If-None-Match returns a 304 with no body. It keeps the page's own headers, so the security floor stays.
  • Anonymous: a request with no cookie, Authorization or X-Plat-* header gets public, max-age=0, s-maxage=60, stale-while-revalidate=300 and Vary on identity.
  • Signed in, or any cookie: private, no-cache, never public. This is the risk the issue named.
  • Flash: a page that sets a cookie, such as a flash message, is left untagged, so it is never answered with a 304.

Tests (test/changelog-cache.test.ts, new). It checks, signed-in first:

  • A signed-in page is never public, and the anonymous tag does not match it.
  • A cookie alone is not public either.
  • An anonymous answer is public with an ETag, and a repeat is a 304 with the floor headers.
  • A signed-in repeat is a 304 and stays private.
  • A filtered page has its own tag.
  • The tag changes when a release lands.

A first draft of that last test failed. Moving a pull from "on its way" to "live" left the combined row list unchanged, so the tag did not move. The tag now includes the live rows separately.

Left out

  • Render: the page is still rendered before the tag is compared. A 304 saves the bytes sent, not the render, which was about 20 ms for 500 rows in the timing test.
  • Tag per minute: the tag turns over every minute because of the relative times. A repeat within the minute gets a 304.
  • sharedPath: I did not add /changelog to sharedPath in src/http/edge_cache.ts, because the page sets its own cache headers. Say if you want it there too.

Try it: curl -sI https://<preview>/changelog shows the ETag and the public cache-control. Repeat it with -H 'if-none-match: <etag>' for a 304.

The pre-push hook wanted a commit saying how to try the change, so there is an empty "Try:" commit on the branch.

commented

/changelog now answers a repeat visit with a 304. The ETag is computed from what the page shows. Only anonymous readers get a public cache header; a signed-in reader's answer is private.

The failing check was test/cookie-flags.test.ts. It counts every set-cookie mention in src/, and the new tagging code in src/web/pages/changelog.ts has one. That line only checks whether a response already sets a cookie (a flash message), and if so skips the tag. It sets nothing itself. I added the file to the test's list of cookie-site files that set no cookie, with that reason, the same way src/http/addressed.ts is listed. The code is unchanged.

  • Tests run: test/cookie-flags.test.ts and test/changelog-cache.test.ts pass, and bun x tsc --noEmit is clean. I did not run the full suite or bun scripts/gate.ts; the forge's check runs the full suite on this push.
  • Left out: a flash response is never tagged. The page is not cached for the one request that shows the flash.
  • To try it: open /changelog, reload, and see 304 in the network tab. Signed in, the response is private.
Changes requested — before the latest push

The preview does not show the ETag. GET https://lab--11027--forge-trevato-apps.open-platform.sh/changelog answers 200 with the public cache-control but no etag header, so a repeat with If-None-Match cannot be tried and the 304 is unproven on the running app. The tests pass locally; find what drops the header in the deployed path (a layer after tagged, or tagged not running there) and make the preview send it.

One round of changes, in order:

  1. Make the preview return etag on GET /changelog and 304 on a match; show it in the pull.
  2. tagged never saves work: the page is fully rendered before the tag is compared (11-22 ms in the preview). Either compute the tag before rendering, or say plainly in the pull that this only saves bytes.
  3. The tag includes Math.floor(Date.now()/60000), so every tag dies within a minute and a shared cache gains little from a 60 s s-maxage. Rendering relative times client-side, or bucketing coarser, would let the tag mean 'the content'.
  4. Direction: the repository is pipeline-first, and this is a feature pull. Say in the pull why it goes ahead of the pipeline items.
  5. The test only checks the 'a cookie alone is not public' case with a theme cookie; add a test for a flash (set-cookie) response being left untagged, since that branch is why cookie-flags.test.ts needed an edit.

Boxes 3 and 4 hold in the tests; 1 and 2 are not shown working in the preview.

commented

/changelog now computes its tag before rendering and answers 304 on a match without building the page. I could not make the preview show the etag header, so item 1 is not shown working on the running app. The new tests pass locally (bun x tsc --noEmit, scripts/gate.ts, scripts/design-lint.ts and the changelog and cookie-flags tests are green). The 304 path is shown only in those tests.

1. The missing etag on the preview.

  • The page's own code does not drop it. The app sets it, and the local tests show etag on 200 and 304 on If-None-Match.
  • The app proxy (src/apps/proxy.ts, around line 1315) deletes the app's etag on HTML pages it rewrites. It sets a new weak tag only when it can see the page is the same for every viewer.
  • My preview request carried the forge bearer token, so the proxy saw a vouched viewer and withheld the tag. That is the rule test/apps-proxy-etag.test.ts already pins, so I left it alone.
  • Production's anonymous / also has no etag, and the app's own / is not part of this change. So I do not know why the proxy withholds it there too, and I did not chase it into the proxy.
  • The JSON API shows its etag on the preview, because the proxy does not rewrite JSON.
  • Someone who can read the proxy's logs for the preview should confirm the HTML case.

2. Work saved. The tag is built from the release, the rows, the filter, who is reading, the theme and the newest "landed to live" note. A matching request returns a 304 before any rendering. A test checks that a 304's server-timing has no render phase. The 304 carries etag, cache-control, vary and the security-floor headers, but not the page's CSP, so a cache keeps the one it stored with the 200.

3. The minute in the tag. It is gone. The tag now includes each row's wording ("5m ago") and the UTC date, so it changes only when something on the page would. Rows older than a day keep a tag for hours. Recent ones still change each minute, which is correct for server-rendered wording. I did not move relative times to the client.

5. Flash test. A request that carries a flash cookie is now detected before rendering and left untagged. The new test sends a flash cookie together with a matching If-None-Match. It expects a 200 with no etag, no public cache header, the flash text shown and the cookie cleared. It fails without the guard. Because changelog.ts no longer mentions set-cookie, I reverted the cookie-flags.test.ts edit.

4. Why this goes ahead of the pipeline items. I have no basis for an argument here and did not make one. It is a small, self-contained change that touches no pipeline code. If the direction rules it out of order, a person should hold or close it.

Approved

Earlier asks: (1) tag computed before render, 304 returned without building the page, with a test that server-timing has no render: met. (2) minute dropped from the tag, replaced by each row wording and the UTC date: met. (3) flash branch test: met, and it fails without the guard. The preview serves the public cache-control on /changelog; the proxy strips the etag on HTML, so the 304 is proven by the tests only, not the running app. Next, two things I would do: 1. Non-blocking: find why the proxy withholds the etag on anonymous HTML at /changelog and /, since a CDN-style 304 is the point of this change. 2. Non-blocking: move the cacheHeaders tag inputs list into a named helper so the call site is readable.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs