🛟 fix: Auto-Recover from Stale Service Worker Assets After Deploys (#13686)

* 🛟 fix: Auto-Recover from Stale Service Worker Assets After Deploys

- 404 missing static assets in the SPA fallback instead of serving index.html
- inline recovery script unregisters stale SWs and reloads once on chunk failure
- route vite:preloadError into the same recovery path for stale lazy chunks

* 🛟 fix: Address Review — SW-Side Recovery, Scoped Unregister, Shared Fallback

- importScripts'd sw-heal.js pings window clients on activation and reloads
  ones that can't pong: stale pages carry no recovery code of their own
- scope SW unregistration to the deployment base for subpath installs
- preventDefault vite:preloadError only when a recovery reload was initiated
- extract createSpaFallback and apply the asset 404 guard to experimental.js
This commit is contained in:
Danny Avila
2026-06-11 11:57:06 -04:00
committed by GitHub
parent e0f715bd24
commit 788cc5ac07
8 changed files with 194 additions and 3 deletions

View File

@@ -40,6 +40,7 @@ const {
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const createSpaFallback = require('./utils/fallback');
const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache');
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
@@ -432,7 +433,7 @@ if (cluster.isMaster) {
app.use('/api', apiNotFound);
/** SPA fallback - serve index.html for all unmatched routes */
app.use(sendIndexHtml);
app.use(createSpaFallback(sendIndexHtml));
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
app.use(ErrorController);

View File

@@ -45,6 +45,7 @@ const { checkMigrations } = require('./services/start/migration');
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const createSpaFallback = require('./utils/fallback');
const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
@@ -279,7 +280,7 @@ const startServer = async () => {
app.use('/api', apiNotFound);
/** SPA fallback - serve index.html for all unmatched routes */
app.use(sendIndexHtml);
app.use(createSpaFallback(sendIndexHtml));
/** Record trace errors before the final error controller. */
if (telemetry.enabled) {

View File

@@ -0,0 +1,20 @@
/** Static asset extensions that must 404 when missing — serving the SPA's
* index.html for them breaks strict MIME checks and poisons SW/browser caches. */
const STATIC_ASSET_EXT =
/\.(?:js|mjs|css|map|json|wasm|webmanifest|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|otf|eot)$/i;
/**
* Creates the SPA fallback middleware: serves index.html for unmatched
* routes while returning 404 for missing static assets.
* @param {(req: import('express').Request, res: import('express').Response) => void} sendIndexHtml
*/
function createSpaFallback(sendIndexHtml) {
return (req, res) => {
if (STATIC_ASSET_EXT.test(req.path)) {
return res.status(404).end();
}
return sendIndexHtml(req, res);
};
}
module.exports = createSpaFallback;

View File

@@ -38,7 +38,8 @@ function staticCache(staticPath, options = {}) {
fileName === 'index.html' ||
fileName.endsWith('.webmanifest') ||
fileName === 'manifest.json' ||
fileName === 'sw.js'
fileName === 'sw.js' ||
fileName === 'sw-heal.js'
) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
} else {

View File

@@ -48,6 +48,91 @@
`;
document.head.appendChild(loadingContainerStyle);
</script>
<script>
(function () {
var KEY = 'lc-asset-recovery-at';
function shouldRecover() {
try {
var last = Number(sessionStorage.getItem(KEY)) || 0;
if (Date.now() - last < 60000) {
return false;
}
sessionStorage.setItem(KEY, String(Date.now()));
return true;
} catch (e) {
return false;
}
}
/** Recovers from stale builds (e.g. an outdated service worker serving old
* hashed chunks after a deploy) by unregistering workers and reloading once.
* Returns true when a recovery reload was initiated. */
window.__lcRecoverStaleAssets = function () {
if (!shouldRecover()) {
return false;
}
var reload = function () {
window.location.reload();
};
if (navigator.serviceWorker) {
var scopeBase = new URL('./', document.baseURI || window.location.href).href;
navigator.serviceWorker
.getRegistrations()
.then(function (registrations) {
return Promise.all(
registrations
.filter(function (registration) {
return registration.scope.indexOf(scopeBase) === 0;
})
.map(function (registration) {
return registration.unregister();
}),
);
})
.then(reload, reload);
} else {
reload();
}
return true;
};
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', function (event) {
if (!event.data || event.data.type !== 'LC_SW_PING') {
return;
}
var source = event.source || navigator.serviceWorker.controller;
if (source) {
source.postMessage({ type: 'LC_SW_PONG' });
}
});
}
window.addEventListener(
'error',
function (event) {
var el = event.target;
if (!el || !el.tagName) {
return;
}
var failedScript = el.tagName === 'SCRIPT' && el.src;
var failedPreload =
el.tagName === 'LINK' && /preload/.test(el.rel || '') && /\.js$/.test(el.href || '');
if (failedScript || failedPreload) {
window.__lcRecoverStaleAssets();
}
},
true,
);
window.addEventListener('unhandledrejection', function (event) {
var message = event.reason && event.reason.message;
if (
typeof message === 'string' &&
(message.indexOf('dynamically imported module') !== -1 ||
message.indexOf('Importing a module script failed') !== -1)
) {
window.__lcRecoverStaleAssets();
}
});
})();
</script>
<script defer type="module" src="/src/main.jsx"></script>
</head>
<body>

View File

@@ -9,6 +9,12 @@ import { ApiErrorBoundaryProvider } from './hooks/ApiErrorBoundaryContext';
import 'katex/dist/katex.min.css';
import 'katex/dist/contrib/copy-tex.js';
window.addEventListener('vite:preloadError', (event) => {
if (window.__lcRecoverStaleAssets?.()) {
event.preventDefault();
}
});
const container = document.getElementById('root');
const root = createRoot(container);

60
client/sw/heal.js Normal file
View File

@@ -0,0 +1,60 @@
/* Runs inside the generated service worker via workbox `importScripts`.
* When a new build's worker activates, pages served from a previous build
* can no longer load their hashed chunks (the old precache is purged) and
* carry no recovery code of their own — the worker is the only code path
* stale clients fetch fresh. Ping every window client; reload the ones
* that cannot answer. */
const PING_TYPE = 'LC_SW_PING';
const PONG_TYPE = 'LC_SW_PONG';
const PONG_TIMEOUT_MS = 1500;
const pendingPongs = new Map();
self.addEventListener('message', (event) => {
if (!event.data || event.data.type !== PONG_TYPE || !event.source) {
return;
}
const resolvePong = pendingPongs.get(event.source.id);
if (resolvePong) {
pendingPongs.delete(event.source.id);
resolvePong(true);
}
});
function pingClient(client) {
return new Promise((resolve) => {
pendingPongs.set(client.id, resolve);
setTimeout(() => {
if (pendingPongs.delete(client.id)) {
resolve(false);
}
}, PONG_TIMEOUT_MS);
client.postMessage({ type: PING_TYPE });
});
}
async function reloadUnresponsiveClients() {
await self.clients.claim();
const windowClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
const topLevelClients = windowClients.filter((client) => client.frameType !== 'nested');
await Promise.all(
topLevelClients.map(async (client) => {
const responsive = await pingClient(client);
if (responsive) {
return;
}
try {
await client.navigate(client.url);
} catch {
/* client closed or no longer controllable */
}
}),
);
}
self.addEventListener('activate', (event) => {
event.waitUntil(reloadUnresponsiveClients());
});

View File

@@ -1,4 +1,5 @@
import react from '@vitejs/plugin-react';
import fs from 'fs';
import path from 'path';
import { defineConfig } from 'vite';
import { createRequire } from 'module';
@@ -73,6 +74,17 @@ export default defineConfig(({ command }) => ({
},
},
nodePolyfills(),
{
name: 'emit-sw-heal',
apply: 'build',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'sw-heal.js',
source: fs.readFileSync(path.resolve(__dirname, 'sw/heal.js'), 'utf8'),
});
},
},
VitePWA({
injectRegister: 'auto', // 'auto' | 'manual' | 'disabled'
registerType: 'autoUpdate', // 'prompt' | 'autoUpdate'
@@ -94,6 +106,7 @@ export default defineConfig(({ command }) => ({
'images/**/*',
'**/*.map',
'index.html',
'sw-heal.js',
'assets/rum.*.js',
'assets/locale-*.js',
'assets/query-devtools*.js',
@@ -101,6 +114,10 @@ export default defineConfig(({ command }) => ({
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
/** LibreChat mutates index.html per request for subpath and language support. */
navigateFallback: null,
/** Reloads window clients that cannot answer a ping after activation —
* pages stuck on a previous build's purged precache (stale index.html)
* have no working code of their own to recover with. */
importScripts: ['sw-heal.js'],
runtimeCaching: [
{
urlPattern: ({ url }) => /\/assets\/locale-[^/]+\.js$/.test(url.pathname),