mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-15 23:43:06 +03:00
🛟 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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
20
api/server/utils/fallback.js
Normal file
20
api/server/utils/fallback.js
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
60
client/sw/heal.js
Normal 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());
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user