Skip to content

fix(start): 404 missing assets + immutable cache headers — stop post-deploy cache poisoning#351

Open
ayushjhanwar-png wants to merge 1 commit into
mainfrom
fix/assets-404-cache-poisoning
Open

fix(start): 404 missing assets + immutable cache headers — stop post-deploy cache poisoning#351
ayushjhanwar-png wants to merge 1 commit into
mainfrom
fix/assets-404-cache-poisoning

Conversation

@ayushjhanwar-png

@ayushjhanwar-png ayushjhanwar-png commented Jul 3, 2026

Copy link
Copy Markdown

Problem

After (almost) every dashboard deploy, users hit:

Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html"

and the dashboard white-screens until a manual Cloudflare cache purge — and even then, affected browsers keep failing for up to 4 more hours from their local cache.

Root cause

  1. Missing /assets/*.js files don't 404. Nitro only auto-404s missing assets for public-asset dirs mounted at a non-root baseURL; the TanStack plugin mounts the client build at /, so misses fall through to the SSR router → 307 /login (unauthenticated) or a 200 HTML page (authenticated). Reproducible on prod: curl -I https://openpanel.dashverse.ai/assets/nonexistent.js307 → /login.
  2. Origin sends no cache-control on assets (nitropack 2.12's static handler never emits it), so Cloudflare applies its 4h default to .js URLs.
  3. Rolling deploys briefly run old+new pods; each image only contains its own build's hashed chunks. A request for a new chunk landing on the old pod returns the HTML fallback, which Cloudflare then caches under the .js URL — poisoning the edge (and every visiting browser) for 4 hours. Incognito doesn't help because the poison is in the shared edge cache.

Fix

  • apps/start/src/server/assets-404.ts — route handler for /assets/**; Nitro's static middleware runs first, so this only fires for files that don't exist and returns 404 + cache-control: no-store. No cache layer can ever store a wrong answer for a chunk URL again.
  • vite.config.ts routeRules — hashed assets: public, max-age=31536000, immutable (content-addressed filenames, safe forever); everything else: no-cache.
  • router.tsx — on vite:preloadError (chunk failed to load), reload once (sessionStorage guard against loops) so tabs opened before a deploy self-heal instead of white-screening.
  • packages/sdks/{astro,nextjs}@openpanel/web was pinned workspace:1.1.0-local but the package is now 1.3.0-local, breaking root pnpm install on main; switched to workspace:*.

Verified locally against the production build (NITRO=1 pnpm build + node .output/server/index.mjs)

Request Before (prod) After
Missing /assets/x.js 307 → /login (HTML) 404, no-store
Real /assets/main-*.js no cache-control max-age=31536000, immutable
HTML page no cache-control no-cache

Post-merge ops (one-time)

  1. Enable Cache Deception Armor on the Cloudflare zone (refuses to cache HTML under .js URLs — independent safety net).
  2. One final Cloudflare purge after this deploys, to flush entries stored under the old 4h rule.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved recovery from failed page loads after deploys, helping the app reload automatically when a chunk can’t be found.
    • Added clearer handling for missing asset requests so broken asset URLs return a proper 404 instead of rendering an incorrect page.
  • Chores

    • Updated internal package dependencies to use workspace-wide references for better consistency.

…deploy cache poisoning

Missing /assets/* files fell through to the SSR router (307 to /login),
so during every deploy Cloudflare and browsers cached HTML under .js
chunk URLs for 4h, breaking module loading for all users until a manual
cache purge.

- assets-404.ts: unmatched /assets/** now returns 404 with no-store
  (nitro's static handler runs first, so real files are unaffected)
- routeRules: hashed assets get max-age=31536000 immutable; everything
  else no-cache (origin previously sent no cache-control at all,
  Cloudflare defaulted to 4h)
- router.tsx: reload once on vite:preloadError so tabs opened before a
  deploy recover from removed old chunks instead of white-screening
- sdks: @openpanel/web workspace pin 1.1.0-local no longer matches
  1.3.0-local, breaking root pnpm install; use workspace:*
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds client-side recovery for stale Vite preload chunk errors via page reload, a server-side 404 handler with no-store caching for missing asset requests, corresponding Nitro/Vite route caching rules, a new h3 devDependency, and workspace wildcard updates for @openpanel/web in the Astro and Next.js SDK packages.

Changes

Deploy Chunk Reload and Asset Caching

Layer / File(s) Summary
Client-side chunk reload recovery
apps/start/src/router.tsx
Registers a vite:preloadError listener that uses sessionStorage to reload the page once per page-load to recover from stale hashed chunks.
Server asset 404 handler and Nitro routing
apps/start/src/server/assets-404.ts, apps/start/vite.config.ts, apps/start/package.json
New event handler returns 404 with no-store caching for missing /assets/** requests; Nitro config wires this handler and adds route rules for immutable asset caching vs. no-cache elsewhere; adds h3 devDependency.

SDK Workspace Dependency Updates

Layer / File(s) Summary
Wildcard workspace dependency updates
packages/sdks/astro/package.json, packages/sdks/nextjs/package.json
Changes @openpanel/web dependency from pinned workspace:1.1.0-local to workspace:* in both SDK packages.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant ViteRouter as Router
  participant Server as Nitro Server
  participant CDN as Edge Cache

  Browser->>ViteRouter: Import stale hashed chunk
  ViteRouter->>Browser: vite:preloadError fired
  ViteRouter->>Browser: sessionStorage check 'op:chunk-reload'
  ViteRouter->>Browser: preventDefault() + reload()
  Browser->>Server: Request /assets/*.js
  Server->>Server: assets-404 handler checks file existence
  alt asset missing
    Server-->>Browser: 404, cache-control no-store
  else asset exists
    Server->>CDN: serve with immutable caching
    CDN-->>Browser: cached asset response
  end
Loading

Compact metadata

  • Related issues: Not specified in the provided changes.
  • Related PRs: Not specified in the provided changes.
  • Suggested labels: deploy-fix, caching, sdk-dependencies
  • Suggested reviewers: Not specified in the provided changes.

Poem
A rabbit hops through hashed-chunk night,
reloads the page to set things right.
Stale assets vanish, four-oh-four,
fresh caches guard the client's door.
Wildcards bloom in SDK trees—
hop, hop, hooray, deployed with ease!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main fix: missing asset 404 handling and cache-header changes to prevent post-deploy cache issues.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/assets-404-cache-poisoning

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/start/src/router.tsx`:
- Around line 8-23: The reload guard in the window-level vite:preloadError
listener can permanently block recovery for the same URL because the
sessionStorage marker is never cleared. Update the router.tsx logic so the guard
is reset after a successful load/navigation, using the existing key and the
current location href in the same listener setup. Keep the loop protection, but
clear or expire the stored marker once the page has loaded successfully so a
later chunk failure can still trigger window.location.reload again.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d17acfe-09f0-4b74-9517-31e6f0bd8abe

📥 Commits

Reviewing files that changed from the base of the PR and between ab32860 and 1e6184f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (6)
  • apps/start/package.json
  • apps/start/src/router.tsx
  • apps/start/src/server/assets-404.ts
  • apps/start/vite.config.ts
  • packages/sdks/astro/package.json
  • packages/sdks/nextjs/package.json

Comment thread apps/start/src/router.tsx
Comment on lines +8 to +23
if (typeof window !== 'undefined') {
// After a deploy the previous build's hashed chunks no longer exist on the
// server, so tabs opened before the deploy fail dynamic imports on their
// next navigation. Reload once to pick up the new build; the sessionStorage
// guard prevents a reload loop if the chunk is still failing afterwards.
window.addEventListener('vite:preloadError', (event) => {
const key = 'op:chunk-reload';
if (sessionStorage.getItem(key) === window.location.href) {
return;
}
sessionStorage.setItem(key, window.location.href);
event.preventDefault();
window.location.reload();
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reload guard can permanently disable recovery for a given URL within a session.

The sessionStorage key is set once and never cleared, so if vite:preloadError fires again for the same URL later in the same tab session (e.g. a second deploy while the tab stays open), the guard silently skips the reload and the user stays stuck on stale chunks.

♻️ Suggested fix: clear the guard once the page has loaded successfully
   window.addEventListener('vite:preloadError', (event) => {
     const key = 'op:chunk-reload';
     if (sessionStorage.getItem(key) === window.location.href) {
       return;
     }
     sessionStorage.setItem(key, window.location.href);
     event.preventDefault();
     window.location.reload();
   });
+  window.addEventListener('load', () => {
+    sessionStorage.removeItem('op:chunk-reload');
+  });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (typeof window !== 'undefined') {
// After a deploy the previous build's hashed chunks no longer exist on the
// server, so tabs opened before the deploy fail dynamic imports on their
// next navigation. Reload once to pick up the new build; the sessionStorage
// guard prevents a reload loop if the chunk is still failing afterwards.
window.addEventListener('vite:preloadError', (event) => {
const key = 'op:chunk-reload';
if (sessionStorage.getItem(key) === window.location.href) {
return;
}
sessionStorage.setItem(key, window.location.href);
event.preventDefault();
window.location.reload();
});
}
if (typeof window !== 'undefined') {
// After a deploy the previous build's hashed chunks no longer exist on the
// server, so tabs opened before the deploy fail dynamic imports on their
// next navigation. Reload once to pick up the new build; the sessionStorage
// guard prevents a reload loop if the chunk is still failing afterwards.
window.addEventListener('vite:preloadError', (event) => {
const key = 'op:chunk-reload';
if (sessionStorage.getItem(key) === window.location.href) {
return;
}
sessionStorage.setItem(key, window.location.href);
event.preventDefault();
window.location.reload();
});
window.addEventListener('load', () => {
sessionStorage.removeItem('op:chunk-reload');
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/start/src/router.tsx` around lines 8 - 23, The reload guard in the
window-level vite:preloadError listener can permanently block recovery for the
same URL because the sessionStorage marker is never cleared. Update the
router.tsx logic so the guard is reset after a successful load/navigation, using
the existing key and the current location href in the same listener setup. Keep
the loop protection, but clear or expire the stored marker once the page has
loaded successfully so a later chunk failure can still trigger
window.location.reload again.

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