Skip to content

fix(server): 404 + no-store for missing static files instead of the SSR HTML shell - #4071

Open
ety001 wants to merge 1 commit into
masterfrom
fix/static-missing-404-no-store
Open

ety001 wants to merge 1 commit into
masterfrom
fix/static-missing-404-no-store

Conversation

@ety001

@ety001 ety001 commented Nov 4, 2026

Copy link
Copy Markdown
Member

Summary

During today's kernel-patch reboot of the production condenser, requests for hashed bundles under /assets/ fell through the koa-static-cache mount to the SSR catch-all, which rendered the 200 HTML shell under the .js URL. CDN caches that key by URL pattern stored that HTML against the asset URL (the deployed build's shell carried Cache-Control: private, max-age=691200; master since carries private, no-store), and page loads then broke with a script-parse error until those entries expire - up to 8 days.

Observed live on 2026-11-04 (HKG edge):

GET /assets/app.<hash>.js        -> cf-cache-status: HIT, content-type: text/html, age ~2900s
GET /assets/app.<hash>.js?bypass -> 200 application/javascript, 13.5 MB   (origin healthy)

Fix

A guard middleware registered right after the static mounts. Any request that reaches it under one of the statically served prefixes (/assets, /images, /javascripts, /favicons) was not served by those mounts, i.e. the file is missing - it now gets 404 + Cache-Control: no-store so neither the CDN nor the browser pins the failure.

Scope notes:

  • /static and /.well-known are mounted later in the chain and their contents ship with the app; guarding them from the top would 404 files that exist.
  • The dev-mode /assets webpack proxy never falls through, so development behavior is unchanged.
  • Only responses that today are a bogus 200 HTML page change; real asset serving (incl. the public, max-age=86400000 headers) is untouched.
  • On current master the HTML shell already carries private, no-store for most pages, but the 200-for-a-.js-URL behavior itself remains wrong and is still cacheable under aggressive CDN configs (cache-by-URL-pattern / TTL overrides).
  • During a rolling deploy, missing-asset requests now return an uncached 404, so clients recover by simply reloading once the deploy completes - no more multi-day poisoned cache windows.

Ops note (deploy)

Redeploying this does not clear the already-poisoned CDN entries; a purge of the affected /assets/* URLs is still required once, right after rollout.

Test Plan

  • jest src/server/__tests__ - 17 passed (new staticAssetPath.test.js + existing redirect.test.js)
  • eslint clean on the two new files. server.js carries 43 pre-existing lint errors on master; this PR adds no new class of error (the guard uses the same function*(next) formatting as the sibling middleware, which is what prettier/lint-staged enforces).

…hell

When a hashed bundle under /assets/ is missing (deploy replaced dist/,
container restarted on a different build), the request fell through
koa-static-cache to the SSR catch-all and got the 200 HTML shell under
the .js URL. URL-keyed CDN caches stored that HTML against the asset
URL - with the long max-age the deployed shell carried - and page
loads then broke on script-parse errors until the cache entries
expired (up to 8 days).

A guard middleware registered right after the static mounts now
answers 404 + Cache-Control: no-store for any request under one of
the statically served prefixes (/assets, /images, /javascripts,
/favicons) that the mounts did not serve. /static and /.well-known
are mounted later and ship with the app, so they are untouched; the
dev-mode /assets proxy never falls through, so development behavior
is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant