Compare commits
3 Commits
8018060d26
...
6039dc80c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6039dc80c1 | ||
|
|
dd71f6b2ab | ||
|
|
f36f6aeee1 |
@@ -3021,16 +3021,25 @@ async function registerServiceWorker() {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
|
||||
_swReg = reg;
|
||||
|
||||
// A waiting worker only means "update pending" when this page is already
|
||||
// controlled by a previous SW. On a FIRST install (fresh visit, or right
|
||||
// after Settings → Force refresh unregisters everything) the brand-new
|
||||
// worker passes through the `installed`/waiting state for a moment before
|
||||
// activating — with no controller that is not an update, and showing the
|
||||
// banner for it is exactly the "Update available keeps coming back after
|
||||
// Refresh UI" loop.
|
||||
const hasController = () => !!navigator.serviceWorker.controller;
|
||||
|
||||
// If a new SW is already waiting (e.g. user refreshed after an update),
|
||||
// show the dialog right away.
|
||||
if (reg.waiting) { showUpdateBanner(); return; }
|
||||
if (reg.waiting && hasController()) { showUpdateBanner(); return; }
|
||||
|
||||
// Listen for a new SW installing after the page is open.
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const sw = reg.installing;
|
||||
if (!sw) return;
|
||||
sw.addEventListener('statechange', () => {
|
||||
if (sw.state === 'installed' && reg.waiting) showUpdateBanner();
|
||||
if (sw.state === 'installed' && reg.waiting && hasController()) showUpdateBanner();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,8 +36,20 @@
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) {
|
||||
const waiting = reg && reg.waiting;
|
||||
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
||||
let waiting = reg && reg.waiting;
|
||||
|
||||
if (!waiting && reg && typeof reg.update === 'function') {
|
||||
// The banner can be triggered by the server buildTag poll before the
|
||||
// browser has fetched the new sw.js at all. With no waiting worker, a
|
||||
// bare reload would be served the OLD cache-first shell, the new SW
|
||||
// would then install in the background, and the banner would reappear
|
||||
// — the "update available keeps showing" loop. Fetch the update now
|
||||
// and wait (bounded) for it to reach `installed` so a single click
|
||||
// activates the new version.
|
||||
try { await reg.update(); } catch { /* offline / fetch failed — fall through */ }
|
||||
waiting = reg.waiting || (await waitForInstalled(reg, scheduleTimeout, 8000));
|
||||
}
|
||||
|
||||
if (!waiting) {
|
||||
// Nothing to activate (e.g. banner was shown from a broadcast message
|
||||
@@ -60,6 +72,25 @@
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for reg.installing to reach the `installed` state (at which point
|
||||
* it becomes reg.waiting), bounded by a timeout. Resolves with the waiting
|
||||
* worker or null.
|
||||
*/
|
||||
function waitForInstalled(reg, scheduleTimeout, ms) {
|
||||
return new Promise((resolve) => {
|
||||
const sw = reg.installing;
|
||||
if (!sw || typeof sw.addEventListener !== 'function') { resolve(null); return; }
|
||||
let settled = false;
|
||||
const settle = (v) => { if (!settled) { settled = true; resolve(v); } };
|
||||
sw.addEventListener('statechange', () => {
|
||||
if (sw.state === 'installed') settle(reg.waiting || sw);
|
||||
else if (sw.state === 'redundant') settle(null);
|
||||
});
|
||||
if (scheduleTimeout) scheduleTimeout(() => settle(reg.waiting || null), ms);
|
||||
});
|
||||
}
|
||||
|
||||
const SwUpdate = { applyUpdate };
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
|
||||
@@ -93,6 +93,76 @@ test('falls back to a plain reload when there is no waiting worker', async () =>
|
||||
assert.strictEqual(reloadCount, 1);
|
||||
});
|
||||
|
||||
test('with no waiting worker, fetches the SW update and activates the newly installed worker (buildTag-poll path)', async () => {
|
||||
// Simulates: server redeployed (banner shown by the /api/version poll) but
|
||||
// the browser hasn't fetched the new sw.js yet — reg.waiting is null until
|
||||
// reg.update() is called and the new worker finishes installing.
|
||||
const messages = [];
|
||||
const container = fakeContainer();
|
||||
let reloadCount = 0;
|
||||
|
||||
const stateListeners = [];
|
||||
const installing = {
|
||||
state: 'installing',
|
||||
addEventListener: (type, fn) => { if (type === 'statechange') stateListeners.push(fn); },
|
||||
postMessage: (m) => messages.push(m),
|
||||
};
|
||||
const reg = {
|
||||
waiting: null,
|
||||
installing: null,
|
||||
update() {
|
||||
// Browser found a byte-different sw.js → a new worker starts installing.
|
||||
this.installing = installing;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
const done = applyUpdate({
|
||||
reg,
|
||||
container,
|
||||
reload: () => { reloadCount++; },
|
||||
setTimeout: () => {}, // no-op — we drive state transitions manually
|
||||
});
|
||||
|
||||
// Let applyUpdate reach the waitForInstalled stage, then finish the install.
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
installing.state = 'installed';
|
||||
reg.waiting = installing;
|
||||
stateListeners.forEach((fn) => fn());
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
|
||||
assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }], 'skip-waiting sent to the freshly installed worker');
|
||||
assert.strictEqual(reloadCount, 0, 'must not reload before the new SW takes control');
|
||||
|
||||
container.fireControllerChange();
|
||||
await done;
|
||||
assert.strictEqual(reloadCount, 1);
|
||||
});
|
||||
|
||||
test('with no waiting worker and no update found, reloads once after the bounded wait', async () => {
|
||||
const container = fakeContainer();
|
||||
let reloadCount = 0;
|
||||
const timeouts = [];
|
||||
|
||||
const reg = {
|
||||
waiting: null,
|
||||
installing: null,
|
||||
update: () => Promise.resolve(), // update check ran; nothing new
|
||||
};
|
||||
|
||||
const done = applyUpdate({
|
||||
reg,
|
||||
container,
|
||||
reload: () => { reloadCount++; },
|
||||
setTimeout: (fn) => { timeouts.push(fn); },
|
||||
});
|
||||
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
// reg.installing is null → waitForInstalled resolves immediately with null.
|
||||
await done;
|
||||
assert.strictEqual(reloadCount, 1, 'plain reload when the update check finds nothing');
|
||||
});
|
||||
|
||||
test('falls back to a plain reload when there is no registration at all', async () => {
|
||||
const container = fakeContainer();
|
||||
let reloadCount = 0;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { serveStatic } from 'hono/bun';
|
||||
import { logger } from 'hono/logger';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js';
|
||||
|
||||
@@ -51,10 +51,19 @@ const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
|
||||
// ----------------------------------------------------------------------------
|
||||
function computeBuildTag() {
|
||||
try {
|
||||
// Hash EVERY served frontend file (recursively, in sorted order), not a
|
||||
// hand-picked subset — a change to any shell file (e.g. sw-update.js or
|
||||
// opfs.js) must produce a new tag, or clients keep their old SW cache
|
||||
// and never receive the change.
|
||||
const hash = createHash('sha256');
|
||||
for (const file of ['app.js', 'sw.js', 'index.html', 'styles.css']) {
|
||||
hash.update(readFileSync(`./public/${file}`));
|
||||
}
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = `${dir}/${name}`;
|
||||
if (statSync(path).isDirectory()) walk(path);
|
||||
else { hash.update(path); hash.update(readFileSync(path)); }
|
||||
}
|
||||
};
|
||||
walk('./public');
|
||||
return hash.digest('hex').slice(0, 12);
|
||||
} catch {
|
||||
// Frontend files not readable (e.g. unit tests run outside ./public) —
|
||||
|
||||
Reference in New Issue
Block a user