From 788cc5ac0731aa882b0c253dc1a0acff3b84d2d2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 11 Jun 2026 11:57:06 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=9F=20fix:=20Auto-Recover=20from=20Sta?= =?UTF-8?q?le=20Service=20Worker=20Assets=20After=20Deploys=20(#13686)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🛟 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 --- api/server/experimental.js | 3 +- api/server/index.js | 3 +- api/server/utils/fallback.js | 20 ++++++++ api/server/utils/staticCache.js | 3 +- client/index.html | 85 +++++++++++++++++++++++++++++++++ client/src/main.jsx | 6 +++ client/sw/heal.js | 60 +++++++++++++++++++++++ client/vite.config.ts | 17 +++++++ 8 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 api/server/utils/fallback.js create mode 100644 client/sw/heal.js diff --git a/api/server/experimental.js b/api/server/experimental.js index c5584c734f..ac289615a3 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -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); diff --git a/api/server/index.js b/api/server/index.js index a1161fd221..501946a413 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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) { diff --git a/api/server/utils/fallback.js b/api/server/utils/fallback.js new file mode 100644 index 0000000000..3e067ab9ca --- /dev/null +++ b/api/server/utils/fallback.js @@ -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; diff --git a/api/server/utils/staticCache.js b/api/server/utils/staticCache.js index 3f9d347bb0..a16830a56c 100644 --- a/api/server/utils/staticCache.js +++ b/api/server/utils/staticCache.js @@ -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 { diff --git a/client/index.html b/client/index.html index c94c3981b4..d302fb250a 100644 --- a/client/index.html +++ b/client/index.html @@ -48,6 +48,91 @@ `; document.head.appendChild(loadingContainerStyle); + diff --git a/client/src/main.jsx b/client/src/main.jsx index 983c3b8cf2..f0f196e720 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -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); diff --git a/client/sw/heal.js b/client/sw/heal.js new file mode 100644 index 0000000000..40c79368a3 --- /dev/null +++ b/client/sw/heal.js @@ -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()); +}); diff --git a/client/vite.config.ts b/client/vite.config.ts index e0559807af..bf6fb53ff3 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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),