fix: include Immich chunk manifest hash in inject-version

After an Immich upgrade, Svelte chunk filenames rotate but the cached
PWA shell still imports old ones — dynamic imports 500 or 404. Our
auto-update mechanism only caught changes to our injected script, not
upstream changes, so the stuck PWA never self-healed.

Now /api/custom/inject-version returns a hash of
(custom script || Immich chunk manifest fingerprint), so any upgrade
bumps the version, the PWA detects it on next poll, unregisters the
service worker, clears caches, and reloads with fresh chunks.

We extract only the /_app/immutable/... references from the HTML to
avoid hashing server-rendered per-user state (CSRF tokens etc) that
would cause spurious version churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-17 12:56:37 -04:00
parent 4cb479404a
commit af3468e0d5
1 changed files with 40 additions and 7 deletions

View File

@ -13,15 +13,35 @@ const UPLOAD_TIMEOUT = 10 * 60 * 1000;
// Load the custom JS to inject. Version hash lets PWAs detect stale caches.
const customJS = fs.readFileSync(path.join(__dirname, 'live-search.js'), 'utf8');
const INJECT_VERSION = crypto
const CUSTOM_JS_HASH = crypto
.createHash('sha256')
.update(customJS)
.digest('hex')
.slice(0, 12);
const SCRIPT_TAG =
`<script>window.__LS_VERSION=${JSON.stringify(INJECT_VERSION)};</script>` +
`<script>${customJS}</script>`;
console.log(`Injected script version: ${INJECT_VERSION}`);
// `currentVersion` combines our custom script with a fingerprint of Immich's
// served HTML so that any upstream update (chunk-hash rotation, etc.) also
// bumps the version. Stuck PWAs polling /api/custom/inject-version will see
// the change and self-reload. Initialized with just the custom hash until the
// first HTML response lands.
let currentVersion = CUSTOM_JS_HASH;
console.log(`Injected script hash: ${CUSTOM_JS_HASH} (initial version: ${currentVersion})`);
function makeScriptTag(version) {
return (
`<script>window.__LS_VERSION=${JSON.stringify(version)};</script>` +
`<script>${customJS}</script>`
);
}
// Extract just the chunk/manifest references from Immich's HTML (the parts
// that actually change on upgrades). Hashing the full HTML would include
// server-rendered state (CSRF tokens, session user id) that change per-user
// per-request and would cause spurious version churn.
function extractShellFingerprint(html) {
const matches = html.match(/\/_app\/immutable\/[^"'\s]+/g) || [];
return matches.sort().join('|');
}
// Helper: make an internal request to Immich (for small JSON requests only)
function immichRequest(method, apiPath, headers, body) {
@ -57,7 +77,7 @@ const server = http.createServer((req, res) => {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate'
});
res.end(JSON.stringify({ version: INJECT_VERSION }));
res.end(JSON.stringify({ version: currentVersion }));
return;
}
@ -129,7 +149,20 @@ const server = http.createServer((req, res) => {
proxyRes.on('data', chunk => htmlChunks.push(chunk));
proxyRes.on('end', () => {
let html = Buffer.concat(htmlChunks).toString('utf8');
html = html.replace('</body>', SCRIPT_TAG + '</body>');
// Compute a per-response version: custom script hash XOR'd
// with the upstream HTML's chunk/manifest fingerprint. This
// way an Immich upgrade that rotates chunk hashes bumps our
// version and stuck PWAs will self-heal on the next poll.
const shellHash = crypto
.createHash('sha256')
.update(customJS)
.update(extractShellFingerprint(html))
.digest('hex')
.slice(0, 12);
currentVersion = shellHash;
html = html.replace('</body>', makeScriptTag(shellHash) + '</body>');
const resHeaders = { ...proxyRes.headers };
delete resHeaders['content-length'];