From 825d8af5bc41379f434237930bfa59205a38800b Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 31 May 2026 14:34:10 +0330 Subject: [PATCH 01/32] apply Bswup improvements #12408 --- .../Bit.Bswup.Demo/wwwroot/service-worker.js | 3 + src/Bswup/Bit.Bswup/BswupProgress.razor | 6 + .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 34 +++ src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 209 +++++++++++++- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 260 ++++++++++++++++-- .../Bit.Bswup/Styles/bit-bswup.progress.css | 39 +++ src/Bswup/README.md | 57 +++- 7 files changed, 565 insertions(+), 43 deletions(-) diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js index cef24b004d..baf7ae75e5 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js @@ -9,6 +9,9 @@ self.externalAssets = [ "url": "not-found/script.file.js" } ]; +// 'lax' opts into best-effort installs: the demo intentionally references a non-existent +// asset to exercise the progress / error reporting UI. Under the default 'strict' setting +// that would abort the install. See README.md > errorTolerance. self.errorTolerance = 'lax'; self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index 3d6344e5a2..eb02ea7ec0 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -25,6 +25,12 @@

0 %

+ } diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index 3e09a3ec7a..815852f05f 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -22,6 +22,10 @@ const percentEl = document.getElementById('bit-bswup-percent'); const assetsEl = document.getElementById('bit-bswup-assets'); const reloadButton = document.getElementById('bit-bswup-reload'); + const errorEl = document.getElementById('bit-bswup-error'); + const errorMessageEl = document.getElementById('bit-bswup-error-message'); + const errorDetailsEl = document.getElementById('bit-bswup-error-details'); + const errorRetryButton = document.getElementById('bit-bswup-error-retry'); const appElOriginalDisplay = appEl && appEl.style.display; @@ -100,6 +104,36 @@ reloadButton && (reloadButton.onclick = data.reload); } return showLogs_ ? console.log('new update is ready.') : undefined; + + case BswupMessage.error: + // Reveal the install panel even if no progress event landed first + // (manifest validation failures fire before any progress message). + hideApp_ && appEl && (appEl.style.display = 'none'); + bswupEl && (bswupEl.style.display = 'block'); + + if (errorEl) { + errorEl.style.display = 'block'; + if (errorMessageEl) errorMessageEl.textContent = (data && data.message) || 'Service worker install failed.'; + if (errorDetailsEl) { + const reasonText = data && data.reason ? `[${data.reason}] ` : ''; + const urlText = data && data.url ? `\nasset: ${data.url}` : ''; + const hashText = data && data.hash ? `\nhash: ${data.hash}` : ''; + errorDetailsEl.textContent = `${reasonText}${urlText}${hashText}`.trim(); + } + if (errorRetryButton) { + errorRetryButton.style.display = 'inline-block'; + errorRetryButton.onclick = () => { + if (data && typeof data.reload === 'function') { + data.reload(); + } else { + window.location.reload(); + } + }; + } + } + // Always log errors regardless of showLogs - this is actionable info. + console.error('BitBswup install error:', data); + return; } } } diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index f5cf5d359d..3a366d40ca 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -26,6 +26,7 @@ interface Window { disableHashlessAssetsUpdate: any forcePrerender: any enableCacheControl: any + cacheVersion: any mode: any } @@ -43,9 +44,30 @@ diag('ASSETS_URL:', ASSETS_URL); self.importScripts(ASSETS_URL); -const VERSION = self.assetsManifest.version; +const MANIFEST_ERRORS = validateAssetsManifest(self.assetsManifest); +if (MANIFEST_ERRORS.length) { + diag('*** assetsManifest validation failed:', MANIFEST_ERRORS); + sendError({ + reason: 'manifest', + message: 'service-worker-assets.js is missing or malformed: ' + MANIFEST_ERRORS.join('; '), + url: ASSETS_URL, + }); +} + +const VERSION = (self.assetsManifest && self.assetsManifest.version) || '0.0.0-invalid-manifest'; const CACHE_NAME_PREFIX = 'bit-bswup'; -const CACHE_NAME = `${CACHE_NAME_PREFIX} - ${VERSION}`; + +// Cache identity normally tracks Blazor's manifest version (assetsManifest.version), a +// hash over the published assets. cacheVersion lets an app override the value used in the +// cache name: pin a stable string across noisy dev rebuilds (so perturbed asset hashes +// don't needlessly evict the whole cache), or bump it to force a refresh when a meaningful +// change lives outside Blazor's asset manifest. Only the cache *bucket name* is affected; +// the per-asset `?v=` cache-buster and SRI hashes still derive from VERSION, so integrity +// is unchanged. Falls back to the manifest version when unset or not a non-empty string. +const CACHE_VERSION = (typeof self.cacheVersion === 'string' && self.cacheVersion) || VERSION; +const CACHE_NAME = `${CACHE_NAME_PREFIX} - ${CACHE_VERSION}`; + +let integrityFailureCount = 0; switch (self.mode) { case 'NoPrerender': // like adminpanel @@ -82,6 +104,16 @@ switch (self.mode) { break; } +// Default error tolerance when no mode preset applies. 'strict' matches the standard +// Microsoft template / Workbox semantics: any precache failure aborts the install and +// the previous SW keeps serving. Set 'lax' explicitly to opt into best-effort installs +// (e.g. when listing optional externalAssets that may legitimately 404). +self.errorTolerance ||= 'strict'; +if (self.errorTolerance !== 'strict' && self.errorTolerance !== 'lax') { + diag('*** unknown errorTolerance, falling back to strict:', self.errorTolerance); + self.errorTolerance = 'strict'; +} + self.addEventListener('install', e => e.waitUntil(handleInstall(e))); self.addEventListener('activate', e => e.waitUntil(handleActivate(e))); self.addEventListener('fetch', e => e.respondWith(handleFetch(e))); @@ -92,7 +124,17 @@ async function handleInstall(e: any) { sendMessage({ type: 'install', data: { version: VERSION, isPassive: self.isPassive } }); - createAssetsCache(); + if (self.errorTolerance === 'strict') { + // Strict: any required asset that fails to fetch / store must reject the install + // promise so the SW lifecycle treats it as a failed install. Without this, a + // partially-populated cache becomes the new active cache on the next reload. + await createAssetsCache(); + } else { + // Lax: lifecycle proceeds immediately; missing assets are filled lazily by + // handleFetch. This preserves best-effort behavior for callers that explicitly + // opt in via errorTolerance: 'lax'. + createAssetsCache(); + } } async function handleActivate(e: any) { @@ -153,7 +195,11 @@ async function handleFetch(e: any) { if (PROHIBITED_URLS.some(pattern => pattern.test(req.url))) { diagFetch('+++ handleFetch ended - prohibited:', e, req); - return new Response(new Blob(), { status: 405, "statusText": `prohibited URL: ${req.url}` }); + return new Response('This URL is prohibited!', { + status: 403, + statusText: 'Prohibited', + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); } const isServerHandled = SERVER_HANDLED_URLS.some(pattern => pattern.test(req.url)); @@ -210,7 +256,15 @@ async function handleFetch(e: any) { const request = createNewAssetRequest(asset); const response = await fetch(request); if (response.ok) { - bitBswupCache.put(cacheUrl, response.clone()); + if (self.errorTolerance === 'strict') { + await bitBswupCache.put(cacheUrl, response.clone()); + } else { + try { + bitBswupCache.put(cacheUrl, response.clone()); + } catch (err) { + diagFetch('+++ handleFetch - lazy-fill put failed:', err, asset); + } + } } diagFetch('+++ handleFetch ended - passive saving asset:', start, asset, e, req); @@ -222,13 +276,26 @@ function handleMessage(e: MessageEvent) { diag('handleMessage:', e); if (e.data === 'SKIP_WAITING') { - deleteOldCaches(); // remove the old caches when the new sw skips waiting - return self.skipWaiting().then(() => sendMessage('WAITING_SKIPPED')); + // Activate the waiting worker, then take control of every open client so each tab + // receives a 'controllerchange' and reloads onto the new version (handled in + // bit-bswup.ts > handleControllerChange). Claiming is what makes multi-tab updates + // consistent: without it, sibling tabs keep running the old app code while their + // asset requests are served from the new worker - or from a cache we just deleted - + // which corrupts boot config / DLL hashes. Old caches are removed only *after* the + // claim so no controlled client is left pointing at a cache that no longer exists. + return self.skipWaiting() + .then(() => self.clients.claim()) + .then(() => deleteOldCaches()) + .then(() => sendMessage('WAITING_SKIPPED')); } if (e.data === 'CLAIM_CLIENTS') { - deleteOldCaches(); // remove the old caches when the new sw claims all clients - return self.clients.claim().then(() => e.source.postMessage('CLIENTS_CLAIMED')); + // First-install claim. Take control so this page can start Blazor; sibling tabs + // that observe the resulting 'controllerchange' will NOT reload because there was + // no previously-active worker (see hadActiveWorkerAtStartup in bit-bswup.ts). + return self.clients.claim() + .then(() => deleteOldCaches()) + .then(() => e.source.postMessage('CLIENTS_CLAIMED')); } if (e.data === 'BLAZOR_STARTED') { @@ -314,11 +381,41 @@ async function createAssetsCache(ignoreProgressReport = false) { diag('assetsToCache:', assetsToCache); total = assetsToCache.length; + integrityFailureCount = 0; const promises = assetsToCache.map(addCache.bind(null, !ignoreProgressReport)); + // Await install batch so SRI/network failures surface as install rejections instead of + // unhandled promise rejections. We keep using allSettled (rather than Promise.all) so a + // single failure doesn't cancel sibling fetches: we want every asset attempted and + // reported even when the install will ultimately fail. + const results = await Promise.allSettled(promises); + const rejectedCount = results.reduce((n, r) => n + (r.status === 'rejected' ? 1 : 0), 0); + + if (integrityFailureCount > 0 && !ignoreProgressReport) { + sendError({ + reason: 'install-incomplete', + message: `Install completed with ${integrityFailureCount} integrity failure(s). The service worker will not activate cleanly; check that service-worker-assets.js, blazor.boot.json, and the framework files are served byte-identical (no on-the-fly gzip/minify by a CDN or proxy).`, + count: integrityFailureCount, + }); + } + diag('createAssetsCache ended.'); diagGroupEnd(); + // Strict tolerance: if any required asset failed to fetch / store, reject so the SW + // lifecycle aborts the install and the previous SW (if any) keeps serving. The cache + // we partially populated is discarded explicitly here; the next install will recreate + // it under the version-suffixed CACHE_NAME. + // ignoreProgressReport === true means this run is the post-BLAZOR_STARTED top-up; that + // path must never reject because install has already activated. + if (!ignoreProgressReport && self.errorTolerance === 'strict' && rejectedCount > 0) { + try { await caches.delete(CACHE_NAME); } catch { /* best effort */ } + throw new Error( + `Install aborted under errorTolerance 'strict': ${rejectedCount} of ${total} asset(s) failed. ` + + `Switch to errorTolerance 'lax' to allow a partial cache plus runtime fallback.` + ); + } + async function addCache(report: boolean, asset: any) { try { const request = createNewAssetRequest(asset); @@ -327,6 +424,14 @@ async function createAssetsCache(ignoreProgressReport = false) { try { if (!response.ok) { diag('*** addCache - !response.ok:', request); + sendError({ + reason: 'fetch', + message: `Asset fetch failed with HTTP ${response.status} ${response.statusText || ''}`.trim(), + url: asset.url, + hash: asset.hash, + status: response.status, + integrity: !!(request as any).integrity, + }); doReport(true); return Promise.reject(response); } @@ -340,12 +445,45 @@ async function createAssetsCache(ignoreProgressReport = false) { } catch (err) { diag('*** addCache - put cache err:', err); + sendError({ + reason: 'cache', + message: 'Failed to store asset in cache: ' + (err && (err as any).message || String(err)), + url: asset.url, + hash: asset.hash, + }); doReport(true); return Promise.reject(err); } + }, async fetchErr => { + // Browsers reject fetch() with a TypeError when SRI validation fails. The + // browser also logs "Failed to find a valid digest in the 'integrity' attribute" + // to the console, but the SW would otherwise silently swallow this. Surface it. + const isIntegrity = + !!(request as any).integrity && + (fetchErr instanceof TypeError || + /integrity|digest|EPRPROTO|ERR_FAILED/i.test(String(fetchErr && (fetchErr as any).message || fetchErr))); + if (isIntegrity) integrityFailureCount++; + diag('*** addCache - fetch rejected:', fetchErr, 'integrity?', isIntegrity); + sendError({ + reason: isIntegrity ? 'integrity' : 'fetch', + message: isIntegrity + ? `Subresource Integrity check failed for ${asset.url}. The bytes served do not match the SHA hash recorded in service-worker-assets.js / blazor.boot.json. This is the classic Blazor "Failed to find a valid digest" failure and usually means a CDN, reverse proxy, or compression layer is rewriting the response after publish.` + : 'Asset fetch rejected: ' + (fetchErr && (fetchErr as any).message || String(fetchErr)), + url: asset.url, + hash: asset.hash, + integrity: !!(request as any).integrity, + }); + doReport(true); + return Promise.reject(fetchErr); }); } catch (err) { diag('*** addCache - catch err:', err); + sendError({ + reason: 'request', + message: 'Failed to build asset request: ' + (err && (err as any).message || String(err)), + url: asset && asset.url, + hash: asset && asset.hash, + }); doReport(true); return Promise.reject(err); } @@ -414,6 +552,39 @@ function sendMessage(message: any) { .then((clients: any) => (clients || []).forEach((client: any) => client.postMessage(typeof message === 'string' ? message : JSON.stringify(message)))); } +function sendError(data: { reason: string; message: string;[key: string]: any }) { + diag('*** error:', data); + try { + // Best-effort console output so the failure is visible even before any client connects. + console.error('BitBswup SW:', data.message, data); + } catch { /* ignore */ } + sendMessage({ type: 'error', data }); +} + +function validateAssetsManifest(manifest: any): string[] { + const errors: string[] = []; + if (!manifest || typeof manifest !== 'object') { + errors.push('assetsManifest is not defined'); + return errors; + } + if (typeof manifest.version !== 'string' || !manifest.version) { + errors.push('assetsManifest.version is missing'); + } + if (!Array.isArray(manifest.assets)) { + errors.push('assetsManifest.assets is not an array'); + return errors; + } + let badEntries = 0; + for (let i = 0; i < manifest.assets.length; i++) { + const a = manifest.assets[i]; + if (!a || typeof a.url !== 'string' || !a.url) badEntries++; + } + if (badEntries > 0) { + errors.push(`${badEntries} asset entr${badEntries === 1 ? 'y has' : 'ies have'} no url`); + } + return errors; +} + function prepareExternalAssetsArray(value: any) { const array = value ? (value instanceof Array ? value : [value]) : []; @@ -431,7 +602,25 @@ function prepareExternalAssetsArray(value: any) { } function prepareRegExpArray(value: any) { - return value ? (value instanceof Array ? value : [value]).filter(p => p instanceof RegExp) : []; + const array = value ? (value instanceof Array ? value : [value]) : []; + + return array.map(p => { + if (p instanceof RegExp) { + return p; + } + + if (typeof p === 'string') { + try { + return new RegExp(p); + } catch (err) { + console.warn('BitBswup SW: ignoring invalid RegExp pattern:', p, err); + return null; + } + } + + console.warn('BitBswup SW: ignoring non-RegExp entry (expected RegExp or string):', p); + return null; + }).filter((p): p is RegExp => p !== null); } function trimEnd(str: string, char: string) { diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index f324e57915..1e4e773543 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -2,6 +2,23 @@ var BitBswup = BitBswup || {}; BitBswup.version = window['bit-bswup version'] = '10.4.5'; (function () { + // Level ordering (lowest priority first). A message is logged when its + // level is at or below the configured threshold. `none` silences everything. + const logLevels: { [k: string]: number } = { + none: 0, + error: 1, + warn: 2, + info: 3, + verbose: 4, + debug: 5, + }; + + // Default Blazor entry-point scripts to auto-detect when `blazorScript` is + // not set explicitly. Covers both the .NET 8+ Blazor Web App template + // (blazor.web.js) and the standalone Blazor WebAssembly template + // (blazor.webassembly.js), so the same setup works without extra config. + const defaultBlazorScripts = ['_framework/blazor.web.js', '_framework/blazor.webassembly.js']; + const bitBswupScript = document.currentScript; window.addEventListener('DOMContentLoaded', runBswup); // important event! @@ -18,38 +35,83 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; startBlazor(); - let reload: () => void; + let reload: () => Promise; let cleanup: () => void; let blazorStartResolver: (value: unknown) => void; + // Captured once the registration resolves so the polling helpers (timer / + // visibilitychange) and the page-facing BitBswup.checkForUpdate() can all call + // reg.update() against the same registration without re-resolving it each time. + let registration: ServiceWorkerRegistration; + let updateTimer: ReturnType; + + // Guards against reloading more than once. A single update can surface through + // several channels (the 'WAITING_SKIPPED' message to the initiating tab and a + // 'controllerchange' in every tab once the new worker claims clients); they all + // funnel through reloadOnce() so the page navigates exactly one time. + let refreshing = false; + + // Snapshot of "was an active worker already present when registration resolved". + // This is the stable signal for first-install vs update. Reading + // navigator.serviceWorker.controller at message time is NOT reliable: controller + // is null whenever the current navigation wasn't served by the SW - most notably + // on a hard reload (Ctrl+Shift+R) - even when an active worker exists. Using that + // as the discriminator makes Bswup mistake every hard reload for a first install. + let hadActiveWorkerAtStartup = false; + try { navigator.serviceWorker .register(options.sw, { scope: options.scope, updateViaCache: 'none' }) .then(prepareRegistration) - .catch(() => { + .catch((err) => { startBlazor(true); - warn('serviceWorker register promise failed'); + error('serviceWorker register promise failed', err); }); navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange); navigator.serviceWorker.addEventListener('message', handleMessage); } catch (e) { startBlazor(true); - warn('serviceWorker registration failed'); + error('serviceWorker registration failed', e); } function prepareRegistration(reg) { + // Capture the install/update discriminator exactly once, at the moment the + // registration resolves. reg.active being set here means a previous version + // was already installed => this is an update; otherwise it's a first install. + hadActiveWorkerAtStartup = !!reg.active; + + // Keep the resolved registration around so checkForUpdate() (page API) and the + // optional polling helpers can drive reg.update() without re-resolving it. + registration = reg; + setupUpdatePolling(reg); + + // Replace the load-time fallback (which re-resolves the registration on every + // call and can't report results) with the registration-aware implementation + // now that we have a live registration to work against. + BitBswup.checkForUpdate = checkForUpdate; + reload = () => { - if (navigator.serviceWorker.controller) { - reg.waiting?.postMessage('SKIP_WAITING'); - return Promise.resolve(); + // An update is staged (a new worker finished installing and is waiting). + // Tell it to skip waiting; the resulting 'WAITING_SKIPPED' message triggers + // the page reload. We deliberately keep the returned promise *pending*: the + // page is about to navigate away, so resolving early would let callers run + // teardown (e.g. hiding the splash) against a page that's already reloading. + if (reg.waiting) { + reg.waiting.postMessage('SKIP_WAITING'); + return new Promise(() => { }); } + // First install: a worker is active but not yet controlling this page. + // Ask it to claim clients; once 'CLIENTS_CLAIMED' arrives we start Blazor + // and resolve this promise so callers can finalize (e.g. hide the splash). if (reg.active) { reg.active.postMessage('CLAIM_CLIENTS'); - return new Promise((res, _) => blazorStartResolver = res); + return new Promise((res) => blazorStartResolver = res as (value: unknown) => void); } + // No worker to coordinate with - fall back to a plain reload. window.location.reload(); + return new Promise(() => { }); }; cleanup = () => { @@ -90,12 +152,12 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; } reg.installing.addEventListener('statechange', function (e) { - info('state chnaged', e, 'eventPhase:', e.eventPhase, 'currentTarget.state:', e.currentTarget.state); + debug('state changed', e, 'eventPhase:', e.eventPhase, 'currentTarget.state:', e.currentTarget.state); handle(BswupMessage.stateChanged, e); if (!reg.waiting) return; - if (navigator.serviceWorker.controller) { + if (hadActiveWorkerAtStartup) { info('update finished.'); // not first install } else { info('initialization finished.'); // first install @@ -106,6 +168,41 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; function handleControllerChange(e) { info('controller changed.', e); + + // A new service worker has taken control of this page. This fires in three + // situations: + // 1. This tab triggered the update (clicked "reload") - handleMessage already + // reloads on 'WAITING_SKIPPED', so reloadOnce() here is a harmless no-op. + // 2. First install, where we deliberately claim clients to start Blazor. In + // that case there was no previously-controlling worker, so we must NOT + // reload (doing so would refresh the splash mid-startup). + // 3. A *sibling* tab accepted an update: its worker called skipWaiting and + // claimed every client, so this tab is now controlled by a newer worker + // while still running the old app code and (more dangerously) the old + // worker's cache has been swapped out underneath it. Mixing old app JS + // with new-version assets corrupts boot config / DLL hashes, so this tab + // must reload to re-sync. See "Stuff I wish I'd known about service + // workers" on the controllerchange reload pattern. + // + // We distinguish case 2 from case 3 with hadActiveWorkerAtStartup: a controller + // change only signals a real *update* when a worker was already active when this + // page started. First install never had one, so we skip the reload there. + if (!hadActiveWorkerAtStartup) { + info('controller changed on first install - not reloading.'); + return; + } + + reloadOnce(); + } + + // Reload the page exactly once. Multiple update signals (the initiating tab's + // 'WAITING_SKIPPED' message and the 'controllerchange' event raised in every tab) + // can race; this guard ensures only the first one wins so the page doesn't reload + // repeatedly. + function reloadOnce() { + if (refreshing) return; + refreshing = true; + window.location.reload(); } function handleMessage(e) { @@ -115,7 +212,10 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; } if (e.data === 'WAITING_SKIPPED') { - window.location.reload(); + // The worker we asked to skip waiting has activated. Reload to pick up the + // new version. reloadOnce() coordinates with the 'controllerchange' that + // also fires once the new worker claims this client, so we reload only once. + reloadOnce(); return; } @@ -146,13 +246,18 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; handle(BswupMessage.downloadProgress, data); if (data.percent >= 100) { - const firstInstall = !(navigator.serviceWorker.controller); + const firstInstall = !hadActiveWorkerAtStartup; handle(BswupMessage.downloadFinished, { reload, cleanup, firstInstall }); } } + if (type === 'error') { + error('install error:', data); + handle(BswupMessage.error, { ...data, reload }); + } + if (type === 'bypass') { - const firstInstall = data?.firstTime || !(navigator.serviceWorker.controller); + const firstInstall = data?.firstTime || !hadActiveWorkerAtStartup; handle(BswupMessage.downloadFinished, { reload, cleanup, firstInstall }); } @@ -163,12 +268,75 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; // ============================================================ + // Opt-in update polling. The browser only re-checks the service worker script on + // navigation and roughly every 24h, so a long-lived SPA tab can run a stale version + // for a long time. When configured, we proactively call reg.update() on a timer + // and/or whenever the tab returns to the foreground. This only *checks*; if a new + // version is found the normal install flow (updatefound -> updateReady) takes over. + function setupUpdatePolling(reg: ServiceWorkerRegistration) { + const intervalSeconds = Number(options.updateInterval) || 0; + if (intervalSeconds > 0) { + info(`update polling enabled - every ${intervalSeconds}s.`); + updateTimer = setInterval(() => { + // Skip background tabs: browsers heavily throttle their timers and the + // request would be wasted. The visibilitychange check below catches up + // the moment the tab is focused again. + if (document.visibilityState !== 'visible') return; + checkForUpdate(); + }, intervalSeconds * 1000); + } + + if (options.updateOnVisibility) { + info('update-on-visibility enabled.'); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') checkForUpdate(); + }); + } + } + + // Registration-aware update check used by the timer, the visibility handler, and + // the page-facing BitBswup.checkForUpdate(). Unlike the load-time fallback it can + // report the outcome: if nothing new is staged after the check it emits + // updateNotFound so callers can stop a spinner / show an "up to date" message. + async function checkForUpdate(): Promise { + if (!registration) { + warn('checkForUpdate called before the service worker registration was ready.'); + return; + } + + info('checking for update...'); + + try { + await registration.update(); + + // A new worker installing/waiting means an update was found; the existing + // 'updatefound' listener already drives updateFound/stateChanged/updateReady. + // Nothing installing or waiting means we're already on the latest version, + // which is exactly the "finished, found nothing" case the page can't infer + // on its own - so announce it explicitly. + if (!registration.installing && !registration.waiting) { + info('no update found.'); + handle(BswupMessage.updateNotFound); + } + } catch (err) { + error('checkForUpdate failed', err); + handle(BswupMessage.error, { reason: 'update', message: String((err && (err as any).message) || err), reload }); + } + } + + // ============================================================ + function startBlazor(forceStart = false) { const scriptTags = [].slice.call(document.scripts); - const blazorWasmScriptTag = scriptTags.find(s => s.src && s.src.indexOf(options.blazorScript) !== -1); + // `blazorScript` may be a single path (explicitly configured) or a list + // of candidates to auto-detect. Normalize to an array and match the first + // script tag whose src contains any of the candidates. + const candidates = Array.isArray(options.blazorScript) ? options.blazorScript : [options.blazorScript]; + + const blazorWasmScriptTag = scriptTags.find(s => s.src && candidates.some(c => s.src.indexOf(c) !== -1)); if (!blazorWasmScriptTag) { - return warn(`blazor script (${options.blazorScript}) not found!`); + return warn(`blazor script (${candidates.join(' or ')}) not found!`); } const autostart = blazorWasmScriptTag.attributes['autostart']; @@ -184,10 +352,10 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; function extract(): BswupOptions { const defaultoptions = { scope: '/', - log: 'none', + log: 'warn', sw: 'service-worker.js', handlerName: 'bitBswupHandler', - blazorScript: '_framework/blazor.web.js', + blazorScript: defaultBlazorScripts, } const optionsAttribute = (bitBswupScript.attributes)['options']; @@ -207,7 +375,15 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; options.handlerName = (handlerAttribute && handlerAttribute.value) || options.handlerName; const blazorScriptAttribute = bitBswupScript.attributes['blazorScript']; - options.blazorScript = (blazorScriptAttribute && blazorScriptAttribute.value) || options.blazorScript; + options.blazorScript = (blazorScriptAttribute && blazorScriptAttribute.value) || options.blazorScript || defaultBlazorScripts; + + // Polling is opt-in: absent attributes leave the options untouched so the + // default (no timer, no visibility check) is preserved. + const updateIntervalAttribute = bitBswupScript.attributes['updateInterval']; + if (updateIntervalAttribute) options.updateInterval = Number(updateIntervalAttribute.value); + + const updateOnVisibilityAttribute = bitBswupScript.attributes['updateOnVisibility']; + if (updateOnVisibilityAttribute) options.updateOnVisibility = updateOnVisibilityAttribute.value === 'true'; return options; } @@ -225,29 +401,51 @@ BitBswup.version = window['bit-bswup version'] = '10.4.5'; options.handler && options.handler(...args); } - // TODO: apply log options: info, verbode, debug, error, ... - //function info(...texts: string[]) { - // console.log(`%cBitBSWUP: ${texts.join('\n')}`, 'color:lightblue'); - //} + function shouldLog(level: 'error' | 'warn' | 'info' | 'verbose' | 'debug'): boolean { + const configured = logLevels[options.log]; + // Unknown values fall back to `warn` (matches the documented default behavior). + const threshold = configured == null ? logLevels.warn : configured; + return logLevels[level] <= threshold; + } - function info(...args: any[]) { - if (options.log === 'none') return; - console.info(...['BitBswup:', ...args]); + function error(...args: any[]) { + if (!shouldLog('error')) return; + console.error(...['BitBswup:', ...args]); } function warn(...args: any[]) { + if (!shouldLog('warn')) return; console.warn(...['BitBswup:', ...args]); } + + function info(...args: any[]) { + if (!shouldLog('info')) return; + console.info(...['BitBswup:', ...args]); + } + + function verbose(...args: any[]) { + if (!shouldLog('verbose')) return; + console.log(...['BitBswup:', ...args]); + } + + function debug(...args: any[]) { + if (!shouldLog('debug')) return; + console.debug(...['BitBswup:', ...args]); + } } }()); +// Load-time fallback. This is replaced by the registration-aware implementation (which +// can report updateNotFound) once runBswup resolves the service worker registration. It +// stays as the public entry point so the API is callable even before registration +// completes, and on browsers without service worker support. BitBswup.checkForUpdate = async (): Promise => { if (!('serviceWorker' in navigator)) { return console.warn('no serviceWorker in navigator'); } const reg = await navigator.serviceWorker.getRegistration(); - await reg.update(); + await reg?.update(); } BitBswup.forceRefresh = async (): Promise => { @@ -290,16 +488,20 @@ const BswupMessage = { updateInstalled: 'UPDATE_INSTALLED', updateReady: 'UPDATE_READY', updateFound: 'UPDATE_FOUND', - stateChanged: 'STATE_CHANGED' + updateNotFound: 'UPDATE_NOT_FOUND', + stateChanged: 'STATE_CHANGED', + error: 'ERROR' }; declare const Blazor: { start: () => Promise } interface BswupOptions { - log: 'none' | 'info' | 'verbose' | 'debug' | 'error' + log: 'none' | 'error' | 'warn' | 'info' | 'verbose' | 'debug' sw: string scope: string handlerName: string - blazorScript: string + blazorScript: string | string[] + updateInterval?: number + updateOnVisibility?: boolean handler?(...args: any[]): void } \ No newline at end of file diff --git a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css index b942613300..afadce593e 100644 --- a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css +++ b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css @@ -54,3 +54,42 @@ height: 50vh; text-align: left; } + +.bit-bswup-error { + margin-top: 20px; + padding: 12px 14px; + border: 1px solid rgb(168, 0, 0); + background-color: rgb(253, 231, 233); + color: rgb(89, 0, 0); + border-radius: 4px; + text-align: left; +} + +.bit-bswup-error-title { + font-size: 16px; + font-weight: 600; + margin: 0 0 6px 0; +} + +.bit-bswup-error-message { + font-size: 13px; + margin: 0 0 8px 0; + white-space: pre-wrap; + word-break: break-word; +} + +.bit-bswup-error-details { + font-size: 11px; + margin: 0 0 8px 0; + padding: 6px 8px; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 3px; + white-space: pre-wrap; + word-break: break-word; + max-height: 30vh; + overflow: auto; +} + +#bit-bswup-error-retry { + display: none; +} diff --git a/src/Bswup/README.md b/src/Bswup/README.md index 7ab1845ca7..566d09a72f 100644 --- a/src/Bswup/README.md +++ b/src/Bswup/README.md @@ -40,13 +40,18 @@ app.UseStaticFiles(new StaticFileOptions scope="/" log="verbose" sw="service-worker.js" - handler="bitBswupHandler"> + handler="bitBswupHandler" + updateInterval="3600" + updateOnVisibility="true"> ``` - `scope`: The scope of the service-worker ([read more](https://developer.chrome.com/docs/workbox/service-worker-lifecycle/#scope)). -- `log`: The log level of the Bswup logger. available options are: `info`, `verbose`, `debug`, and `error`. (not implemented yet) +- `log`: The log level of the Bswup logger. Available options are: `none`, `error`, `warn`, `info`, `verbose`, and `debug` (case-insensitive). Each level includes everything above it (e.g. `info` also shows `warn` and `error`). Defaults to `warn`. Use `none` to silence all output. - `sw`: The file path of the service-worker file. - `handler`: The name of the handler function for the service-worker events. +- `blazorScript`: The path of the Blazor entry-point script (the one you added `autostart="false"` to in step 3). When omitted, Bswup auto-detects both the Blazor Web App script (`_framework/blazor.web.js`) and the standalone Blazor WebAssembly script (`_framework/blazor.webassembly.js`), so you only need to set this if your script lives at a non-default path. +- `updateInterval`: Number of seconds between automatic update checks. By default the browser only re-checks the service worker on navigation and roughly every 24 hours, so a long-lived SPA tab can run a stale version for a long time. Set this to a positive number (e.g. `3600` for hourly) to have Bswup call `reg.update()` on a timer. Checks are skipped while the tab is in the background (the browser throttles those timers anyway) and resume when it becomes visible again. Omit or set to `0` to disable (the default). +- `updateOnVisibility`: When set to `true`, Bswup checks for an update every time the tab returns to the foreground (the `visibilitychange` event). This is a lightweight way to catch updates right when a user comes back to a tab they left open. Disabled by default. > You can remove any of these attributes, and use the default values mentioned above. @@ -92,10 +97,22 @@ function bitBswupHandler(type, data) { reloadButton.style.display = 'block'; reloadButton.onclick = data.reload; return console.log('new update ready.'); + + case BswupMessage.updateNotFound: + return console.log('checked for an update, already on the latest version.'); } } ``` +> **Multi-tab updates:** Service workers are single-instance per origin, so accepting an +> update in one tab activates the new version for every open tab. When that happens, Bswup +> has the new worker claim all clients and each *other* tab reloads itself automatically +> (via the `controllerchange` event) onto the new version. This keeps every tab consistent +> and avoids the classic failure where an old tab keeps running old app code while its +> asset requests are served from the new version's cache (mismatched boot config / DLL +> hashes). The first install is exempt: claiming a client for the first time starts Blazor +> and does not trigger a reload. + 6. Configure additional settings in the service-worker file like the following code: ```js @@ -115,6 +132,7 @@ self.externalAssets = [ ]; self.assetsUrl = '/service-worker-assets.js'; self.noPrerenderQuery = 'no-prerender=true'; +self.cacheVersion = '2026.05.31-abc1234'; self.caseInsensitiveUrl = true; self.ignoreDefaultInclude = true; @@ -156,14 +174,45 @@ The other settings are: #### Keep in mind that caching service-worker related files will corrupt the update cycle of the service-worker. Only the browser should handle these files. - `isPassive`: Enables the Bswup's passive mode. In this mode, the assets won't be cached in advance but rather upon initial request. - `enableIntegrityCheck`: Enables the default integrity check available in browsers by setting the `integrity` attribute of the request object created in the service-worker to fetch the assets. -- `errorTolerance`: Determines how the Bswup should handle the errors while downloading assets. Possible values are: `strict`, `lax`, `config`. +- `errorTolerance`: Controls how the service worker reacts to asset download / cache failures during install. Possible values: + - `strict` (default): mirrors the standard Microsoft template / Workbox behavior. If any required asset fails to fetch or store during install, the install promise rejects, the partially populated cache is discarded, and the previous service-worker (if any) keeps serving the app. Failed assets are reported via the `error` message and are *not* counted toward the progress percentage, so 100% means every asset succeeded. + - `lax`: best-effort install. The install always succeeds; missing assets are filled in lazily on the first fetch (in both passive and non-passive modes). Failed assets are still reported as errors but are counted toward the progress so the bar can reach 100% even with failures. Use this only when you knowingly accept a partial cache, for example when listing optional `externalAssets` that may legitimately 404. - `enableDiagnostics`: Enables diagnostics by pushing service-worker logs to the browser console. - `enableFetchDiagnostics`: Enables fetch event diagnostics by pushing service-worker fetch event logs to the browser console. - `disableHashlessAssetsUpdate`: Disables the update of the hash-less assets. By default, the Bswup tries to automatically update all of the hash-less assets (e.g. the external assets) every time an update found for the app. - `forcePrerender`: Forces the prerendering of the default document for every navigation request to ensure that the server always has the latest version of the app. This is useful when you have a server-rendered app and you want to make sure that the client always has the latest version of the app. - `enableCacheControl`: Enables the cache-control mechanism by providing cache busting setting and header to each request (`cache:no-store` settings and `cache-control:no-cache` header). +- `cacheVersion`: Overrides the value used to name the cache storage bucket (`bit-bswup - `). By default this tracks Blazor's `assetsManifest.version` (a hash over the published assets), which means the cache is rotated automatically whenever any asset hash changes - and *only* then. Set `cacheVersion` to take manual control: pin it to a stable string so noisy dev rebuilds that perturb asset hashes don't needlessly evict the whole cache (runtime `.dll`/`.wasm` included), or bump it to force a refresh when a meaningful change lives outside Blazor's asset manifest. Only the cache bucket name is affected; the per-asset `?v=` cache-buster and the Subresource Integrity hashes still derive from the manifest version, so asset integrity is unchanged. When unset (or not a non-empty string) it falls back to the manifest version. Tip: feed it a build-stamped value (commit SHA, build timestamp, or your app's informational version) so it bumps automatically per publish. - `mode`: Determines the mode of the Bswup. Possible values are: - `NoPrerender`: Disables the prerendering of the default document for every navigation request. - `InitialPrerender`: Enables the prerendering of the default document only for the initial navigation request. - `AlwaysPrerender`: Enables the prerendering of the default document for every navigation request. - - `FullOffline`: Enables the full offline mode where all assets are cached and served from the cache from first time the app is loaded. \ No newline at end of file + - `FullOffline`: Enables the full offline mode where all assets are cached and served from the cache from first time the app is loaded. + +## JavaScript API + +Bswup exposes a small global `BitBswup` object on the page so you can drive the update lifecycle from your own code (a "check for updates" button, a custom poller, a "reset app" action, etc.): + +- `BitBswup.checkForUpdate()`: Asks the browser to re-fetch the service-worker script and check for a new version. If a new version is found, the normal update flow runs (`updateFound` -> `stateChanged` -> `updateReady`/`downloadFinished`). If the app is already on the latest version, Bswup raises the `updateNotFound` event so you can stop a spinner or show an "up to date" message. This is the registration-aware version that powers the built-in polling; it is safe to call as often as you like. +- `BitBswup.skipWaiting()`: If an update has finished downloading and is waiting, this activates it immediately (equivalent to calling the `reload` callback you receive in `updateReady`/`downloadFinished`). Returns `true` when there was a waiting worker to activate, otherwise `false`. +- `BitBswup.forceRefresh()`: Clears the Bswup and Blazor caches, unregisters all service workers, and reloads the page. Use this as a last-resort "reset" when a client gets into a bad state. + +### Polling for updates + +By default a service worker is only re-checked by the browser on navigation and roughly every 24 hours, so a tab that stays open for a long time can keep running an old version. There are three ways to check more often: + +1. Set `updateInterval` (and/or `updateOnVisibility`) on the script tag for built-in polling (see the options above). This is the simplest approach and requires no extra code. +2. Call `BitBswup.checkForUpdate()` yourself, for example from a timer or after a user action. + +```js +// check every hour from your own code (equivalent to updateInterval="3600") +setInterval(() => BitBswup.checkForUpdate(), 60 * 60 * 1000); + +// or check whenever the user clicks a button, and react to the result +document.getElementById('check-updates').onclick = () => BitBswup.checkForUpdate(); +``` + +Either way, the result surfaces through your `bitBswupHandler`: a found update flows through `updateFound`/`updateReady`, and "nothing new" flows through `updateNotFound`. + +> Built-in polling skips checks while the tab is in the background (the browser throttles +> those timers anyway) and catches up automatically when the tab becomes visible again. From c6467848615a1f9faf32ac036a502f8831dd0647 Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 1 Jun 2026 13:03:51 +0330 Subject: [PATCH 02/32] further improvements --- .../Bit.Bswup.Demo/wwwroot/service-worker.js | 1 - .../wwwroot/service-worker.published.js | 1 - src/Bswup/Bit.Bswup/BswupProgress.razor | 5 +- .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 9 + src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 226 +++-- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 821 +++++++++--------- src/Bswup/README.md | 23 +- 7 files changed, 624 insertions(+), 462 deletions(-) diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js index baf7ae75e5..831f82e974 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js @@ -2,7 +2,6 @@ self.assetsExclude = [/\.scp\.css$/, /weather\.json$/]; self.caseInsensitiveUrl = true; -self.precachedAssetsInclude = [/favicon\.ico$/, /icon-512\.png$/, /bit-bw-64\.png$/]; self.externalAssets = [ { diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js index 58eaeef789..1862f5cde5 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js @@ -2,7 +2,6 @@ self.assetsExclude = [/\.scp\.css$/, /weather\.json$/]; self.caseInsensitiveUrl = true; -self.precachedAssetsInclude = [/favicon\.ico$/, /icon-512\.png$/, /bit-bw-64\.png$/]; //self.externalAssets = [ // { diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index eb02ea7ec0..ccdfd2d4d8 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -34,6 +34,7 @@ } - + diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index 815852f05f..52ace91619 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -141,6 +141,15 @@ function config(newConfig: IBswupProgressConfigs) { Object.assign(_config, newConfig); + + // Keep the assets list visibility in sync when toggled at runtime. + // The
    is server-rendered with an inline display style based on the + // initial ShowAssets parameter, so flipping the config alone wouldn't + // reveal/hide it without also updating the element here. + if (newConfig.showAssets !== undefined) { + const assetsEl = document.getElementById('bit-bswup-assets'); + if (assetsEl) assetsEl.style.display = newConfig.showAssets ? 'block' : 'none'; + } } }()); diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index 3a366d40ca..0fbe4b901f 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -21,6 +21,8 @@ interface Window { isPassive: any enableIntegrityCheck: any errorTolerance: any + maxRetries: any + retryDelay: any enableDiagnostics: any enableFetchDiagnostics: any disableHashlessAssetsUpdate: any @@ -114,6 +116,19 @@ if (self.errorTolerance !== 'strict' && self.errorTolerance !== 'lax') { self.errorTolerance = 'strict'; } +// Transient-failure retry policy for asset downloads. A single flaky request (CDN blip, +// dropped connection, 5xx/429/408) shouldn't fail the whole strict install or silently +// drop an asset under lax. We retry such failures with exponential backoff before giving +// up. Deterministic failures (SRI/integrity mismatch, 404/403 and other permanent 4xx) are +// NOT retried because re-fetching identical bytes would just fail again. +// MAX_RETRIES is the number of *additional* attempts after the first try (default 2 => up +// to 3 total attempts). RETRY_DELAY is the base backoff in ms; attempt n waits +// RETRY_DELAY * 2^(n-1) (e.g. 300ms, 600ms) plus jitter. +const MAX_RETRIES = normalizeNonNegativeInt(self.maxRetries, 2); +const RETRY_DELAY = normalizeNonNegativeInt(self.retryDelay, 300); + +diag('MAX_RETRIES:', MAX_RETRIES, 'RETRY_DELAY:', RETRY_DELAY); + self.addEventListener('install', e => e.waitUntil(handleInstall(e))); self.addEventListener('activate', e => e.waitUntil(handleActivate(e))); self.addEventListener('fetch', e => e.respondWith(handleFetch(e))); @@ -255,16 +270,24 @@ async function handleFetch(e: any) { const request = createNewAssetRequest(asset); const response = await fetch(request); + if (response.ok) { - if (self.errorTolerance === 'strict') { - await bitBswupCache.put(cacheUrl, response.clone()); - } else { - try { - bitBswupCache.put(cacheUrl, response.clone()); - } catch (err) { - diagFetch('+++ handleFetch - lazy-fill put failed:', err, asset); - } - } + // Stream the response to the page immediately and write to the cache in the + // background. Awaiting cache.put() here would block the (potentially large + // .wasm / .dll) body from reaching the page until the whole file had been + // downloaded and stored. response.clone() lets the browser tee the stream so the + // page and the cache write consume bytes as they arrive, and e.waitUntil keeps the + // service worker alive until the background write completes. This mirrors how + // Workbox's Strategy.handle returns the response while caching transparently. + // + // Lazy-fill is best-effort under both error tolerances: at runtime there is no + // install promise to reject, so a failed write just means the asset is re-fetched + // next time instead of being served from cache. (errorTolerance is enforced during + // install in createAssetsCache, not on this passive runtime path.) + const cachePut = bitBswupCache.put(cacheUrl, response.clone()).catch(err => { + diagFetch('+++ handleFetch - lazy-fill put failed:', err, asset); + }); + e.waitUntil(cachePut); } diagFetch('+++ handleFetch ended - passive saving asset:', start, asset, e, req); @@ -357,7 +380,7 @@ async function createAssetsCache(ignoreProgressReport = false) { let hash = lastIndex === -1 ? '' : key.url.substring(lastIndex + 1); oldUrls.push({ url, hash }); - const foundAsset = UNIQUE_ASSETS.find(a => url.endsWith(a.url)); + const foundAsset = UNIQUE_ASSETS.find(a => urlEndsWith(url, a.url)); if (!foundAsset) { diag('*** removed oldUrl:', key.url); newCache.delete(key.url); @@ -376,7 +399,7 @@ async function createAssetsCache(ignoreProgressReport = false) { diag('oldUrls:', oldUrls); diag('updatedAssets:', updatedAssets); - const assetsToCache = updatedAssets.concat(UNIQUE_ASSETS.filter(a => !oldUrls.find(u => u.url.endsWith(a.url) || a.url.endsWith(u.url)))); + const assetsToCache = updatedAssets.concat(UNIQUE_ASSETS.filter(a => !oldUrls.find(u => urlEndsWith(u.url, a.url) || urlEndsWith(a.url, u.url)))); diag('assetsToCache:', assetsToCache); @@ -417,77 +440,121 @@ async function createAssetsCache(ignoreProgressReport = false) { } async function addCache(report: boolean, asset: any) { + let request: Request; try { - const request = createNewAssetRequest(asset); - const responsePromise = fetch(request); - return responsePromise.then(async response => { - try { - if (!response.ok) { - diag('*** addCache - !response.ok:', request); - sendError({ - reason: 'fetch', - message: `Asset fetch failed with HTTP ${response.status} ${response.statusText || ''}`.trim(), - url: asset.url, - hash: asset.hash, - status: response.status, - integrity: !!(request as any).integrity, - }); - doReport(true); - return Promise.reject(response); - } - - const cacheUrl = createCacheUrl(asset); - await newCache.put(cacheUrl, response.clone()); - - doReport(); - - return response; - - } catch (err) { - diag('*** addCache - put cache err:', err); - sendError({ - reason: 'cache', - message: 'Failed to store asset in cache: ' + (err && (err as any).message || String(err)), - url: asset.url, - hash: asset.hash, - }); - doReport(true); - return Promise.reject(err); - } - }, async fetchErr => { + request = createNewAssetRequest(asset); + } catch (err) { + diag('*** addCache - catch err:', err); + sendError({ + reason: 'request', + message: 'Failed to build asset request: ' + (err && (err as any).message || String(err)), + url: asset && asset.url, + hash: asset && asset.hash, + }); + doReport(true); + return Promise.reject(err); + } + + const hasIntegrity = !!(request as any).integrity; + let lastError: any; + + // Attempt the download up to MAX_RETRIES additional times after the first try. + // Only transient failures fall through to the next iteration; deterministic ones + // (integrity mismatch, permanent HTTP statuses) reject immediately. + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + // Exponential backoff with jitter: attempt 1 waits ~RETRY_DELAY, attempt 2 + // ~2*RETRY_DELAY, etc. Jitter spreads the retry storm when many of the 200+ + // assets fail at once (e.g. a brief CDN outage) so they don't all re-hit the + // origin on the same tick. + const backoff = RETRY_DELAY * Math.pow(2, attempt - 1); + const wait = backoff + Math.floor(Math.random() * RETRY_DELAY); + diag(`*** addCache - retrying (${attempt}/${MAX_RETRIES}) in ${wait}ms:`, asset.url); + await delay(wait); + } + + let response: Response; + try { + response = await fetch(request); + } catch (fetchErr) { // Browsers reject fetch() with a TypeError when SRI validation fails. The // browser also logs "Failed to find a valid digest in the 'integrity' attribute" // to the console, but the SW would otherwise silently swallow this. Surface it. const isIntegrity = - !!(request as any).integrity && + hasIntegrity && (fetchErr instanceof TypeError || /integrity|digest|EPRPROTO|ERR_FAILED/i.test(String(fetchErr && (fetchErr as any).message || fetchErr))); + + // Integrity failures are deterministic: re-fetching identical bytes fails the + // same way, so never retry them. Genuine network errors are transient and + // worth another attempt while retries remain. + if (!isIntegrity && attempt < MAX_RETRIES) { + lastError = fetchErr; + diag('*** addCache - fetch rejected (will retry):', fetchErr, asset.url); + continue; + } + if (isIntegrity) integrityFailureCount++; diag('*** addCache - fetch rejected:', fetchErr, 'integrity?', isIntegrity); sendError({ reason: isIntegrity ? 'integrity' : 'fetch', message: isIntegrity ? `Subresource Integrity check failed for ${asset.url}. The bytes served do not match the SHA hash recorded in service-worker-assets.js / blazor.boot.json. This is the classic Blazor "Failed to find a valid digest" failure and usually means a CDN, reverse proxy, or compression layer is rewriting the response after publish.` - : 'Asset fetch rejected: ' + (fetchErr && (fetchErr as any).message || String(fetchErr)), + : 'Asset fetch rejected' + (attempt > 0 ? ` after ${attempt + 1} attempts` : '') + ': ' + (fetchErr && (fetchErr as any).message || String(fetchErr)), url: asset.url, hash: asset.hash, - integrity: !!(request as any).integrity, + integrity: hasIntegrity, }); doReport(true); return Promise.reject(fetchErr); - }); - } catch (err) { - diag('*** addCache - catch err:', err); - sendError({ - reason: 'request', - message: 'Failed to build asset request: ' + (err && (err as any).message || String(err)), - url: asset && asset.url, - hash: asset && asset.hash, - }); - doReport(true); - return Promise.reject(err); + } + + if (!response.ok) { + // Retry only transient HTTP statuses (request timeout, rate limit, 5xx). + // Permanent ones (404, 403, ...) will not change on retry. + if (isRetryableStatus(response.status) && attempt < MAX_RETRIES) { + lastError = response; + diag('*** addCache - !response.ok (will retry):', response.status, asset.url); + continue; + } + + diag('*** addCache - !response.ok:', request); + sendError({ + reason: 'fetch', + message: `Asset fetch failed with HTTP ${response.status} ${response.statusText || ''}`.trim() + (attempt > 0 ? ` after ${attempt + 1} attempts` : ''), + url: asset.url, + hash: asset.hash, + status: response.status, + integrity: hasIntegrity, + }); + doReport(true); + return Promise.reject(response); + } + + try { + const cacheUrl = createCacheUrl(asset); + await newCache.put(cacheUrl, response.clone()); + + doReport(); + + return response; + } catch (err) { + diag('*** addCache - put cache err:', err); + sendError({ + reason: 'cache', + message: 'Failed to store asset in cache: ' + (err && (err as any).message || String(err)), + url: asset.url, + hash: asset.hash, + }); + doReport(true); + return Promise.reject(err); + } } + // Unreachable in practice (the loop returns on success or rejects on the final + // attempt), but keep a defensive fallback so the promise always settles. + return Promise.reject(lastError); + function doReport(rejected = false) { if (!report) return; if (rejected && self.errorTolerance !== 'lax') return; @@ -502,6 +569,41 @@ function createCacheUrl(asset: any) { return asset.hash ? `${asset.url}.${asset.hash}` : asset.url; } +// Resolves after `ms` milliseconds. Used to space out asset-download retries. +function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Whether an HTTP status code represents a transient failure worth retrying. 408 (Request +// Timeout) and 429 (Too Many Requests) are explicitly transient; any 5xx is treated as a +// server-side hiccup. Everything else (notably 404/403 and other 4xx) is permanent and +// must not be retried. +function isRetryableStatus(status: number) { + return status === 408 || status === 429 || (status >= 500 && status <= 599); +} + +// Coerces a self.* config value into a non-negative integer, falling back to `fallback` +// when the value is missing or not a sane number. Keeps MAX_RETRIES / RETRY_DELAY robust +// against bad app configuration. +function normalizeNonNegativeInt(value: any, fallback: number) { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.floor(n); +} + +// Case-folding aware `endsWith` for asset URLs. handleFetch already resolves assets +// case-insensitively when self.caseInsensitiveUrl is set; the install/update diff must use +// the same folding so a pure casing change in the manifest/served path (e.g. IIS serving +// Bit.Bswup.Foo.css vs bit.bswup.foo.css) is not mistaken for a removed+added asset, which +// would needlessly evict and re-download a byte-identical file. Hashes stay case-sensitive +// and are compared separately, so SRI/base64 integrity is unaffected. +function urlEndsWith(value: string, suffix: string) { + if (self.caseInsensitiveUrl) { + return value.toLowerCase().endsWith(suffix.toLowerCase()); + } + return value.endsWith(suffix); +} + function createNewAssetRequest(asset: any) { const version = ((asset.hash || self.assetsManifest.version) as string).replaceAll('+', '-').replaceAll('/', '_'); const trimmedVersion = encodeURIComponent(trimEnd(version, '=')); diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index 1e4e773543..14fcc8390a 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -1,491 +1,522 @@ var BitBswup = BitBswup || {}; BitBswup.version = window['bit-bswup version'] = '10.4.5'; -(function () { - // Level ordering (lowest priority first). A message is logged when its - // level is at or below the configured threshold. `none` silences everything. - const logLevels: { [k: string]: number } = { - none: 0, - error: 1, - warn: 2, - info: 3, - verbose: 4, - debug: 5, - }; +// Idempotency guard. bit-bswup.js wires up a DOMContentLoaded handler (and through it +// the service-worker registration, event listeners, update timers and reload handlers) +// and assigns the public BitBswup.* API - all as side effects that run the moment the +// script is parsed. If the script is included more than once (e.g. a stray duplicate +// diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts index 253a09b75f..2b095ab989 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts @@ -1,4 +1,4 @@ -self['bit-bswup.sw-cleanup version'] = '10.4.5'; +(self as any)['bit-bswup.sw-cleanup version'] = '10.4.5'; self.addEventListener('install', e => e.waitUntil(removeBswup())); diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index 4d9c97ace4..d5345af345 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -513,10 +513,11 @@ async function createAssetsCache(ignoreProgressReport = false) { // Browsers reject fetch() with a TypeError when SRI validation fails. The // browser also logs "Failed to find a valid digest in the 'integrity' attribute" // to the console, but the SW would otherwise silently swallow this. Surface it. + // SRI and transient network failures both reject as TypeError; only treat as + // integrity when the message signals a digest/SRI problem, not on TypeError alone. const isIntegrity = hasIntegrity && - (fetchErr instanceof TypeError || - /integrity|digest|EPRPROTO|ERR_FAILED/i.test(String(fetchErr && (fetchErr as any).message || fetchErr))); + /integrity|digest|EPRPROTO|ERR_FAILED/i.test(String(fetchErr && (fetchErr as any).message || fetchErr)); // Integrity failures are deterministic: re-fetching identical bytes fails the // same way, so never retry them. Genuine network errors are transient and diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index 30ac7d8781..ab4fab519b 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -32,7 +32,11 @@ if (!BitBswup.initialized) { const bitBswupScript = document.currentScript; - window.addEventListener('DOMContentLoaded', runBswup); // important event! + if (document.readyState === 'loading') { + window.addEventListener('DOMContentLoaded', runBswup); // important event! + } else { + runBswup(); + } function runBswup() { const options = extract(); @@ -496,7 +500,10 @@ if (!BitBswup.initialized) { const shouldDelete = typeof cacheFilter === 'function' ? cacheFilter : - cacheFilter instanceof RegExp ? (key: string) => cacheFilter.test(key) : + cacheFilter instanceof RegExp ? (key: string) => { + cacheFilter.lastIndex = 0; + return cacheFilter.test(key); + } : typeof cacheFilter === 'string' ? (key: string) => key.startsWith(cacheFilter) : () => true; From 23b29b3254a42bf5aabc56a49c51dd69441c9a9d Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Fri, 5 Jun 2026 11:11:39 +0330 Subject: [PATCH 05/32] resolve review comments III --- src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts | 2 +- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 2 +- src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css | 4 ++-- src/Bswup/README.md | 11 +++++++---- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index 9e2bfa6aee..efb00be7e5 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -133,7 +133,7 @@ // CDN/proxy), not a retry. For those, hide the retry button so we // don't invite a pointless reload loop; keep it for transient // failures (network/fetch/cache) where reloading can genuinely help. - const nonRetriableReasons = ['manifest', 'integrity']; + const nonRetriableReasons = ['manifest', 'integrity', 'install-incomplete']; const isRetriable = !(data && nonRetriableReasons.indexOf(data.reason) !== -1); if (isRetriable) { errorRetryButton.style.display = 'inline-block'; diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index ab4fab519b..32e70cc754 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -396,7 +396,7 @@ if (!BitBswup.initialized) { const optionsAttribute = (bitBswupScript.attributes)['options']; const optionsName = (optionsAttribute || {}).value || 'bitBswup'; - const options = (window[optionsName] || defaultoptions) as BswupOptions; + const options = Object.assign({}, defaultoptions, window[optionsName]) as BswupOptions; const logAttribute = bitBswupScript.attributes['log']; options.log = (logAttribute && logAttribute.value) || options.log; diff --git a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css index afadce593e..486bc1f5dc 100644 --- a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css +++ b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css @@ -75,7 +75,7 @@ font-size: 13px; margin: 0 0 8px 0; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: anywhere; } .bit-bswup-error-details { @@ -85,7 +85,7 @@ background-color: rgba(0, 0, 0, 0.04); border-radius: 3px; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: anywhere; max-height: 30vh; overflow: auto; } diff --git a/src/Bswup/README.md b/src/Bswup/README.md index 7aa0216957..a99ec4c47c 100644 --- a/src/Bswup/README.md +++ b/src/Bswup/README.md @@ -106,7 +106,10 @@ function bitBswupHandler(type, data) { // Structured install failure. data.reason is one of 'manifest' | 'integrity' | // 'fetch' | 'cache' | 'request' | 'install-incomplete'; data.message is human // readable, and data.url / data.hash point at the offending asset when known. - console.error('Bswup install error:', data.reason, data.message); + console.error('Bswup install error:', data.reason, data.message, + ...(data.url ? [`url: ${data.url}`] : []), + ...(data.hash ? [`hash: ${data.hash}`] : []), + data); return; } } @@ -210,12 +213,12 @@ The other settings are: - `disableHashlessAssetsUpdate`: Disables the update of the hash-less assets. By default, the Bswup tries to automatically update all of the hash-less assets (e.g. the external assets) every time an update found for the app. - `forcePrerender`: Forces the prerendering of the default document for every navigation request to ensure that the server always has the latest version of the app. This is useful when you have a server-rendered app and you want to make sure that the client always has the latest version of the app. - `enableCacheControl`: Enables the cache-control mechanism by providing cache busting setting and header to each request (`cache:no-store` settings and `cache-control:no-cache` header). -- `cacheVersion`: Overrides the value used to name the cache storage bucket (`bit-bswup - `). By default this tracks Blazor's `assetsManifest.version` (a hash over the published assets), which means the cache is rotated automatically whenever any asset hash changes - and *only* then. Set `cacheVersion` to take manual control: pin it to a stable string so noisy dev rebuilds that perturb asset hashes don't needlessly evict the whole cache (runtime `.dll`/`.wasm` included), or bump it to force a refresh when a meaningful change lives outside Blazor's asset manifest. Only the cache bucket name is affected; the per-asset `?v=` cache-buster and the Subresource Integrity hashes still derive from the manifest version, so asset integrity is unchanged. When unset (or not a non-empty string) it falls back to the manifest version. Tip: feed it a build-stamped value (commit SHA, build timestamp, or your app's informational version) so it bumps automatically per publish. +- `cacheVersion`: Overrides the value used to name the cache storage bucket (`bit-bswup - `). By default this tracks Blazor's `assetsManifest.version` (a hash over the published assets), which means the cache is rotated automatically whenever any asset hash changes - and *only* then. Set `cacheVersion` to take manual control: pin it to a stable string so noisy dev rebuilds that perturb asset hashes don't needlessly evict the whole cache (runtime `.dll`/`.wasm` included), or bump it to force a refresh when a meaningful change lives outside Blazor's asset manifest. Only the cache bucket name (`CACHE_NAME`) is affected. Per-asset cache busting (`?v=`) is set in `createNewAssetRequest()` from each asset's `asset.hash` (falling back to `assetsManifest.version`), and Subresource Integrity uses `asset.hash` when integrity checking is enabled. When unset (or not a non-empty string) it falls back to the manifest version. Tip: feed it a build-stamped value (commit SHA, build timestamp, or your app's informational version) so it bumps automatically per publish. - `mode`: Determines the mode of the Bswup. Possible values are: - `NoPrerender`: Disables the prerendering of the default document for every navigation request. - `InitialPrerender`: Enables the prerendering of the default document only for the initial navigation request. - `AlwaysPrerender`: Enables the prerendering of the default document for every navigation request. - - `FullOffline`: Enables the full offline mode where all assets are cached and served from the cache from first time the app is loaded. + - `FullOffline`: Enables the full offline mode where all assets are cached and served from the cache from the first time the app is loaded. ## JavaScript API @@ -227,7 +230,7 @@ Bswup exposes a small global `BitBswup` object on the page so you can drive the ### Polling for updates -By default a service worker is only re-checked by the browser on navigation and roughly every 24 hours, so a tab that stays open for a long time can keep running an old version. There are three ways to check more often: +By default a service worker is only re-checked by the browser on navigation and roughly every 24 hours, so a tab that stays open for a long time can keep running an old version. There are two ways to check more often: 1. Set `updateInterval` (and/or `updateOnVisibility`) on the script tag for built-in polling (see the options above). This is the simplest approach and requires no extra code. 2. Call `BitBswup.checkForUpdate()` yourself, for example from a timer or after a user action. From 088c6d162f0d48414e86fd5c8e7f75ae568ffaf6 Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 6 Jun 2026 10:41:16 +0330 Subject: [PATCH 06/32] resolve review comments IV --- .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 9 +++++++++ src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 18 ++++++++++++------ src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 4 +++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index efb00be7e5..ca7775704e 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -111,6 +111,15 @@ hideApp_ && appEl && (appEl.style.display = 'none'); bswupEl && (bswupEl.style.display = 'block'); + // A failed install supersedes any earlier "update ready" prompt. Leaving + // the reload button visible would invite the user to activate an update + // that has already failed, promoting a broken worker / caches. Hide and + // unwire it so the only actionable control is the (conditional) Retry. + if (reloadButton) { + reloadButton.style.display = 'none'; + reloadButton.onclick = null; + } + // The error supersedes any in-flight progress. Hide the bar and the // percentage so a stale partial value (e.g. "47%") isn't left sitting // next to the failure message. diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index d5345af345..26787e62ae 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -162,11 +162,14 @@ async function handleInstall(e: any) { diag('installing version:', VERSION); if (!MANIFEST_VALID) { - // The manifest is missing/malformed - sendError already notified the page. Don't - // build a cache from an empty/partial asset list (it would replace the previous good - // cache with a broken one). Let the lifecycle settle without caching anything. - diag('*** skipping install - invalid assetsManifest.'); - return; + // The manifest is missing/malformed - sendError already notified the page. Reject the + // install so the SW lifecycle aborts: a worker that never built a valid cache must not + // reach the waiting/active state, otherwise a later SKIP_WAITING could activate it and + // run deleteOldCaches(), discarding the last-known-good cache and promoting a broken + // update. Throwing keeps the previous service worker in control until the manifest is + // fixed. + diag('*** aborting install - invalid assetsManifest.'); + throw new Error('Install aborted: service-worker-assets.js is missing or malformed.'); } sendMessage({ type: 'install', data: { version: VERSION, isPassive: self.isPassive } }); @@ -371,7 +374,10 @@ async function createAssetsCache(ignoreProgressReport = false) { const cacheKeys = await caches.keys(); if (!ignoreProgressReport) { - const oldCacheKey = cacheKeys.find(key => key.startsWith(CACHE_NAME_PREFIX)); + // Pick an *older* cache to warm-start from. Exclude CACHE_NAME itself: it shares the + // CACHE_NAME_PREFIX, and since we just opened it above it would otherwise be selected + // here, turning the copy loop into a no-op and forcing every asset to be re-downloaded. + const oldCacheKey = cacheKeys.find(key => key.startsWith(CACHE_NAME_PREFIX) && key !== CACHE_NAME); if (oldCacheKey) { diag('copying old cache:', oldCacheKey); const oldCache = await caches.open(oldCacheKey); diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index 32e70cc754..d1ce00c193 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -438,7 +438,9 @@ if (!BitBswup.initialized) { } function shouldLog(level: 'error' | 'warn' | 'info' | 'verbose' | 'debug'): boolean { - const configured = logLevels[options.log]; + // Normalize the configured level so values like `Info` or `WARN` still match the + // lowercase logLevels keys instead of silently falling back to the default. + const configured = logLevels[String(options.log).toLowerCase()]; // Unknown values fall back to `warn` (matches the documented default behavior). const threshold = configured == null ? logLevels.warn : configured; return logLevels[level] <= threshold; From 8c11506188b56261019a967674ffd3b92525c510 Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 6 Jun 2026 11:00:58 +0330 Subject: [PATCH 07/32] resovle review comments V --- src/Bswup/Bit.Bswup/BswupProgress.razor | 8 +- .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 20 +++ .../Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts | 11 ++ src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 135 ++++++++++++++---- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 12 ++ 5 files changed, 156 insertions(+), 30 deletions(-) diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index 5c038f3e7d..5d714981b0 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -1,4 +1,6 @@ -@code { +@using System.Text.Json + +@code { [Parameter] public RenderFragment ChildContent { get; set; } = default!; [Parameter] public bool AutoReload { get; set; } = true; @@ -40,10 +42,10 @@ @(AutoReload ? "true" : "false"), @(ShowLogs ? "true" : "false"), @(ShowAssets ? "true" : "false"), - '@(AppContainer)', + @((MarkupString)JsonSerializer.Serialize(AppContainer)), @(HideApp ? "true" : "false"), @(AutoHide ? "true" : "false"), - '@(Handler)'); + @((MarkupString)JsonSerializer.Serialize(Handler))); } else { console.error('BitBswupProgress not found'); } diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index ca7775704e..76cdc58215 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -1,6 +1,13 @@ window['bit-bswup.progress version'] = '10.4.5'; +// Default progress/splash UI for Bswup. This script registers the global +// `bitBswupHandler` that bit-bswup.ts calls with every BswupMessage, and drives the +// built-in splash markup (progress bar, percentage, asset log, reload/retry buttons, +// error panel) rendered by BswupProgress.razor. Apps can layer their own behavior by +// passing a custom `handler` name, which is invoked after the built-in handling. (function () { + // Live config overrides applied via BitBswupProgress.config(); each value, when set, + // takes precedence over the corresponding argument passed to start(). const _config: IBswupProgressConfigs = {}; (window as any).BitBswupProgress = { @@ -8,6 +15,16 @@ config }; + // Initializes the splash UI and installs the message handler. Called once from the + // generated startup markup with the app's display preferences: + // autoReload - reload automatically when an update finishes, instead of + // showing a manual reload button + // showLogs - console.log lifecycle messages + // showAssets - list each downloaded asset in the UI + // appContainerSelector - element whose visibility is toggled while installing + // hideApp - hide the app element during download + // autoHide - hide the splash automatically when the download finishes + // handler - optional name of a user handler invoked after the built-in one function start(autoReload: boolean, showLogs: boolean, showAssets: boolean, @@ -32,6 +49,9 @@ (window as any).bitBswupHandler = bitBswupHandler; const handlerFn = (handler ? window[handler] : undefined) as (message: any, data: any) => void; + // The global handler bit-bswup.ts invokes for every lifecycle message. It runs the + // built-in UI handling first, then forwards to the optional user handler (errors in + // the user handler are caught so they can't break the splash). function bitBswupHandler(message: string, data: any) { handleInternal(message, data); diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts index 2b095ab989..b3db8431ae 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts @@ -1,12 +1,23 @@ (self as any)['bit-bswup.sw-cleanup version'] = '10.4.5'; +// Self-destructing "uninstall" service worker. Deploy this in place of the real +// bit-bswup.sw.js when an app needs to fully back out of Bswup (e.g. switching a site away +// from offline support, or recovering clients stuck on a broken worker/cache). On install +// it wipes every Bswup/Blazor cache, immediately takes over all clients, and tells each one +// to unregister and reload - leaving the app running purely from the network with no SW. self.addEventListener('install', e => e.waitUntil(removeBswup())); +// Purges the caches this library (and Blazor) created, then activates immediately and +// signals every open client to tear down. Runs once, at install time. async function removeBswup() { const cacheKeys = await caches.keys(); const cachePromises = cacheKeys.filter(key => key.startsWith('bit-bswup') || key.startsWith('blazor-resources')).map(key => caches.delete(key)); await Promise.all(cachePromises); + // skipWaiting() so this cleanup worker activates without waiting for existing clients to + // close, then message every client (controlled or not) to unregister itself. The delayed + // 'WAITING_SKIPPED' nudge is a fallback reload signal for clients that don't act on + // 'UNREGISTER' fast enough, so no tab is left running against the now-deleted caches. self.skipWaiting().then(() => self.clients .matchAll({ includeUncontrolled: true }) .then(clients => (clients || []).forEach(client => { diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index 26787e62ae..e342fbd9b7 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -1,38 +1,43 @@ (self as any)['bit-bswup.sw version'] = '10.4.5'; +// Configuration surface for the service worker. Every field is read off the global `self` +// at startup and is meant to be set by the app *before* this script runs - typically by the +// generated service-worker.js that defines these values and then importScripts() this file. +// All are typed `any` because they arrive untyped from that generated/host script. interface Window { clients: any skipWaiting: any importScripts: any - assetsManifest: any - - assetsInclude: any - assetsExclude: any - externalAssets: any - defaultUrl: any - assetsUrl: any - prohibitedUrls: any - caseInsensitiveUrl: any - serverHandledUrls: any - serverRenderedUrls: any - noPrerenderQuery: any - ignoreDefaultInclude: any - ignoreDefaultExclude: any - isPassive: any - enableIntegrityCheck: any - errorTolerance: any - maxRetries: any - retryDelay: any - enableDiagnostics: any - enableFetchDiagnostics: any - disableHashlessAssetsUpdate: any - forcePrerender: any - enableCacheControl: any - cacheVersion: any - - mode: any + assetsManifest: any // injected by service-worker-assets.js (version + asset list) + + assetsInclude: any // extra RegExp(s) of asset URLs to precache + assetsExclude: any // RegExp(s) of asset URLs to skip + externalAssets: any // additional (often cross-origin) assets to cache + defaultUrl: any // document served for navigation requests (SPA fallback) + assetsUrl: any // path to service-worker-assets.js (default '/service-worker-assets.js') + prohibitedUrls: any // RegExp(s) that must always be answered with 403 + caseInsensitiveUrl: any // match asset URLs case-insensitively + serverHandledUrls: any // RegExp(s) bypassed straight to the network (server owns them) + serverRenderedUrls: any // RegExp(s) of navigations that must NOT get the SPA fallback + noPrerenderQuery: any // query appended to defaultUrl to request the non-prerendered doc + ignoreDefaultInclude: any // drop the built-in DEFAULT_ASSETS_INCLUDE list + ignoreDefaultExclude: any // drop the built-in DEFAULT_ASSETS_EXCLUDE list + isPassive: any // passive: don't precache on install, fill cache lazily on fetch + enableIntegrityCheck: any // attach SRI `integrity` to asset requests from the manifest hash + errorTolerance: any // 'strict' (fail install on any error) | 'lax' (best-effort) + maxRetries: any // extra download attempts after the first on transient failure + retryDelay: any // base backoff (ms) between retries + enableDiagnostics: any // verbose console grouping/logging of install/activate + enableFetchDiagnostics: any // verbose console logging on every fetch (noisy) + disableHashlessAssetsUpdate: any // don't re-download cached assets that have no hash + forcePrerender: any // always hit the network for the default doc (server prerender) + enableCacheControl: any // add no-store/no-cache to asset requests (bypass HTTP cache) + cacheVersion: any // override the version used in the cache bucket name + mode: any // preset bundle of the above (see the switch below) } +// Minimal shape of the ExtendableEvent / FetchEvent surface we use. Declared locally so the +// install/activate/fetch handlers can call waitUntil()/respondWith() without DOM lib types. interface Event { waitUntil: any respondWith: any @@ -95,6 +100,10 @@ const CACHE_NAME = `${CACHE_NAME_PREFIX} - ${CACHE_VERSION}`; let integrityFailureCount = 0; +// Named presets that expand into a coherent bundle of the individual self.* settings, so an +// app can pick a caching strategy with a single `mode` value instead of wiring each flag. +// The comment beside each case names a representative app using that strategy. Every preset +// uses ||= so any value the app set explicitly still wins over the preset default. switch (self.mode) { case 'NoPrerender': // like adminpanel self.isPassive = true; @@ -153,6 +162,10 @@ const RETRY_DELAY = normalizeNonNegativeInt(self.retryDelay, 300); diag('MAX_RETRIES:', MAX_RETRIES, 'RETRY_DELAY:', RETRY_DELAY); +// Wire up the four service-worker lifecycle/runtime events. install/activate extend the +// event with waitUntil() so the browser keeps the worker alive until our async work +// settles; fetch uses respondWith() to take over the response; message handles the +// page<->worker commands (SKIP_WAITING, CLAIM_CLIENTS, BLAZOR_STARTED, CLEAN_UP). self.addEventListener('install', e => e.waitUntil(handleInstall(e))); self.addEventListener('activate', e => e.waitUntil(handleActivate(e))); self.addEventListener('fetch', e => e.respondWith(handleFetch(e))); @@ -239,6 +252,16 @@ diag('UNIQUE_ASSETS:', UNIQUE_ASSETS); diagGroupEnd(); +// Runtime request router. For every GET this decides whether to serve the request from the +// Bswup cache, fall back to the SPA default document, or pass the request straight to the +// network. High-level flow: +// 1. Block prohibited URLs (403) and pass through non-GET / server-handled requests. +// 2. For navigations, substitute the default document unless the URL is server-rendered or +// forcePrerender is on (then the server owns the HTML). +// 3. Resolve the request URL to a known asset (with a fallback that strips ?asp-append-version +// style query versioning), and serve it from cache when present. +// 4. In passive mode a cache miss is fetched from the network and lazily written to the +// cache in the background; in active mode a miss simply goes to the network. async function handleFetch(e: any) { const req = e.request as Request; @@ -330,6 +353,8 @@ async function handleFetch(e: any) { return response; } +// Handles commands posted from the page (bit-bswup.ts). Each branch corresponds to a string +// command in the page<->worker protocol; non-matching JSON messages are ignored. function handleMessage(e: MessageEvent) { diag('handleMessage:', e); @@ -367,6 +392,16 @@ function handleMessage(e: MessageEvent) { // ============================================================================ +// Builds (or updates) the version-suffixed cache for the current VERSION. This is the heart +// of the install/update flow: +// - Warm-starts from the previous cache by copying over still-valid entries so an update +// only re-downloads what actually changed (skipped when ignoreProgressReport is set). +// - Diffs the existing cache against UNIQUE_ASSETS: removes assets no longer in the +// manifest, re-downloads ones whose hash changed (and always refreshes the default doc). +// - Downloads the remaining assets with retry/backoff, reporting progress to the page and +// surfacing integrity/network failures via sendError. +// `ignoreProgressReport` is true for the post-BLAZOR_STARTED top-up pass: that run must not +// report progress to the UI and must never reject (the install has already activated). async function createAssetsCache(ignoreProgressReport = false) { diagGroup('bit-bswup:createAssetsCache:' + ignoreProgressReport); @@ -605,6 +640,9 @@ async function createAssetsCache(ignoreProgressReport = false) { } } +// Cache key for an asset: the URL suffixed with `.` when a hash exists, so a changed +// hash produces a distinct cache entry (the old one is detected and evicted during the +// update diff in createAssetsCache). Hashless assets are keyed by URL alone. function createCacheUrl(asset: any) { return asset.hash ? `${asset.url}.${asset.hash}` : asset.url; } @@ -644,6 +682,13 @@ function urlEndsWith(value: string, suffix: string) { return value.endsWith(suffix); } +// Builds the network Request used to download an asset. The asset version (its own hash, or +// the manifest version as a fallback) is base64url-normalized and appended as a `?v=` cache +// buster so each published version is fetched distinctly. For the default document the +// optional noPrerenderQuery params are added to request the non-prerendered variant. When +// the hash is an SRI digest (sha*) and integrity checks are enabled, the request carries the +// `integrity` attribute so the browser rejects tampered/mismatched bytes; enableCacheControl +// adds no-store/no-cache headers to bypass the HTTP cache and force a fresh fetch. function createNewAssetRequest(asset: any) { const version = ((asset.hash || self.assetsManifest.version) as string).replaceAll('+', '-').replaceAll('/', '_'); const trimmedVersion = encodeURIComponent(trimEnd(version, '=')); @@ -668,12 +713,18 @@ function createNewAssetRequest(asset: any) { return new Request(assetUrl, requestInit); } +// Removes every Bswup cache except the current CACHE_NAME. Called after a new worker claims +// clients (SKIP_WAITING / CLAIM_CLIENTS) and on the CLEAN_UP command, so stale +// version-suffixed caches from previous installs are reclaimed once they're no longer needed. async function deleteOldCaches() { const cacheKeys = await caches.keys(); const promises = cacheKeys.filter(key => (key.startsWith(CACHE_NAME_PREFIX) && key !== CACHE_NAME)).map(key => caches.delete(key)); return Promise.all(promises); } +// De-duplicates the asset list by URL (the first occurrence wins) and, as a side effect, +// precomputes `reqUrl` - the fully-resolved absolute URL the browser will actually request - +// so handleFetch can match incoming requests without re-resolving on every fetch. function uniqueAssets(assets: any) { const unique = {} as any; const distinct = []; @@ -688,12 +739,18 @@ function uniqueAssets(assets: any) { return distinct; } +// Broadcasts a message to every client (controlled or not), so all open tabs - not just the +// one that triggered the work - receive install/progress/activate/error updates. Objects are +// JSON-stringified; plain string commands (e.g. 'WAITING_SKIPPED') are sent as-is. function sendMessage(message: any) { self.clients .matchAll({ includeUncontrolled: true }) .then((clients: any) => (clients || []).forEach((client: any) => client.postMessage(typeof message === 'string' ? message : JSON.stringify(message)))); } +// Reports a structured install/runtime failure: logs it for diagnostics, also writes to the +// console as a best-effort signal in case no client is connected yet, then forwards it to the +// page as an 'error' message so the progress UI can show it (see bit-bswup.progress.ts). function sendError(data: { reason: string; message: string;[key: string]: any }) { diag('*** error:', data); try { @@ -719,6 +776,10 @@ function normalizeAssetsManifest(manifest: any) { return safe; } +// Validates the manifest injected by service-worker-assets.js and returns a list of human +// readable problems (empty array == valid). Checks it's an object, has a non-empty version +// string, has an assets array, and that every asset entry carries a url. The result gates +// MANIFEST_VALID, which in turn decides whether the worker is allowed to cache/activate. function validateAssetsManifest(manifest: any): string[] { const errors: string[] = []; if (!manifest || typeof manifest !== 'object') { @@ -743,6 +804,10 @@ function validateAssetsManifest(manifest: any): string[] { return errors; } +// Normalizes self.externalAssets into a consistent array of `{ url, ... }` objects. Accepts a +// single value or an array, passes through entries that already have a url, wraps bare +// strings into `{ url }`, and drops anything else (null/invalid) so the precache list only +// contains well-formed asset descriptors. function prepareExternalAssetsArray(value: any) { const array = value ? (value instanceof Array ? value : [value]) : []; @@ -760,6 +825,15 @@ function prepareExternalAssetsArray(value: any) { } function prepareRegExpArray(value: any) { + // Threat model: the patterns here come from developer-configured sources + // (self.prohibitedUrls, self.serverHandledUrls, etc.), not end-user input. + // They are compiled into RegExp objects and run against URLs on every request, + // so a pathological pattern can cause catastrophic backtracking (ReDoS) and + // stall the service worker. When authoring patterns: + // - avoid nested/overlapping quantifiers such as (a+)+, (a*)*, (.*)* + // - prefer anchored, specific patterns over broad .* wildcards + // - keep pattern length bounded; very long patterns are a smell + // Invalid patterns are caught below and skipped rather than throwing. const array = value ? (value instanceof Array ? value : [value]) : []; return array.map(p => { @@ -781,11 +855,18 @@ function prepareRegExpArray(value: any) { }).filter((p): p is RegExp => p !== null); } +// Strips trailing occurrences of `char` from the end of `str`. Used to drop base64 `=` +// padding from version/hash values before they go into the `?v=` query. `char` is regex +// escaped first so callers can pass literal characters safely. function trimEnd(str: string, char: string) { const escaped = char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escape regex special chars return str.replace(new RegExp(`${escaped}+$`), ""); } +// Diagnostics helpers - all no-ops unless the matching flag is enabled, so verbose logging +// can be turned on per app without code changes. diag*/diagGroup* gate on enableDiagnostics; +// diagFetch gates on the separate (noisier) enableFetchDiagnostics. diag/diagFetch append an +// ISO timestamp to every line to make install/fetch timing legible in the console. function diagGroup(label: string) { if (!self.enableDiagnostics) return; diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index d1ce00c193..d41fcb4b74 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -177,6 +177,12 @@ if (!BitBswup.initialized) { } else { info('initialization finished.'); // first install } + + // Notify listeners that an update is staged and ready. The + // registration-time check only fires updateReady for updates already + // waiting on load; updates discovered in the same session surface here + // instead, so emit it for them too. + handle(BswupMessage.updateReady, { reload }); }); }); } @@ -238,6 +244,12 @@ if (!BitBswup.initialized) { Blazor.start().then(() => { blazorStartResolver?.(undefined); e.source.postMessage('BLAZOR_STARTED'); + }).catch((err) => { + error('Blazor.start() failed after clients claimed', err); + // Always settle the pending reload() promise so callers can't hang, and + // notify the worker that startup failed (mirrors the success path). + blazorStartResolver?.(undefined); + e.source.postMessage('BLAZOR_START_FAILED'); }); return; } From ea3d330a61d3833758979f8e401ae65a8813c193 Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 6 Jun 2026 13:18:51 +0330 Subject: [PATCH 08/32] resolve review comments VI --- src/Bswup/Bit.Bswup/BswupProgress.razor | 4 +--- src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 6 ++++-- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 9 ++++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index 5d714981b0..bd0db6f10b 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -1,6 +1,4 @@ -@using System.Text.Json - -@code { +@code { [Parameter] public RenderFragment ChildContent { get; set; } = default!; [Parameter] public bool AutoReload { get; set; } = true; diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index e342fbd9b7..0234091f68 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -195,8 +195,10 @@ async function handleInstall(e: any) { } else { // Lax: lifecycle proceeds immediately; missing assets are filled lazily by // handleFetch. This preserves best-effort behavior for callers that explicitly - // opt in via errorTolerance: 'lax'. - createAssetsCache(); + // opt in via errorTolerance: 'lax'. We deliberately do not await, but still + // attach a .catch so a rejection is logged rather than surfacing as an unhandled + // promise rejection - lifecycle progression is unaffected. + createAssetsCache().catch(err => diag('*** createAssetsCache failed (lax):', err)); } } diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index d41fcb4b74..debe4f8f7c 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -262,7 +262,14 @@ if (!BitBswup.initialized) { return; } - const message = JSON.parse(e.data); + let message: any; + try { + if (typeof e.data !== 'string') return; + message = JSON.parse(e.data); + } catch { + return; + } + const { type, data } = message; if (type === 'install') { From a71d04f3b9d3623c30711bcc23e586ea8a2e107e Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 6 Jun 2026 13:25:04 +0330 Subject: [PATCH 09/32] remove json serializer --- src/Bswup/Bit.Bswup/BswupProgress.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index bd0db6f10b..f735e323f2 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -40,10 +40,10 @@ @(AutoReload ? "true" : "false"), @(ShowLogs ? "true" : "false"), @(ShowAssets ? "true" : "false"), - @((MarkupString)JsonSerializer.Serialize(AppContainer)), + @(AppContainer), @(HideApp ? "true" : "false"), @(AutoHide ? "true" : "false"), - @((MarkupString)JsonSerializer.Serialize(Handler))); + @(Handler)); } else { console.error('BitBswupProgress not found'); } From ddd6829edb9ee03c7a14f1929f33a1c6113d3bbb Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 6 Jun 2026 13:52:05 +0330 Subject: [PATCH 10/32] fix progress component --- src/Bswup/Bit.Bswup/BswupProgress.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index f735e323f2..5c038f3e7d 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -40,10 +40,10 @@ @(AutoReload ? "true" : "false"), @(ShowLogs ? "true" : "false"), @(ShowAssets ? "true" : "false"), - @(AppContainer), + '@(AppContainer)', @(HideApp ? "true" : "false"), @(AutoHide ? "true" : "false"), - @(Handler)); + '@(Handler)'); } else { console.error('BitBswupProgress not found'); } From 9d73fbf266d4f1a31b39509535cf3beb3c27c2a1 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Sun, 14 Jun 2026 05:53:02 +0330 Subject: [PATCH 11/32] resolve local review findimgs --- src/Bswup/Bit.Bswup/Bit.Bswup.csproj | 33 ++-- src/Bswup/Bit.Bswup/BswupProgress.razor | 48 +++-- .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 50 ++++- .../Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts | 17 +- src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 179 ++++++++++++++---- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 150 ++++++++++++--- src/Bswup/Bit.Bswup/tsconfig.json | 6 +- src/Bswup/Bit.Bswup/tsconfig.sw.json | 10 + src/Bswup/README.md | 2 +- 9 files changed, 381 insertions(+), 114 deletions(-) create mode 100644 src/Bswup/Bit.Bswup/tsconfig.sw.json diff --git a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj index 0335bfd2f5..9f25f00ebb 100644 --- a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj +++ b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj @@ -5,7 +5,13 @@ net10.0;net9.0;net8.0 true - + + $(TargetFrameworks.Split(';')[0]) + BeforeBuildTasks; $(ResolveStaticWebAssetsInputsDependsOn) @@ -21,16 +27,14 @@ + - - + + - - - - - + @@ -40,8 +44,12 @@ - + + + @@ -51,11 +59,4 @@ - - - - - - - \ No newline at end of file diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index 5c038f3e7d..e00d8b6dd8 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -10,18 +10,44 @@ [Parameter] public string? Handler { get; set; } } -
    - @if (ChildContent is not null) +@* Configuration is published as data-* attributes and read by bit-bswup.progress.js when it + loads (it self-initializes from these attributes). This deliberately avoids emitting an + inline
    diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index efb00be7e5..9f43b7fec1 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -66,28 +66,30 @@ hideApp_ && appEl && (appEl.style.display = 'none'); bswupEl && (bswupEl.style.display = 'block'); - if (showAssets_ && assetsEl) { + if (showAssets_ && assetsEl && data.asset) { const li = document.createElement('li'); li.innerHTML = `${data.index}: ${data.asset.url}: ${data.asset.hash}` assetsEl.prepend(li); - } - const percent = Math.round(data.percent); + } const percent = Math.round(data.percent); const perStr = `${percent}%`; bswupEl && bswupEl.style.setProperty('--bit-bswup-percent', perStr) bswupEl && bswupEl.style.setProperty('--bit-bswup-percent-text', `"${perStr}"`) progressEl && (progressEl.style.width = `${percent}%`); + // Keep the ARIA value in sync with the visual bar so assistive + // technology announces progress, not just a static 0%. + progressEl && progressEl.setAttribute('aria-valuenow', String(percent)); percentEl && (percentEl.innerHTML = `${percent}%`); return showLogs_ ? console.log('asset downloaded:', data) : undefined; case BswupMessage.downloadFinished: if (autoHide_) { - hideApp && appEl && (appEl.style.display = appElOriginalDisplay); + hideApp_ && appEl && (appEl.style.display = appElOriginalDisplay); bswupEl && (bswupEl.style.display = 'none'); } if (autoReload_ || data.firstInstall) { data.reload().then(() => { - hideApp && appEl && (appEl.style.display = appElOriginalDisplay); + hideApp_ && appEl && (appEl.style.display = appElOriginalDisplay); bswupEl && (bswupEl.style.display = 'none'); }); } else { @@ -170,6 +172,44 @@ if (assetsEl) assetsEl.style.display = newConfig.showAssets ? 'block' : 'none'; } } + + // Self-initialize from the data-* attributes rendered by the BswupProgress Razor + // component. This replaces the inline - + @* Date: Tue, 30 Jun 2026 07:04:09 +0330 Subject: [PATCH 22/32] improve FullDemo --- src/Bswup/FullDemo/Client/App.razor | 11 ---- .../Client/Bit.Bswup.FullDemo.Client.csproj | 5 +- .../Client/Pages/{Index.razor => Home.razor} | 6 +-- .../Client/Properties/launchSettings.json | 11 ---- src/Bswup/FullDemo/Client/Routes.razor | 12 +++++ .../FullDemo/Client/Shared/MainLayout.razor | 9 ++++ src/Bswup/FullDemo/Client/_Imports.razor | 20 ++++---- .../Server/Bit.Bswup.FullDemo.Server.csproj | 1 + .../FullDemo/Server/Components/App.razor | 51 +++++++++++++++++++ .../FullDemo/Server/Components/_Imports.razor | 13 +++++ src/Bswup/FullDemo/Server/Pages/_Host.cshtml | 14 ----- .../FullDemo/Server/Pages/_Layout.cshtml | 44 ---------------- src/Bswup/FullDemo/Server/Program.cs | 51 +++++++++++++++---- .../FullDemo/Server/Startup/Middlewares.cs | 49 ------------------ src/Bswup/FullDemo/Server/Startup/Services.cs | 28 ---------- 15 files changed, 145 insertions(+), 180 deletions(-) delete mode 100644 src/Bswup/FullDemo/Client/App.razor rename src/Bswup/FullDemo/Client/Pages/{Index.razor => Home.razor} (81%) delete mode 100644 src/Bswup/FullDemo/Client/Properties/launchSettings.json create mode 100644 src/Bswup/FullDemo/Client/Routes.razor create mode 100644 src/Bswup/FullDemo/Client/Shared/MainLayout.razor create mode 100644 src/Bswup/FullDemo/Server/Components/App.razor create mode 100644 src/Bswup/FullDemo/Server/Components/_Imports.razor delete mode 100644 src/Bswup/FullDemo/Server/Pages/_Host.cshtml delete mode 100644 src/Bswup/FullDemo/Server/Pages/_Layout.cshtml delete mode 100644 src/Bswup/FullDemo/Server/Startup/Middlewares.cs delete mode 100644 src/Bswup/FullDemo/Server/Startup/Services.cs diff --git a/src/Bswup/FullDemo/Client/App.razor b/src/Bswup/FullDemo/Client/App.razor deleted file mode 100644 index cbbe99ed6c..0000000000 --- a/src/Bswup/FullDemo/Client/App.razor +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - Not found -

    Sorry, there's nothing at this address.

    -
    -
    -
    \ No newline at end of file diff --git a/src/Bswup/FullDemo/Client/Bit.Bswup.FullDemo.Client.csproj b/src/Bswup/FullDemo/Client/Bit.Bswup.FullDemo.Client.csproj index f9655aea06..3546a3c7ce 100644 --- a/src/Bswup/FullDemo/Client/Bit.Bswup.FullDemo.Client.csproj +++ b/src/Bswup/FullDemo/Client/Bit.Bswup.FullDemo.Client.csproj @@ -4,14 +4,15 @@ net10.0 enable enable + true + Default service-worker-assets.js + false
    - - diff --git a/src/Bswup/FullDemo/Client/Pages/Index.razor b/src/Bswup/FullDemo/Client/Pages/Home.razor similarity index 81% rename from src/Bswup/FullDemo/Client/Pages/Index.razor rename to src/Bswup/FullDemo/Client/Pages/Home.razor index efe3388f3f..d0d3530273 100644 --- a/src/Bswup/FullDemo/Client/Pages/Index.razor +++ b/src/Bswup/FullDemo/Client/Pages/Home.razor @@ -1,6 +1,6 @@ -@page "/" +@page "/" -Index +Home

    111

    @@ -14,4 +14,4 @@ - \ No newline at end of file + diff --git a/src/Bswup/FullDemo/Client/Properties/launchSettings.json b/src/Bswup/FullDemo/Client/Properties/launchSettings.json deleted file mode 100644 index 22db3fdc2d..0000000000 --- a/src/Bswup/FullDemo/Client/Properties/launchSettings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "profiles": { - "Bit.Bswup.FullDemo.Client": { - "commandName": "Project", - "dotnetRunMessages": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} \ No newline at end of file diff --git a/src/Bswup/FullDemo/Client/Routes.razor b/src/Bswup/FullDemo/Client/Routes.razor new file mode 100644 index 0000000000..aae7e56222 --- /dev/null +++ b/src/Bswup/FullDemo/Client/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

    Sorry, there's nothing at this address.

    +
    +
    +
    diff --git a/src/Bswup/FullDemo/Client/Shared/MainLayout.razor b/src/Bswup/FullDemo/Client/Shared/MainLayout.razor new file mode 100644 index 0000000000..4231bc9974 --- /dev/null +++ b/src/Bswup/FullDemo/Client/Shared/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
    + An unhandled error has occurred. + Reload + 🗙 +
    diff --git a/src/Bswup/FullDemo/Client/_Imports.razor b/src/Bswup/FullDemo/Client/_Imports.razor index 0153f5c93c..67c122e5d9 100644 --- a/src/Bswup/FullDemo/Client/_Imports.razor +++ b/src/Bswup/FullDemo/Client/_Imports.razor @@ -1,9 +1,11 @@ -@using System; -@using System.Net.Http; -@using System.Reflection; -@using System.Net.Http.Json; -@using Microsoft.JSInterop; -@using Microsoft.AspNetCore.Components.Web; -@using Microsoft.AspNetCore.Components.Forms; -@using Microsoft.AspNetCore.Components.Routing; -@using Bit.Bswup.FullDemo.Client; \ No newline at end of file +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Bit.Bswup +@using Bit.Bswup.FullDemo.Client +@using Bit.Bswup.FullDemo.Client.Shared diff --git a/src/Bswup/FullDemo/Server/Bit.Bswup.FullDemo.Server.csproj b/src/Bswup/FullDemo/Server/Bit.Bswup.FullDemo.Server.csproj index 818c758fc2..976063d526 100644 --- a/src/Bswup/FullDemo/Server/Bit.Bswup.FullDemo.Server.csproj +++ b/src/Bswup/FullDemo/Server/Bit.Bswup.FullDemo.Server.csproj @@ -2,6 +2,7 @@ net10.0 + enable enable diff --git a/src/Bswup/FullDemo/Server/Components/App.razor b/src/Bswup/FullDemo/Server/Components/App.razor new file mode 100644 index 0000000000..608c6b4790 --- /dev/null +++ b/src/Bswup/FullDemo/Server/Components/App.razor @@ -0,0 +1,51 @@ +@code { + [CascadingParameter] HttpContext HttpContext { get; set; } = default!; +} + +@{ + // Honors the Bswup service-worker "no-prerender" escape hatch: when the query string is + // present the page is rendered as interactive WebAssembly without a prerendered pass. + var noPrerender = HttpContext.Request.Query["no-prerender"].Count > 0; + var renderMode = new InteractiveWebAssemblyRenderMode(prerender: !noPrerender); +} + + + + + + + + bit Bswup Full Demo + + + + + + + + +
    + +
    + + + + + + + + + @* + *@ + + + diff --git a/src/Bswup/FullDemo/Server/Components/_Imports.razor b/src/Bswup/FullDemo/Server/Components/_Imports.razor new file mode 100644 index 0000000000..23aacbed1f --- /dev/null +++ b/src/Bswup/FullDemo/Server/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Bit.Bswup +@using Bit.Bswup.FullDemo.Server +@using Bit.Bswup.FullDemo.Server.Components +@using Bit.Bswup.FullDemo.Client +@using Bit.Bswup.FullDemo.Client.Shared diff --git a/src/Bswup/FullDemo/Server/Pages/_Host.cshtml b/src/Bswup/FullDemo/Server/Pages/_Host.cshtml deleted file mode 100644 index 82b1bcd310..0000000000 --- a/src/Bswup/FullDemo/Server/Pages/_Host.cshtml +++ /dev/null @@ -1,14 +0,0 @@ -@page "/" -@namespace Bit.Bswup.Demo.Web.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers - -@using RenderMode = Microsoft.AspNetCore.Mvc.Rendering.RenderMode - -@{ - Layout = "_Layout"; - - var noPrerender = Request.Query["no-prerender"].Count > 0; - var renderMode = noPrerender ? RenderMode.WebAssembly : RenderMode.WebAssemblyPrerendered; -} - - \ No newline at end of file diff --git a/src/Bswup/FullDemo/Server/Pages/_Layout.cshtml b/src/Bswup/FullDemo/Server/Pages/_Layout.cshtml deleted file mode 100644 index 2b1e63e5eb..0000000000 --- a/src/Bswup/FullDemo/Server/Pages/_Layout.cshtml +++ /dev/null @@ -1,44 +0,0 @@ -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@namespace Bit.Bswup.Demo.Web.Pages - -@using Bit.Bswup.Demo.Web -@using Microsoft.AspNetCore.Http -@using Microsoft.AspNetCore.Components.Web -@using RenderMode = Microsoft.AspNetCore.Mvc.Rendering.RenderMode - - - - - - - bit Bswup Full Demo - - - - - - -
    - @RenderBody() -
    - - - - - - - - @* - *@ - - \ No newline at end of file diff --git a/src/Bswup/FullDemo/Server/Program.cs b/src/Bswup/FullDemo/Server/Program.cs index 83dd34b452..b05ecc436f 100644 --- a/src/Bswup/FullDemo/Server/Program.cs +++ b/src/Bswup/FullDemo/Server/Program.cs @@ -1,20 +1,53 @@ -var builder = WebApplication.CreateBuilder(args); +using System.IO.Compression; +using Bit.Bswup.FullDemo.Server.Components; +using Microsoft.AspNetCore.ResponseCompression; -#if DEBUG -if (OperatingSystem.IsWindows()) +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddCors(); +builder.Services.AddControllers(); +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddResponseCompression(opts => +{ + opts.EnableForHttps = true; + opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]); + opts.Providers.Add(); + opts.Providers.Add(); +}) + .Configure(opt => opt.Level = CompressionLevel.Fastest) + .Configure(opt => opt.Level = CompressionLevel.Fastest); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) { - builder.WebHost.UseUrls("https://localhost:5021", "http://localhost:5020", "https://*:5021", "http://*:5020"); + app.UseWebAssemblyDebugging(); } else { - builder.WebHost.UseUrls("https://localhost:5021", "http://localhost:5020"); + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + app.UseResponseCompression(); } -#endif -Bit.Bswup.FullDemo.Server.Startup.Services.Add(builder.Services); +app.UseHttpsRedirection(); -var app = builder.Build(); +app.UseCors(options => options.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); + +app.MapStaticAssets(); +app.UseAntiforgery(); + +app.MapControllers(); -Bit.Bswup.FullDemo.Server.Startup.Middlewares.Use(app, builder.Environment); +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(Bit.Bswup.FullDemo.Client._Imports).Assembly); app.Run(); diff --git a/src/Bswup/FullDemo/Server/Startup/Middlewares.cs b/src/Bswup/FullDemo/Server/Startup/Middlewares.cs deleted file mode 100644 index 7cad461fb4..0000000000 --- a/src/Bswup/FullDemo/Server/Startup/Middlewares.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.Net.Http.Headers; - -namespace Bit.Bswup.FullDemo.Server.Startup; - -public static class Middlewares -{ - public static void Use(IApplicationBuilder app, IHostEnvironment env) - { - //app.Use(async (context, next) => - //{ - // await Task.Delay(new Random().Next(500, 800)); - // await next.Invoke(context); - //}); - - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - app.UseWebAssemblyDebugging(); - } - - app.UseBlazorFrameworkFiles(); - - if (env.IsDevelopment() is false) - { - app.UseResponseCompression(); - } - app.UseStaticFiles(new StaticFileOptions - { - OnPrepareResponse = ctx => - { - ctx.Context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromDays(7), - Public = true - }; - } - }); - - app.UseRouting(); - app.UseCors(options => options.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); - - app.UseEndpoints(endpoints => - { - endpoints.MapDefaultControllerRoute(); - - endpoints.MapFallbackToPage("/_Host"); - }); - } -} diff --git a/src/Bswup/FullDemo/Server/Startup/Services.cs b/src/Bswup/FullDemo/Server/Startup/Services.cs deleted file mode 100644 index cca6176778..0000000000 --- a/src/Bswup/FullDemo/Server/Startup/Services.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.IO.Compression; -using Microsoft.AspNetCore.ResponseCompression; - -namespace Bit.Bswup.FullDemo.Server.Startup; - -public static class Services -{ - public static void Add(IServiceCollection services) - { - services.AddRazorPages(); - - services.AddCors(); - - services.AddControllers(); - - services.AddHttpContextAccessor(); - - services.AddResponseCompression(opts => - { - opts.EnableForHttps = true; - opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] { "application/octet-stream" }).ToArray(); - opts.Providers.Add(); - opts.Providers.Add(); - }) - .Configure(opt => opt.Level = CompressionLevel.Fastest) - .Configure(opt => opt.Level = CompressionLevel.Fastest); - } -} From 571f53707000ba1b04db5457a09916950ab54280 Mon Sep 17 00:00:00 2001 From: msynk Date: Fri, 3 Jul 2026 13:36:09 +0330 Subject: [PATCH 23/32] resolve review comments XII --- .../Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts index 8fdecacd2d..54f8616c0f 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts @@ -11,36 +11,35 @@ interface Window extends BitBswupGlobals { } // Self-destructing "uninstall" service worker. Deploy this in place of the real // bit-bswup.sw.js when an app needs to fully back out of Bswup (e.g. switching a site away -// from offline support, or recovering clients stuck on a broken worker/cache). On install -// it wipes every Bswup/Blazor cache, immediately takes over all clients, and tells each one -// to unregister and reload - leaving the app running purely from the network with no SW. -self.addEventListener('install', (e: any) => e.waitUntil(removeBswup())); +// from offline support, or recovering clients stuck on a broken worker/cache). It takes over +// all clients, wipes every Bswup/Blazor cache once in control, and tells each one to +// unregister and reload - leaving the app running purely from the network with no SW. -// Take over all clients and signal teardown only once this worker has *activated* - not -// during install. Sending the reload signal at activate time guarantees the cleanup worker -// is fully in control before any tab is told to unregister/reload, so no client reloads -// against a half-installed worker. +// On install, only skipWaiting() so this cleanup worker activates without waiting for existing +// clients to close. All teardown work (cache purge + client notification) is deferred to the +// 'activate' handler below, once this worker is actually in control. +self.addEventListener('install', (e: any) => e.waitUntil(self.skipWaiting())); + +// Take over all clients and run teardown only once this worker has *activated* - not during +// install. Doing the cache purge at activate time (after clients.claim()) guarantees the +// cleanup worker is fully in control before its caches disappear, so no controlled tab is left +// using a worker whose caches were already purged. self.addEventListener('activate', (e: any) => e.waitUntil(teardownClients())); -// Purges the caches this library (and Blazor) created, then activates immediately. Runs once, -// at install time. Client teardown signalling happens later, in the activate handler. -async function removeBswup() { +// Activate-time teardown: claim every client, purge the caches this library (and Blazor) +// created, then message each (controlled or not) to unregister itself. The delayed +// 'WAITING_SKIPPED' nudge is a fallback reload signal for clients that don't act on +// 'UNREGISTER' fast enough, so no tab is left running against the now-deleted caches. Await the +// whole chain so the activate event (which waits on this via waitUntil) doesn't resolve before +// the teardown signalling has actually been dispatched. +async function teardownClients() { + // Claim first so controlled tabs are served by this fetch-less worker (straight from the + // network) before their caches vanish, then purge the Bswup/Blazor caches. + await self.clients.claim(); + const cacheKeys = await caches.keys(); const cachePromises = cacheKeys.filter(key => key.startsWith('bit-bswup') || key.startsWith('blazor-resources')).map(key => caches.delete(key)); await Promise.all(cachePromises); - - // skipWaiting() so this cleanup worker activates without waiting for existing clients to - // close. The actual client notification is deferred to the 'activate' event below. - await self.skipWaiting(); -} - -// Activate-time teardown: claim every client, then message each (controlled or not) to -// unregister itself. The delayed 'WAITING_SKIPPED' nudge is a fallback reload signal for -// clients that don't act on 'UNREGISTER' fast enough, so no tab is left running against the -// now-deleted caches. Await the whole chain so the activate event (which waits on this via -// waitUntil) doesn't resolve before the teardown signalling has actually been dispatched. -async function teardownClients() { - await self.clients.claim(); // Only target window clients that belong to this registration's scope. matchAll with // includeUncontrolled returns every same-origin client (including those under other // scopes / mounted sub-apps and non-window clients like workers); broadcasting From 968fc9993df6997d5b63ccbd12754c1b46ebd889 Mon Sep 17 00:00:00 2001 From: msynk Date: Fri, 3 Jul 2026 15:10:43 +0330 Subject: [PATCH 24/32] resolve review comments XIII --- src/Bswup/FullDemo/Server/Components/App.razor | 7 ------- src/Bswup/FullDemo/Server/Program.cs | 3 --- 2 files changed, 10 deletions(-) diff --git a/src/Bswup/FullDemo/Server/Components/App.razor b/src/Bswup/FullDemo/Server/Components/App.razor index 608c6b4790..556bc4f45e 100644 --- a/src/Bswup/FullDemo/Server/Components/App.razor +++ b/src/Bswup/FullDemo/Server/Components/App.razor @@ -39,13 +39,6 @@ - - @* - *@ diff --git a/src/Bswup/FullDemo/Server/Program.cs b/src/Bswup/FullDemo/Server/Program.cs index b05ecc436f..37bf9cc289 100644 --- a/src/Bswup/FullDemo/Server/Program.cs +++ b/src/Bswup/FullDemo/Server/Program.cs @@ -8,7 +8,6 @@ builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); -builder.Services.AddCors(); builder.Services.AddControllers(); builder.Services.AddHttpContextAccessor(); @@ -39,8 +38,6 @@ app.UseHttpsRedirection(); -app.UseCors(options => options.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); - app.MapStaticAssets(); app.UseAntiforgery(); From 3431b3c1dac193f76efdbf4df79ecda9447aee4d Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 7 Jul 2026 10:08:44 +0330 Subject: [PATCH 25/32] add regex support for asset urls --- src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 182 +++++++++++------- .../wwwroot/service-worker.js | 6 + .../wwwroot/service-worker.published.js | 6 + 3 files changed, 121 insertions(+), 73 deletions(-) diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index afd61cbba8..74861bda57 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -16,7 +16,7 @@ interface BitBswupGlobals { assetsManifest: any // injected by service-worker-assets.js (version + asset list) assetsInclude: any // extra RegExp(s) of asset URLs to precache assetsExclude: any // RegExp(s) of asset URLs to skip - externalAssets: any // additional (often cross-origin) assets to cache + externalAssets: any // additional assets to cache; each entry is either an exact asset (string / { url }) precached on install, or a RegExp pattern ({ url: /re/ } or a bare /re/) that lazily caches server-generated URLs unknown ahead of time (e.g. resource-collection..js) defaultUrl: any // document served for navigation requests (SPA fallback) assetsUrl: any // path to service-worker-assets.js (default '/service-worker-assets.js') prohibitedUrls: any // RegExp(s) that must always be answered with 403 @@ -262,6 +262,11 @@ diag('SERVER_RENDERED_URLS:', SERVER_RENDERED_URLS); const USER_ASSETS_INCLUDE = prepareRegExpArray(self.assetsInclude); const USER_ASSETS_EXCLUDE = prepareRegExpArray(self.assetsExclude); +// EXTERNAL_ASSETS holds every externalAssets entry, including RegExp patterns. Exact entries are +// precached on install; RegExp entries (e.g. Blazor Web's fingerprinted +// _framework/resource-collection..js) can't be precached because their concrete URL isn't +// known ahead of time, so they're matched and cached lazily in handleFetch and kept across +// updates so the app still boots offline. const EXTERNAL_ASSETS = prepareExternalAssetsArray(self.externalAssets); diag('USER_ASSETS_INCLUDE:', USER_ASSETS_INCLUDE); @@ -294,12 +299,14 @@ diagGroupEnd(); // Bswup cache, fall back to the SPA default document, or pass the request straight to the // network. High-level flow: // 1. Block prohibited URLs (403) and pass through non-GET / server-handled requests. -// 2. For navigations, substitute the default document unless the URL is server-rendered or +// 2. For navigations, serve the default document unless the URL is server-rendered or // forcePrerender is on (then the server owns the HTML). -// 3. Resolve the request URL to a known asset (with a fallback that strips ?asp-append-version -// style query versioning), and serve it from cache when present. -// 4. In passive mode a cache miss is fetched from the network and lazily written to the -// cache in the background; in active mode a miss simply goes to the network. +// 3. Resolve the request to an asset by testing each asset's (RegExp) url against it. +// 4. Serve the asset from cache. On a cache miss the response is fetched from the network and +// lazily written to the cache (in passive mode, and always for non-precacheable RegExp +// pattern assets like resource-collection..js); in active mode a precached asset that +// misses simply goes to the network. Hash-less assets carry no version to diff, so they are +// re-downloaded on every update (see createAssetsCache), not on every request. async function handleFetch(e: any) { const req = e.request as Request; @@ -323,72 +330,60 @@ async function handleFetch(e: any) { const isServerRendered = SERVER_RENDERED_URLS.some(pattern => pattern.test(req.url)); const shouldServeDefaultDoc = (req.mode === 'navigate') && !isServerRendered && !self.forcePrerender; - const requestUrl = shouldServeDefaultDoc ? DEFAULT_URL : req.url; const start = new Date().toISOString(); - const caseMethod = self.caseInsensitiveUrl ? 'toLowerCase' : 'toString'; + // Every asset's `url` is a RegExp (uniqueAssets converts concrete string URLs to anchored, + // query-tolerant patterns and keeps externalAssets RegExp entries as-is), so matching is a + // single uniform test against the actual request URL. Navigations instead resolve to the SPA + // default document. + const asset = shouldServeDefaultDoc + ? UNIQUE_ASSETS.find(a => a.isDefault) + : UNIQUE_ASSETS.find(a => a.url.test(req.url)); - // the assets url are only the pathname part of the actual request url! - // since only the default url is simple and other ones contain other parts (like 'https://...`) - let asset = UNIQUE_ASSETS.find(a => a[shouldServeDefaultDoc ? 'url' : 'reqUrl'][caseMethod]() === requestUrl[caseMethod]()); - - if (!asset) { // for assets that has asp-append-version or similar type of url versioning - try { - const url = new URL(requestUrl); - const reqUrl = `${url.origin}${url.pathname}`; - asset = UNIQUE_ASSETS.find(a => a.reqUrl[caseMethod]() === reqUrl[caseMethod]()); - } catch { } - } - - if (!(asset?.url)) { - diagFetch('+++ handleFetch ended - asset not found:', start, asset, requestUrl, e, req); + if (!asset) { + diagFetch('+++ handleFetch ended - asset not found:', start, req.url, e, req); return fetch(req); } - if (self.forcePrerender && asset.url === DEFAULT_URL) { - diagFetch('+++ handleFetch ended - skipped - forcePrerender defaultDoc:', start, asset, requestUrl, e, req); + if (self.forcePrerender && asset.isDefault) { + diagFetch('+++ handleFetch ended - skipped - forcePrerender defaultDoc:', start, asset, e, req); return fetch(req); } - const cacheUrl = createCacheUrl(asset); + // Concrete assets are keyed/fetched via their reqUrl (+ hash / ?v= cache-buster). A pattern + // asset (a RegExp externalAssets entry, e.g. resource-collection..js) has no precomputed + // URL, so it is keyed and fetched by the actual request. + const cacheUrl = asset.reqUrl ? createCacheUrl(asset) : req.url; const bitBswupCache = await caches.open(CACHE_NAME); - // createCacheUrl always returns a non-empty string here (we already returned above when - // asset?.url was falsy), so the previous `cacheUrl || requestUrl` fallback was dead code. const cachedResponse = await bitBswupCache.match(cacheUrl); - if (cachedResponse || !self.isPassive) { + // Serve from cache when present. In active (non-passive) mode a precached asset that missed + // the cache goes straight to the network; pattern assets are never precached (their concrete + // URL isn't known ahead of time), so they must lazily fill the cache even in active mode. + if (cachedResponse || (!self.isPassive && asset.reqUrl)) { diagFetch('+++ handleFetch ended - ', cachedResponse ? '' : 'NOT', 'using cache.', start, asset); return cachedResponse || fetch(req); } - const request = createNewAssetRequest(asset); + const request = asset.reqUrl ? createNewAssetRequest(asset) : req; const response = await fetch(request); if (response.ok) { - // Stream the response to the page immediately and write to the cache in the - // background. Awaiting cache.put() here would block the (potentially large - // .wasm / .dll) body from reaching the page until the whole file had been - // downloaded and stored. response.clone() lets the browser tee the stream so the - // page and the cache write consume bytes as they arrive, and e.waitUntil keeps the - // service worker alive until the background write completes. This mirrors how - // Workbox's Strategy.handle returns the response while caching transparently. - // - // Lazy-fill is best-effort under both error tolerances: at runtime there is no - // install promise to reject, so a failed write just means the asset is re-fetched - // next time instead of being served from cache. (errorTolerance is enforced during - // install in createAssetsCache, not on this passive runtime path.) + // Stream the response to the page immediately and write to the cache in the background. + // response.clone() tees the stream so the page and the cache write consume bytes as they + // arrive; e.waitUntil keeps the worker alive until the background write completes. const cachePut = bitBswupCache.put(cacheUrl, response.clone()).catch(err => { diagFetch('+++ handleFetch - lazy-fill put failed:', err, asset); }); e.waitUntil(cachePut); } - diagFetch('+++ handleFetch ended - passive saving asset:', start, asset, e, req); + diagFetch('+++ handleFetch ended - lazily caching asset:', start, asset, e, req); return response; } @@ -510,6 +505,9 @@ async function createAssetsCache(ignoreProgressReport = false) { const fold = (s: string) => self.caseInsensitiveUrl ? s.toLowerCase() : s; const assetByCacheKey = new Map(); for (const asset of UNIQUE_ASSETS) { + // Pattern assets (no concrete reqUrl) can't be precached or diffed; they are matched and + // cached lazily by handleFetch, so skip them here. + if (!asset.reqUrl) continue; assetByCacheKey.set(fold(new Request(createCacheUrl(asset)).url), asset); } @@ -528,6 +526,14 @@ async function createAssetsCache(ignoreProgressReport = false) { const matched = assetByCacheKey.get(fold(key.url)); if (!matched) { + // A key lazily cached for a pattern asset (no concrete reqUrl, e.g. + // resource-collection..js) has no entry in assetByCacheKey; keep it so it stays + // available offline instead of being pruned as stale. + if (UNIQUE_ASSETS.some(a => !a.reqUrl && a.url.test(key.url))) { + diag('*** keeping lazily-cached pattern asset key:', key.url); + continue; + } + // No current asset maps to this key: the asset was removed from the manifest, or // its hash changed (a changed hash yields a different key, so the old hashed key // no longer matches). Either way it's stale - drop it. @@ -551,7 +557,7 @@ async function createAssetsCache(ignoreProgressReport = false) { // Always refresh the default document on each update so navigations pick up the latest // app shell even when its hash is unchanged. If it was kept above, drop it from the kept // set and delete its current entry so it is re-fetched below. - const defaultAsset = UNIQUE_ASSETS.find(a => a.url === DEFAULT_URL); + const defaultAsset = UNIQUE_ASSETS.find(a => a.isDefault); if (defaultAsset && cachedAssets.has(defaultAsset)) { cachedAssets.delete(defaultAsset); keysToDelete.push(new Request(createCacheUrl(defaultAsset)).url); // get the latest version of the default doc in each update if exists!! @@ -559,7 +565,9 @@ async function createAssetsCache(ignoreProgressReport = false) { await Promise.all(keysToDelete.map(url => newCache.delete(url))); - const assetsToCache = UNIQUE_ASSETS.filter(a => !cachedAssets.has(a)); + // Pattern assets are excluded: they can't be precached (no concrete URL) and are filled + // lazily by handleFetch instead. + const assetsToCache = UNIQUE_ASSETS.filter(a => !cachedAssets.has(a) && a.reqUrl); diag('cachedAssets:', cachedAssets.size, 'assetsToCache:', assetsToCache); @@ -628,7 +636,7 @@ async function createAssetsCache(ignoreProgressReport = false) { sendError({ reason: 'request', message: 'Failed to build asset request: ' + (err && (err as any).message || String(err)), - url: asset && asset.url, + url: asset && asset.reqUrl, hash: asset && asset.hash, }); doReport(true); @@ -649,7 +657,7 @@ async function createAssetsCache(ignoreProgressReport = false) { // origin on the same tick. const backoff = RETRY_DELAY * Math.pow(2, attempt - 1); const wait = backoff + Math.floor(Math.random() * RETRY_DELAY); - diag(`*** addCache - retrying (${attempt}/${MAX_RETRIES}) in ${wait}ms:`, asset.url); + diag(`*** addCache - retrying (${attempt}/${MAX_RETRIES}) in ${wait}ms:`, asset.reqUrl); await delay(wait); } @@ -671,7 +679,7 @@ async function createAssetsCache(ignoreProgressReport = false) { // worth another attempt while retries remain. if (!isIntegrity && attempt < MAX_RETRIES) { lastError = fetchErr; - diag('*** addCache - fetch rejected (will retry):', fetchErr, asset.url); + diag('*** addCache - fetch rejected (will retry):', fetchErr, asset.reqUrl); continue; } @@ -680,9 +688,9 @@ async function createAssetsCache(ignoreProgressReport = false) { sendError({ reason: isIntegrity ? 'integrity' : 'fetch', message: isIntegrity - ? `Subresource Integrity check failed for ${asset.url}. The bytes served do not match the SHA hash recorded in service-worker-assets.js / blazor.boot.json. This is the classic Blazor "Failed to find a valid digest" failure and usually means a CDN, reverse proxy, or compression layer is rewriting the response after publish.` + ? `Subresource Integrity check failed for ${asset.reqUrl}. The bytes served do not match the SHA hash recorded in service-worker-assets.js / blazor.boot.json. This is the classic Blazor "Failed to find a valid digest" failure and usually means a CDN, reverse proxy, or compression layer is rewriting the response after publish.` : 'Asset fetch rejected' + (attempt > 0 ? ` after ${attempt + 1} attempts` : '') + ': ' + (fetchErr && (fetchErr as any).message || String(fetchErr)), - url: asset.url, + url: asset.reqUrl, hash: asset.hash, integrity: hasIntegrity, }); @@ -695,7 +703,7 @@ async function createAssetsCache(ignoreProgressReport = false) { // Permanent ones (404, 403, ...) will not change on retry. if (isRetryableStatus(response.status) && attempt < MAX_RETRIES) { lastError = response; - diag('*** addCache - !response.ok (will retry):', response.status, asset.url); + diag('*** addCache - !response.ok (will retry):', response.status, asset.reqUrl); continue; } @@ -703,7 +711,7 @@ async function createAssetsCache(ignoreProgressReport = false) { sendError({ reason: 'fetch', message: `Asset fetch failed with HTTP ${response.status} ${response.statusText || ''}`.trim() + (attempt > 0 ? ` after ${attempt + 1} attempts` : ''), - url: asset.url, + url: asset.reqUrl, hash: asset.hash, status: response.status, integrity: hasIntegrity, @@ -724,7 +732,7 @@ async function createAssetsCache(ignoreProgressReport = false) { sendError({ reason: 'cache', message: 'Failed to store asset in cache: ' + (err && (err as any).message || String(err)), - url: asset.url, + url: asset.reqUrl, hash: asset.hash, }); doReport(true); @@ -746,11 +754,12 @@ async function createAssetsCache(ignoreProgressReport = false) { } } -// Cache key for an asset: the URL suffixed with `.` when a hash exists, so a changed -// hash produces a distinct cache entry (the old one is detected and evicted during the -// update diff in createAssetsCache). Hashless assets are keyed by URL alone. +// Cache key for a concrete asset: its reqUrl suffixed with `.` when a hash exists, so a +// changed hash produces a distinct cache entry (the old one is detected and evicted during the +// update diff in createAssetsCache). Hashless assets are keyed by reqUrl alone. Only called for +// concrete assets (asset.reqUrl set); pattern assets are keyed by the live request URL instead. function createCacheUrl(asset: any) { - return asset.hash ? `${asset.url}.${asset.hash}` : asset.url; + return asset.hash ? `${asset.reqUrl}.${asset.hash}` : asset.reqUrl; } // Resolves after `ms` milliseconds. Used to space out asset-download retries. @@ -786,9 +795,9 @@ function createNewAssetRequest(asset: any) { const version = ((asset.hash || self.assetsManifest.version) as string).replaceAll('+', '-').replaceAll('/', '_'); const trimmedVersion = encodeURIComponent(trimEnd(version, '=')); - const url = new URL(asset.url, self.location.origin); + const url = new URL(asset.reqUrl, self.location.origin); url.searchParams.set('v', trimmedVersion); - if (asset.url === DEFAULT_URL && self.noPrerenderQuery) { + if (asset.isDefault && self.noPrerenderQuery) { new URLSearchParams(String(self.noPrerenderQuery)).forEach((value, key) => url.searchParams.set(key, value)); } @@ -823,26 +832,52 @@ async function deleteOldCaches() { return Promise.all(promises); } -// De-duplicates the asset list by URL (the first occurrence wins) and, as a side effect, -// precomputes `reqUrl` - the fully-resolved absolute URL the browser will actually request - -// so handleFetch can match incoming requests without re-resolving on every fetch. +// De-duplicates the asset list by URL (the first occurrence wins) and normalizes each entry so +// the rest of the worker can treat every asset uniformly: +// - `url` becomes a RegExp matcher. A concrete string URL is converted to an anchored, +// query-tolerant pattern for its resolved origin+pathname; an externalAssets RegExp +// entry (a server-generated file whose concrete name isn't known ahead of time, e.g. +// resource-collection..js) is kept as the matcher it already is. +// - `reqUrl` holds the concrete absolute URL used to build cache keys and download requests. It +// is undefined for pattern assets, whose concrete URL is only known when a matching +// request arrives (they are cached lazily by that request URL instead). +// - `isDefault` flags the SPA default document, since `url` is no longer comparable by string. +// The manifest entries are shallow-copied rather than mutated in place, since self.assetsManifest +// / externalAssets are caller-owned and read elsewhere. function uniqueAssets(assets: any) { const unique = {} as any; const distinct = []; for (let i = 0; i < assets.length; i++) { const a = assets[i]; - if (unique[a.url]) continue; - - // Shallow-copy the manifest entry before adding the derived reqUrl, instead of - // mutating the object in place. The input comes from self.assetsManifest.assets - // (and externalAssets), which other code may read; tacking reqUrl onto the shared - // object was an unnecessary side effect on caller-owned data. - distinct.push({ ...a, reqUrl: new Request(a.url).url }); - unique[a.url] = 1; + const isPattern = a.url instanceof RegExp; + const dedupeKey = isPattern ? a.url.toString() : a.url; + if (unique[dedupeKey]) continue; + unique[dedupeKey] = 1; + + const reqUrl = isPattern ? undefined : new Request(a.url).url; + distinct.push({ + ...a, + url: isPattern ? applyUrlCaseSensitivity(a.url) : urlToRegExp(reqUrl as string), + reqUrl, + isDefault: !isPattern && a.url === DEFAULT_URL, + }); } return distinct; } +// Escapes RegExp metacharacters so a literal string can be embedded in a pattern. +function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Converts a concrete asset URL into a RegExp that matches requests for it. Anchored to the +// resolved origin+pathname and tolerant of any query string, so cache-busting variants +// (?asp-append-version, ?v=, ...) still match. Honors caseInsensitiveUrl via the `i` flag. +function urlToRegExp(url: string) { + const u = new URL(url, self.location.origin); + return new RegExp('^' + escapeRegExp(`${u.origin}${u.pathname}`) + '(\\?.*)?$', self.caseInsensitiveUrl ? 'i' : ''); +} + // Broadcasts a message to every client (controlled or not), so all open tabs - not just the // one that triggered the work - receive install/progress/activate/error updates. Objects are // JSON-stringified; plain string commands (e.g. 'WAITING_SKIPPED') are sent as-is. @@ -909,9 +944,10 @@ function validateAssetsManifest(manifest: any): string[] { } // Normalizes self.externalAssets into a consistent array of `{ url, ... }` objects. Accepts a -// single value or an array, passes through entries that already have a url, wraps bare -// strings into `{ url }`, and drops anything else (null/invalid) so the precache list only -// contains well-formed asset descriptors. +// single value or an array, passes through entries that already have a url (a concrete string or +// a RegExp pattern), wraps bare strings and bare RegExps into `{ url }`, and drops anything else +// (null/invalid). RegExp `url` entries flow through the same list as exact assets; they simply +// aren't precached (their concrete URL is unknown) and are matched/cached lazily in handleFetch. function prepareExternalAssetsArray(value: any) { const array = value ? (value instanceof Array ? value : [value]) : []; @@ -920,7 +956,7 @@ function prepareExternalAssetsArray(value: any) { return asset; } - if (typeof asset === 'string') { + if (typeof asset === 'string' || asset instanceof RegExp) { return ({ url: asset }); } diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js index eea90b1a2d..2af9c75098 100644 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js @@ -31,6 +31,12 @@ self.externalAssets = [ }, { "url": "Bit.Bswup.NewDemo.Client.bundle.scp.css" + }, + { + // Server-generated Blazor Web boot module with a fingerprint that changes each publish, + // so it can't be listed as an exact asset. This RegExp lets Bswup cache it lazily so the + // app still boots offline. + "url": /\/_framework\/resource-collection\..+\.js$/ } ]; diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js index ce1077fa4b..f489228c90 100644 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js @@ -26,6 +26,12 @@ self.externalAssets = [ }, { "url": "Bit.Bswup.NewDemo.Client.bundle.scp.css" + }, + { + // Server-generated Blazor Web boot module with a fingerprint that changes each publish, + // so it can't be listed as an exact asset. This RegExp lets Bswup cache it lazily so the + // app still boots offline. + "url": /\/_framework\/resource-collection\..+\.js$/ } ]; From 4bd67413f011de58e90d95f80253be4de6f3aaea Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 21 Jul 2026 00:34:39 +0330 Subject: [PATCH 26/32] fix local findings --- .../Bit.Bswup.Demo/wwwroot/service-worker.js | 6 +- src/Bswup/Bit.Bswup/Bit.Bswup.csproj | 26 ++- src/Bswup/Bit.Bswup/BswupProgress.razor | 10 +- .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 84 +++++++-- src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 159 ++++++++++++++---- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 113 +++++++++++-- src/Bswup/Bit.Bswup/tsconfig.json | 4 +- src/Bswup/Bit.Bswup/tsconfig.sw.json | 4 +- src/Bswup/README.md | 51 +++++- 9 files changed, 379 insertions(+), 78 deletions(-) diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js index d8333de0f1..713c3d4128 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js @@ -8,9 +8,9 @@ self.externalAssets = [ "url": "not-found/script.file.js" } ]; -// 'lax' opts into best-effort installs: the demo intentionally references a non-existent -// asset to exercise the progress / error reporting UI. Under the default 'strict' setting -// that would abort the install. See README.md > errorTolerance. +// 'lax' is already the default; it is spelled out here because the demo intentionally +// references a non-existent asset to exercise the progress / error reporting UI, and under +// 'strict' that would abort the install. See README.md > errorTolerance. self.errorTolerance = 'lax'; self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); diff --git a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj index ff8c95a1a1..9969d5d918 100644 --- a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj +++ b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj @@ -5,6 +5,15 @@ net10.0;net9.0;net8.0 true + + es2019 @@ -113,13 +122,22 @@ - + + - + - + - + diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index e00d8b6dd8..38441d9f76 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -20,9 +20,15 @@ statically-rendered host document. Razor attribute-encodes these values and the script reads them back via getAttribute, so no value can break out of the attribute into markup or script. Make sure - _content/Bit.Bswup/bit-bswup.progress.js is referenced on the page. *@ + _content/Bit.Bswup/bit-bswup.progress.js is referenced on the page. + + The hidden-until-needed default deliberately lives in bit-bswup.progress.css + (`#bit-bswup { display: none }`), NOT in an inline style here - same as #bit-bswup-reload. + An inline style wins over every stylesheet rule, so hardcoding it would silently override + apps that restyle #bit-bswup (a custom ChildContent splash meant to be visible during + startup, or a layout that needs flex/grid) and apps that skip the bundled stylesheet + entirely. bit-bswup.progress.js still toggles display inline at runtime as it always has. *@ - } +@* Rendered for BOTH the default splash and custom ChildContent: with AutoReload defaulting + to false, this button is the only way a finished update surfaces - a custom splash that + omitted it would silently lose every update notification (the update just stays staged). + A custom splash that renders its own control can restyle these by id, or simply keep them. + + Deliberately OUTSIDE #bit-bswup: the update-ready button must be showable while the + overlay stays hidden. A background update never reveals the splash over the running + app, and an update already staged at page load never produced a progress event to + reveal it - in both cases unhiding a button INSIDE a display:none parent rendered + nothing. The button positions itself (position: fixed in bit-bswup.progress.css). + type="button" keeps a host
    (e.g. a login page) from treating the click as a + submit. The visually-hidden role="status" region is what announces the button's + appearance to screen readers - display:none removes the button itself from the + accessibility tree, so its display toggle alone is never announced. + + Unlike #bit-bswup (see the comment above), the initial hiding here IS inline: there is no + legitimate "visible before an update exists" styling for this button, and the inline style + keeps it hidden even when the bundled stylesheet is not referenced or an older cached copy + is still being served - bit-bswup.progress.js toggles the same inline property to show it. + The status region's hiding is inline for the same stale-stylesheet reason (it must never + render as visible page text), while staying in the accessibility tree. *@ + + diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index a95bbdebac..67a9cec9ed 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -25,6 +25,25 @@ // hideApp - hide the app element during download // autoHide - hide the splash automatically when the download finishes // handler - optional name of a user handler invoked after the built-in one + // Resolves a splash element at USE time, with a cache that re-resolves when the cached + // node has been replaced. Capturing the elements once at start() froze the UI whenever an + // interactive Blazor render swapped the splash subtree after initialization: the handler + // kept driving the detached nodes (bar stuck at its last value, the reload button toggled + // on an orphan) with no observer left to recover. isConnected is undefined on exotic node + // fakes; only an explicit false (a real node that left the document) triggers + // re-resolution, and the stale node is kept as a last resort when no replacement exists. + const elementCache: { [id: string]: any } = {}; + function el(id: string) { + const cached = elementCache[id]; + if (cached && cached.isConnected !== false) return cached; + const fresh = document.getElementById(id); + if (fresh) { + elementCache[id] = fresh; + return fresh; + } + return cached || null; + } + function start(autoReload: boolean, showLogs: boolean, showAssets: boolean, @@ -33,20 +52,47 @@ autoHide: boolean, handler?: string) { - const appEl = document.querySelector(appContainerSelector) as HTMLElement; - const bswupEl = document.getElementById('bit-bswup'); - const progressEl = document.getElementById('bit-bswup-progress-bar'); - const percentEl = document.getElementById('bit-bswup-percent'); - const assetsEl = document.getElementById('bit-bswup-assets'); - const reloadButton = document.getElementById('bit-bswup-reload'); - const errorEl = document.getElementById('bit-bswup-error'); - const errorMessageEl = document.getElementById('bit-bswup-error-message'); - const errorDetailsEl = document.getElementById('bit-bswup-error-details'); - const errorRetryButton = document.getElementById('bit-bswup-error-retry'); + // Install the global handler FIRST. Everything below touches the DOM, and a bad + // AppContainer selector makes document.querySelector throw - previously that threw + // BEFORE window.bitBswupHandler was assigned, so no handler ever registered, the + // downloadFinished -> reload() handshake never ran, and a first install sat behind + // the splash until the stall watchdog fired a minute later. The handler closes over + // bindings initialized below, which is safe: messages arrive as async events and can + // never run mid-start(). + (window as any).bitBswupHandler = bitBswupHandler; + + // Tolerate an invalid selector instead of aborting initialization: losing the + // hide-the-app nicety costs a cosmetic overlap; losing the handler costs the boot. + let appEl: HTMLElement | null = null; + try { + appEl = document.querySelector(appContainerSelector) as HTMLElement; + } catch (err) { + console.error('BitBswupProgress: invalid appContainer selector - continuing without app hiding:', appContainerSelector, err); + } const appElOriginalDisplay = appEl && appEl.style.display; - (window as any).bitBswupHandler = bitBswupHandler; + // Sets the reload button visible/wired and announces it. The button is hidden with + // display:none, which removes it from the accessibility tree entirely - its + // appearance is never announced on its own - so the always-present visually-hidden + // role="status" region carries the announcement for screen readers. + function showReloadButton(reload: any, display: string) { + const reloadButton = el('bit-bswup-reload'); + const reloadStatusEl = el('bit-bswup-reload-status'); + reloadButton && (reloadButton.style.display = display); + reloadButton && (reloadButton.onclick = reload); + reloadStatusEl && (reloadStatusEl.textContent = 'A new version is ready to install.'); + } + function hideReloadButton() { + const reloadButton = el('bit-bswup-reload'); + const reloadStatusEl = el('bit-bswup-reload-status'); + if (reloadButton) { + reloadButton.style.display = 'none'; + reloadButton.onclick = null; + } + reloadStatusEl && (reloadStatusEl.textContent = ''); + } + // Resolve the optional user handler lazily rather than capturing window[handler] once // here at start(): if the host registers its handler after this script runs (a racey // script order), an early capture would bind `undefined` forever and the custom handler @@ -56,6 +102,16 @@ function resolveHandler() { if (typeof handlerFn === 'function') return handlerFn; const candidate = handler ? window[handler] : undefined; + // Handler="bitBswupHandler" - the very global this script registers - would make + // the handler invoke itself until the stack blows, and handleInternal runs FIRST + // at every depth, so ShowAssets would prepend thousands of duplicate rows per + // message on the way down. Refuse self-references and cache a no-op so the + // warning fires once, not per message. + if (candidate === bitBswupHandler) { + console.warn('BitBswupProgress: the custom handler resolves to the built-in bitBswupHandler itself - ignoring it (point Handler at a different function).'); + handlerFn = () => { }; + return handlerFn; + } if (typeof candidate === 'function') handlerFn = candidate as (message: any, data: any) => void; return handlerFn; } @@ -79,6 +135,17 @@ const showAssets_ = _config.showAssets ?? showAssets; const autoReload_ = _config.autoReload ?? autoReload; + // Resolved per message, not captured at start() - see el(): an interactive + // render may have replaced the splash subtree since the previous message. + const bswupEl = el('bit-bswup'); + const progressEl = el('bit-bswup-progress-bar'); + const percentEl = el('bit-bswup-percent'); + const assetsEl = el('bit-bswup-assets'); + const errorEl = el('bit-bswup-error'); + const errorMessageEl = el('bit-bswup-error-message'); + const errorDetailsEl = el('bit-bswup-error-details'); + const errorRetryButton = el('bit-bswup-error-retry'); + switch (message) { case BswupMessage.updateFound: return showLogs_ ? console.log('an update found.') : undefined; @@ -94,6 +161,20 @@ return showLogs_ ? console.log('downloading assets started:', data?.version) : undefined; case BswupMessage.downloadProgress: { + // Background updates (firstInstall === false) download behind a + // healthy running app. Painting the full-viewport splash over it + // blocked every click for the entire download - and the overlay root + // has no background, so it rendered as stray text on top of the live + // UI. The built-in overlay is therefore first-install-only; progress + // still reaches the user handler for apps that render their own + // indicator, and completion surfaces through the reload button. The + // strict === false check keeps the old take-over behavior when the + // flag is absent (an older bit-bswup.js still cached alongside this + // script). + if (data && data.firstInstall === false) { + return showLogs_ ? console.log('asset downloaded (background update):', data) : undefined; + } + hideApp_ && appEl && (appEl.style.display = 'none'); bswupEl && (bswupEl.style.display = 'block'); @@ -137,12 +218,13 @@ // this the splash could stay hidden with no way forward, so restore // the splash and offer a manual retry wired to data.reload. bswupEl && (bswupEl.style.display = 'block'); - reloadButton && (reloadButton.style.display = 'block'); - reloadButton && (reloadButton.onclick = data.reload); + showReloadButton(data.reload, 'block'); }); } else { - reloadButton && (reloadButton.style.display = 'block'); - reloadButton && (reloadButton.onclick = data.reload); + // The button lives OUTSIDE #bit-bswup (see BswupProgress.razor) + // precisely so this works without revealing the whole overlay + // over a running app. + showReloadButton(data.reload, 'block'); } return showLogs_ ? console.log('downloading assets finished.') : undefined; @@ -151,12 +233,14 @@ // Wrap in Promise.resolve so a non-promise return is handled too, and // fall back to a manual reload button if the auto-reload rejects. Promise.resolve(data.reload()).catch(() => { - reloadButton && (reloadButton.style.display = 'inline'); - reloadButton && (reloadButton.onclick = data.reload); + showReloadButton(data.reload, 'inline'); }); } else { - reloadButton && (reloadButton.style.display = 'inline'); - reloadButton && (reloadButton.onclick = data.reload); + // Shown without touching #bit-bswup: when an update is already + // staged at page load no progress event ever revealed the overlay, + // and unhiding a button inside a display:none parent rendered + // nothing - the user was never told an update was ready. + showReloadButton(data.reload, 'inline'); } return showLogs_ ? console.log('new update is ready.') : undefined; @@ -191,10 +275,7 @@ if (data && data.firstInstall === false) { hideApp_ && appEl && (appEl.style.display = appElOriginalDisplay); bswupEl && (bswupEl.style.display = 'none'); - if (reloadButton) { - reloadButton.style.display = 'none'; - reloadButton.onclick = null; - } + hideReloadButton(); return; } @@ -207,10 +288,7 @@ // the reload button visible would invite the user to activate an update // that has already failed, promoting a broken worker / caches. Hide and // unwire it so the only actionable control is the (conditional) Retry. - if (reloadButton) { - reloadButton.style.display = 'none'; - reloadButton.onclick = null; - } + hideReloadButton(); // The error supersedes any in-flight progress. Hide the bar and the // percentage so a stale partial value (e.g. "47%") isn't left sitting @@ -264,7 +342,8 @@ // is only set after a successful start - a start() that threw can be retried - and so // manual BitBswupProgress.start(...) callers are tracked too, keeping the // DOMContentLoaded/MutationObserver paths from re-initializing. - bswupEl && bswupEl.setAttribute('data-bit-bswup-initialized', 'true'); + const initializedEl = el('bit-bswup'); + initializedEl && initializedEl.setAttribute('data-bit-bswup-initialized', 'true'); }; function config(newConfig: IBswupProgressConfigs) { diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts index e30cb0523d..7cd287e6ae 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw-cleanup.ts @@ -11,32 +11,36 @@ interface Window extends BitBswupGlobals { } // Self-destructing "uninstall" service worker. Deploy this in place of the real // bit-bswup.sw.js when an app needs to fully back out of Bswup (e.g. switching a site away -// from offline support, or recovering clients stuck on a broken worker/cache). It takes over -// all clients, wipes every Bswup/Blazor cache once in control, and tells each one to -// unregister and reload - leaving the app running purely from the network with no SW. +// from offline support, or recovering clients stuck on a broken worker/cache). On activation +// it wipes every Bswup/Blazor cache this app owns, unregisters its own registration, and +// signals in-scope tabs to detach - leaving the app running purely from the network with no +// SW. Tabs the takeover controlled reload once (their controllerchange); everything after +// that stays quiet even while the app HTML keeps re-registering this script. // On install, only skipWaiting() so this cleanup worker activates without waiting for existing // clients to close. All teardown work (cache purge + client notification) is deferred to the // 'activate' handler below, once this worker is actually in control. self.addEventListener('install', (e: any) => e.waitUntil(self.skipWaiting())); -// Take over all clients and run teardown only once this worker has *activated* - not during -// install. Doing the cache purge at activate time (after clients.claim()) guarantees the -// cleanup worker is fully in control before its caches disappear, so no controlled tab is left -// using a worker whose caches were already purged. +// Run teardown only once this worker has *activated* - not during install. skipWaiting-driven +// activation is also what hands this fetch-less worker control of every tab the previous +// (real) worker controlled: per the lifecycle, the new active worker takes over controlled +// clients immediately, firing their 'controllerchange' (which is the page's reload-and-detach +// signal). clients.claim() is deliberately NOT called: claiming would additionally attach +// UNCONTROLLED pages - pages that are already running SW-free, including the very tabs that +// just reloaded to detach - re-entangling them with a worker whose whole purpose is to +// disappear, and turning every future page load into another claim/reload cycle. self.addEventListener('activate', (e: any) => e.waitUntil(teardownClients())); -// Activate-time teardown: claim every client, purge the caches this library (and Blazor) -// created, then message each (controlled or not) to unregister itself. The delayed -// 'WAITING_SKIPPED' nudge is a fallback reload signal for clients that don't act on -// 'UNREGISTER' fast enough, so no tab is left running against the now-deleted caches. Await the -// whole chain so the activate event (which waits on this via waitUntil) doesn't resolve before -// the teardown signalling has actually been dispatched. +// Activate-time teardown: purge the caches this library (and Blazor) created, unregister this +// registration (the client-independent step - see below), then message each in-scope window +// client to detach itself. The delayed 'WAITING_SKIPPED' nudge is a fallback reload signal +// for clients that don't act on 'UNREGISTER' fast enough, so no tab is left running against +// the now-deleted caches. Every step is individually best-effort: this worker is deployed +// into broken-storage / wedged-client situations, and any one step failing must not stop the +// rest. Await the whole chain so the activate event (which waits on this via waitUntil) +// doesn't resolve before the teardown signalling has actually been dispatched. async function teardownClients() { - // Claim first so controlled tabs are served by this fetch-less worker (straight from the - // network) before their caches vanish, then purge the Bswup/Blazor caches. - await self.clients.claim(); - // Best-effort: CacheStorage can reject under storage pressure / broken origin storage - // the very situations this recovery worker is deployed into. The purge must never abort // the teardown, or no client would ever be told to UNREGISTER and every tab would stay @@ -62,13 +66,37 @@ async function teardownClients() { } catch (err) { console.warn('BitBswup SW cleanup: cache purge failed (continuing with unregister):', err); } + + // Unregister HERE, in the worker, not only via the clients. The 'UNREGISTER' message + // below is inherently racy: tabs the takeover just switched are already reloading on + // their 'controllerchange' when it is posted (a navigating client silently drops + // messages), and tabs that load later never hear it at all - teardown runs once, at + // activate. Relying on clients alone left the registration alive indefinitely. Calling + // registration.unregister() from the activate handler is the canonical self-destroying + // service-worker pattern and needs no client cooperation: the registration is marked for + // removal now and fully clears once the last client detaches. Pages that re-register + // later (the app HTML still ships the Bswup script) just repeat this cycle silently - + // register, activate, purge (no-op), unregister - without ever being claimed or reloaded. + try { + await self.registration.unregister(); + } catch (err) { + console.warn('BitBswup SW cleanup: self-unregister failed (continuing with client notification):', err); + } + // Only target window clients that belong to this registration's scope. matchAll with // includeUncontrolled returns every same-origin client (including those under other // scopes / mounted sub-apps and non-window clients like workers); broadcasting // 'UNREGISTER' to all of them would tell unrelated apps to tear themselves down. Filter // to in-scope window clients so the reloadSignals loop only reloads this registration. const scope = self.registration && self.registration.scope; - const allClients = await self.clients.matchAll({ includeUncontrolled: true }); + // Same best-effort rule as every other step: a matchAll rejection must not abort the + // teardown (the purge and unregister above already ran; only the notification is lost). + let allClients: any = []; + try { + allClients = await self.clients.matchAll({ includeUncontrolled: true }); + } catch (err) { + console.warn('BitBswup SW cleanup: client enumeration failed (teardown already completed):', err); + } const clients = (allClients || []).filter((client: any) => client.type === 'window' && (!scope || (typeof client.url === 'string' && client.url.indexOf(scope) === 0))); const reloadSignals: Promise[] = []; diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index f03ddd18b7..17f256e107 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -151,8 +151,9 @@ const CACHE_NAME = `${SCOPED_CACHE_PREFIX}${CACHE_VERSION}`; // the wrong tool for this (it also overwrites legitimate falsy config: an explicit // `caseInsensitiveUrl = false` came back true, `noPrerenderQuery = ''` came back // 'no-prerender=true', and isPassive - previously a bare assignment - was clobbered outright). -// `??=` would express the intent directly, but it is ES2021 syntax and this bundle targets -// ES2019 (see BswupJsTarget in the csproj). +// `??=` is close but not identical: it also overwrites an explicit `null`, and tsc downlevels +// it to the ES2019 this bundle targets anyway - the explicit undefined check is kept because +// it states the exact contract ("only fill what the app never set"), not for syntax reasons. function presetDefault(key: string, value: any) { if ((self as any)[key] === undefined) (self as any)[key] = value; } @@ -265,6 +266,10 @@ async function handleInstall(e: any) { sendMessage({ type: 'install', data: { version: VERSION, isPassive: self.isPassive } }); + // Asset entries whose URL failed to parse during module evaluation (see + // INVALID_ASSET_ERRORS): surfaced here so they are reported exactly once per install. + INVALID_ASSET_ERRORS.forEach(err => sendError(err)); + if (self.errorTolerance === 'strict') { // Strict: any required asset that fails to fetch / store must reject the install // promise so the SW lifecycle treats it as a failed install. Without this, a @@ -352,7 +357,7 @@ async function handleActivate(e: any) { const windowClients = await matchScopeClients(); if (windowClients.length === 0) { diag('activate - no open window clients; pruning old caches.'); - await deleteOldCaches(); + await safeDeleteOldCaches('activate'); } else { diag('activate - open window clients present; deferring cache cleanup:', windowClients.length); } @@ -394,7 +399,17 @@ diag('USER_ASSETS_EXCLUDE:', USER_ASSETS_EXCLUDE); diag('EXTERNAL_ASSETS:', EXTERNAL_ASSETS); const DEFAULT_ASSETS_INCLUDE = [/\.dll$/, /\.wasm/, /\.pdb/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.svg$/, /\.woff2$/, /\.ttf$/, /\.webp$/]; -const DEFAULT_ASSETS_EXCLUDE = [/^_content\/Bit\.Bswup\/bit-bswup\.sw\.js$/, /^service-worker\.js$/]; +// Service-worker scripts must not be precached: the browser fetches them through its own +// update pipeline (updateViaCache: 'none'), never through this worker's fetch handler, so a +// cached copy is dead weight at best. All shipped variants are excluded - the minified +// bundles and the cleanup worker included - not just the exact files the old list named. +const DEFAULT_ASSETS_EXCLUDE = [ + /^_content\/Bit\.Bswup\/bit-bswup\.sw\.js$/, + /^_content\/Bit\.Bswup\/bit-bswup\.sw\.min\.js$/, + /^_content\/Bit\.Bswup\/bit-bswup\.sw-cleanup\.js$/, + /^_content\/Bit\.Bswup\/bit-bswup\.sw-cleanup\.min\.js$/, + /^service-worker\.js$/, +]; const ASSETS_INCLUDE = (self.ignoreDefaultInclude ? [] : DEFAULT_ASSETS_INCLUDE).concat(USER_ASSETS_INCLUDE); const ASSETS_EXCLUDE = (self.ignoreDefaultExclude ? [] : DEFAULT_ASSETS_EXCLUDE).concat(USER_ASSETS_EXCLUDE); @@ -402,6 +417,13 @@ const ASSETS_EXCLUDE = (self.ignoreDefaultExclude ? [] : DEFAULT_ASSETS_EXCLUDE) diag('ASSETS_INCLUDE:', ASSETS_INCLUDE); diag('ASSETS_EXCLUDE:', ASSETS_EXCLUDE); +// Invalid asset entries found while normalizing the asset list. Collected here and reported +// from handleInstall - NOT at module scope: the global scope re-evaluates on every cold start +// of the worker (browsers terminate idle workers between events), so a module-scope sendError +// would replay the same 'request' error to the page for the whole lifetime of the version, +// hours after the install, every time the worker wakes. +const INVALID_ASSET_ERRORS: any[] = []; + const ALL_ASSETS = (MANIFEST_VALID && Array.isArray(self.assetsManifest.assets) ? self.assetsManifest.assets : []) .filter((asset: any) => ASSETS_INCLUDE.some(pattern => pattern.test(asset.url))) .filter((asset: any) => !ASSETS_EXCLUDE.some(pattern => pattern.test(asset.url))) @@ -439,18 +461,23 @@ function foldUrlKey(s: string) { return self.caseInsensitiveUrl ? s.toLowerCase() : s; } -// Resolves a request URL to a managed asset: O(1) exact origin+pathname lookup for concrete -// assets, then a regex scan over the few pattern assets. new URL() can throw on exotic URLs -// (about:, data: - the browser can dispatch fetch events for them); those are never assets. -function findAssetForUrl(url: string) { - let key: string; +// O(1) exact origin+pathname lookup over the concrete assets. new URL() can throw on exotic +// URLs (about:, data: - the browser can dispatch fetch events for them); those are never +// assets. Used on its own for navigations (see handleFetch), which must never consult the +// pattern assets. +function findConcreteAssetForUrl(url: string) { try { const u = new URL(url); - key = foldUrlKey(`${u.origin}${u.pathname}`); + return CONCRETE_ASSETS.get(foldUrlKey(`${u.origin}${u.pathname}`)); } catch { return undefined; } - return CONCRETE_ASSETS.get(key) || PATTERN_ASSETS.find(a => a.url.test(url)); +} + +// Resolves a request URL to a managed asset: the concrete lookup first, then a regex scan +// over the few pattern assets. +function findAssetForUrl(url: string) { + return findConcreteAssetForUrl(url) || PATTERN_ASSETS.find(a => a.url.test(url)); } // A missing default asset silently disables offline navigation: every navigate request is @@ -543,10 +570,17 @@ function handleFetch(e: any) { const start = self.enableFetchDiagnostics ? new Date().toISOString() : ''; // Concrete assets resolve via the precomputed origin+pathname Map, pattern assets via - // their RegExp (see findAssetForUrl). Navigations instead resolve to the SPA default - // document. + // their RegExp (see findAssetForUrl). Navigations resolve to the SPA default document - + // but only when the navigated URL is not itself a CONCRETE managed asset: opening + // /manifest.json or an image directly in a tab must show that file, not the app shell + // rendered under the wrong URL. Route URLs (/counter, /admin/...) match no asset and + // still fall through to the default document, so deep links keep working offline. + // Pattern assets are deliberately NOT consulted for navigations: a broad RegExp + // externalAssets entry that happens to match a route URL would otherwise hijack that + // route away from the shell - caching its HTML under the route URL as a bogus "pattern + // generation" and answering it with a network error offline instead of the app shell. const asset = shouldServeDefaultDoc - ? DEFAULT_ASSET + ? (findConcreteAssetForUrl(req.url) || DEFAULT_ASSET) : findAssetForUrl(req.url); if (!asset) { @@ -573,6 +607,16 @@ async function serveAsset(e: any, req: Request, asset: any, start: string) { // URL, so it is keyed and fetched by the actual request. const cacheUrl = asset.reqUrl ? createCacheUrl(asset) : req.url; + // Whether the page's own request actually targets this asset's URL. False exactly when the + // asset was chosen as the NAVIGATION fallback: req.url is a route (/counter) and the asset + // is the SPA default document. In that case the page's request must NEVER be used to fetch + // bytes that get written under the asset's cache key - the server's answer for /counter is + // route-specific (often prerendered) HTML, and caching it as the app shell would poison + // every future navigation with it. Those paths fetch the asset's own versioned URL instead. + const reqMatchesAsset = asset.reqUrl + ? !!(asset.url && typeof asset.url.test === 'function' && asset.url.test(req.url)) + : true; // pattern assets are keyed by the live request URL - the request IS the asset + // CacheStorage is not guaranteed available: it throws under storage pressure, in some // private-browsing modes, and when the origin's quota is exhausted. Losing the cache is // survivable (we fall through to the network); letting it reject is not. @@ -594,8 +638,10 @@ async function serveAsset(e: any, req: Request, asset: any, start: string) { // next update), every request would hit the network, and the asset would never become // available offline - contradicting the documented lax semantics, which promise lazy fill // on first fetch in BOTH modes. Pattern assets don't take this path (no reqUrl); they fall - // through to the versioned-request lazy path below. - if (!self.isPassive && asset.reqUrl) { + // through to the versioned-request lazy path below - as does a navigation-fallback request + // (see reqMatchesAsset), whose route URL must not supply the bytes cached under the + // default document's key. + if (!self.isPassive && asset.reqUrl && reqMatchesAsset) { const response = await tryFetch(req); if (!response) { return await lastResort(bitBswupCache, req, asset); @@ -612,8 +658,10 @@ async function serveAsset(e: any, req: Request, asset: any, start: string) { // *network* response here would mean buffering the whole stream before the first byte // reaches the page. So a ranged request goes out as the page sent it: the server answers // 206 itself, and lazyFill refuses partial responses so the cache only ever holds full - // bodies (filled by whatever non-ranged request comes first). - const request = (asset.reqUrl && !getRangeHeader(req)) ? createNewAssetRequest(asset) : req; + // bodies (filled by whatever non-ranged request comes first). Except when the request + // does not target the asset's own URL (a ranged navigation fallback, a degenerate case): + // there the versioned request is the only one that fetches the right bytes. + const request = (asset.reqUrl && (!getRangeHeader(req) || !reqMatchesAsset)) ? createNewAssetRequest(asset) : req; let response = await tryFetch(request); // The versioned request carries a `?v=` buster and, with enableCacheControl, no-store @@ -624,7 +672,9 @@ async function serveAsset(e: any, req: Request, asset: any, start: string) { // Not when the request carried an integrity hash, though: SRI failures surface as a // rejected fetch, and retrying without integrity would serve exactly the unverified bytes // the check exists to reject. A tampered asset must fail, not silently downgrade. - if (!response && asset.reqUrl && request !== req && !(request as any).integrity) { + // And not when the page's request doesn't target the asset's URL (navigation fallback): + // fetching the route URL would cache route-specific HTML under the shell's key. + if (!response && asset.reqUrl && request !== req && reqMatchesAsset && !(request as any).integrity) { diagFetch('*** serveAsset - versioned request failed, retrying with the plain request:', start, asset); response = await tryFetch(req); } @@ -753,16 +803,20 @@ async function applyRangeHeader(req: Request, response: Response) { if (response.status !== 200) return response; // Clone before reading: on any fallback path the original response must still carry - // an unconsumed body for the page. - const buffer = await response.clone().arrayBuffer(); - const size = buffer.byteLength; + // an unconsumed body for the page. Read as a Blob, not an ArrayBuffer: browsers keep + // cached-response blobs disk-backed, and blob.slice() is a lazy view - so serving a + // range never materializes the whole file in memory. Media elements issue MANY small + // Range requests while seeking; buffering a full-length video per request was pure + // memory waste for bytes the page never asked for. + const blob = await response.clone().blob(); + const size = blob.size; const range = parseRangeHeader(rangeHeader, size); if (!range) return response; const headers = new Headers((response as any).headers); headers.set('content-range', `bytes ${range.start}-${range.end}/${size}`); headers.set('content-length', String(range.end - range.start + 1)); - return new Response(buffer.slice(range.start, range.end + 1), { status: 206, statusText: 'Partial Content', headers }); + return new Response(blob.slice(range.start, range.end + 1), { status: 206, statusText: 'Partial Content', headers }); } catch (err) { diagFetch('*** applyRangeHeader failed - returning the full response:', err); return response; @@ -858,20 +912,34 @@ function handleMessage(e: MessageEvent) { // asset requests are served from the new worker - or from a cache we just deleted - // which corrupts boot config / DLL hashes. Old caches are removed only *after* the // claim so no controlled client is left pointing at a cache that no longer exists. + // + // Captured BEFORE skipWaiting flips the lifecycle slots: no active worker right now + // means the receiver is a FIRST install being activated through the skipWaiting path + // (e.g. BitBswup.skipWaiting() catching the transient waiting state). The initiating + // page must then receive the first-install completion signal - CLIENTS_CLAIMED, which + // starts Blazor in place and triggers the post-start cache top-up - instead of the + // update reload signal: by the time a WAITING_SKIPPED landed, the claim's + // controllerchange had already marked the page as controlled-by-an-active-worker, so + // the reload it triggers would kill the very boot that controllerchange just started. + // Sibling tabs need nothing extra - their controllerchange handles the first-install + // start. Defaults to update semantics when the registration is unavailable. + const isFirstInstallActivation = !!(self.registration && !self.registration.active); return e.waitUntil( self.skipWaiting() .then(() => self.clients.claim()) - .then(() => deleteOldCaches().catch(err => diag('*** SKIP_WAITING - old-cache cleanup failed (continuing):', err))) - .then(() => sendMessage('WAITING_SKIPPED'))); + .then(() => safeDeleteOldCaches('SKIP_WAITING')) + .then(() => isFirstInstallActivation + ? e.source?.postMessage('CLIENTS_CLAIMED') + : sendMessage('WAITING_SKIPPED'))); } if (e.data === 'CLAIM_CLIENTS') { // First-install claim. Take control so this page can start Blazor; sibling tabs // that observe the resulting 'controllerchange' will NOT reload because there was - // no previously-active worker (see hadActiveWorkerAtStartup in bit-bswup.ts). + // no previously-active worker (see hasActiveWorker in bit-bswup.ts). return e.waitUntil( self.clients.claim() - .then(() => deleteOldCaches().catch(err => diag('*** CLAIM_CLIENTS - old-cache cleanup failed (continuing):', err))) + .then(() => safeDeleteOldCaches('CLAIM_CLIENTS')) .then(() => e.source?.postMessage('CLIENTS_CLAIMED'))); } @@ -903,7 +971,9 @@ function handleMessage(e: MessageEvent) { diag('CLEAN_UP skipped - an update is staged or staging; pruning now would delete a live cache.'); return; } - e.waitUntil(deleteOldCaches().catch((err: any) => diag('*** CLEAN_UP - cache pruning failed:', err))); + // safeDeleteOldCaches re-checks the staged/staging condition at prune time; the early + // return above just keeps the historical "skipped" diagnostic explicit for CLEAN_UP. + e.waitUntil(safeDeleteOldCaches('CLEAN_UP')); } } @@ -967,10 +1037,15 @@ async function createAssetsCache(ignoreProgressReport = false) { let newCacheKeys = await newCache.keys(); const firstTime = newCacheKeys.length === 0; const passiveFirstTime = self.isPassive && firstTime - if (passiveFirstTime) { - if (!ignoreProgressReport) { - sendMessage({ type: 'bypass', data: { firstTime: true } }); - } + // Passive first install: skip the download and let the page boot immediately (assets + // lazy-fill as the app fetches them) - but only for the INSTALL run. The + // post-BLAZOR_STARTED top-up (ignoreProgressReport) must proceed regardless: it is what + // completes the offline cache in the background once the app is running. Gating it on + // the cache being non-empty made that completion a race against the first lazy-fill + // put - almost always won, but "the offline story depends on a put landing first" is + // not a semantic; now the top-up deterministically fills whatever is still missing. + if (passiveFirstTime && !ignoreProgressReport) { + sendMessage({ type: 'bypass', data: { firstTime: true } }); return; } @@ -994,7 +1069,16 @@ async function createAssetsCache(ignoreProgressReport = false) { // Pattern assets (no concrete reqUrl) can't be precached or diffed; they are matched and // cached lazily by handleFetch, so skip them here. if (!asset.reqUrl) continue; - assetByCacheKey.set(foldUrlKey(new Request(createCacheUrl(asset)).url), asset); + // A pathological custom hash can make new Request() throw on the composed cache URL; + // fall back to the raw string key (self-consistent for the diff) rather than letting + // one bad entry reject the whole createAssetsCache pass as an install-infra failure. + let diffKey: string; + try { + diffKey = new Request(createCacheUrl(asset)).url; + } catch { + diffKey = createCacheUrl(asset); + } + assetByCacheKey.set(foldUrlKey(diffKey), asset); } // Assets confirmed present at their current cache key - these are not re-downloaded. @@ -1022,7 +1106,16 @@ async function createAssetsCache(ignoreProgressReport = false) { // matches every fingerprint it ever cached, so the diff cannot tell the current // entry from dead ones; collect matches per pattern here and evict the oldest // beyond MAX_PATTERN_ASSET_GENERATIONS after the loop. - const pattern = PATTERN_ASSETS.find(a => a.url.test(key.url)); + // + // A hashed CONCRETE key (`...url.`) can never be a lazily-cached pattern + // entry - pattern assets are keyed by their live request URL, which carries no + // hash suffix. Without this guard an unanchored pattern (e.g. + // /resource-collection/) also matched the stale hashed keys of removed/re-hashed + // concrete assets, retaining them as bogus "generations" that leak storage and + // evict genuine generations from the cap slots. + const lastDot = key.url.lastIndexOf('.'); + const isHashedConcreteKey = lastDot !== -1 && STALE_HASH_KEY_SUFFIX.test(key.url.slice(lastDot + 1)); + const pattern = isHashedConcreteKey ? undefined : PATTERN_ASSETS.find(a => a.url.test(key.url)); if (pattern) { diag('*** keeping lazily-cached pattern asset key (subject to the generation cap):', key.url); const kept = patternKeys.get(pattern) || []; @@ -1042,10 +1135,18 @@ async function createAssetsCache(ignoreProgressReport = false) { // Exact key match: the asset is cached at its current version. Hashed keys are // content-addressed, so an unchanged hash means the bytes are current - keep them. // Hashless keys carry no version, so re-download them each update unless the app - // opts out via disableHashlessAssetsUpdate. - if (!matched.hash && !self.disableHashlessAssetsUpdate) { - diag('*** refreshing hashless cache key:', key.url); - keysToDelete.push(key.url); + // opts out via disableHashlessAssetsUpdate. The existing entry is deliberately NOT + // deleted here: cache.put() overwrites the same key atomically on success, while + // deleting first meant a failed re-download under lax left NO cached copy at all - + // an asset (or the app shell) that had been available offline went dark until some + // later fetch happened to succeed. Keeping the old entry until the new bytes land + // degrades to "one version stale" instead of "gone". + // The refresh is skipped for the post-BLAZOR_STARTED top-up (ignoreProgressReport): + // that run happens seconds after the install already fetched these exact bytes, and + // re-refusing to mark them cached made every install download all hashless assets + // (and the default document, below) twice. + if (!matched.hash && !self.disableHashlessAssetsUpdate && !ignoreProgressReport) { + diag('*** refreshing hashless cache key (old copy kept until the new download lands):', key.url); } else { cachedAssets.add(matched); } @@ -1062,11 +1163,13 @@ async function createAssetsCache(ignoreProgressReport = false) { }); // Always refresh the default document on each update so navigations pick up the latest - // app shell even when its hash is unchanged. If it was kept above, drop it from the kept - // set and delete its current entry so it is re-fetched below. - if (DEFAULT_ASSET && cachedAssets.has(DEFAULT_ASSET)) { + // app shell even when its hash is unchanged. Dropping it from the kept set is enough to + // re-fetch it below; the current entry is NOT deleted first (cache.put overwrites in + // place), so a failed refresh under lax keeps serving the previous shell offline instead + // of losing offline navigation entirely. Skipped for the top-up run for the same reason + // as the hashless refresh above - the install fetched the shell seconds ago. + if (!ignoreProgressReport && DEFAULT_ASSET && cachedAssets.has(DEFAULT_ASSET)) { cachedAssets.delete(DEFAULT_ASSET); - keysToDelete.push(new Request(createCacheUrl(DEFAULT_ASSET)).url); // get the latest version of the default doc in each update if exists!! } await Promise.all(keysToDelete.map(url => newCache.delete(url))); @@ -1257,7 +1360,13 @@ async function createAssetsCache(ignoreProgressReport = false) { // 0 / ok false BY DESIGN - its real status and bytes are hidden - so the HTTP // status checks below don't apply to it; it goes straight to the cache write. const isOpaque = (response as any).type === 'opaque'; - if (!isOpaque && !response.ok) { + // 206 Partial Content reads response.ok === true, but a partial body must never be + // stored as the asset's full bytes (a resuming middlebox answering the versioned + // request with a fragment would pin that fragment under the asset's key until the + // next hash change - and applyRangeHeader refuses to slice non-200 responses, so + // even ranged requests would serve it broken). Treat it like a failed status; the + // Cache API itself rejects 206 puts, so this also fails cleanly instead of loudly. + if (!isOpaque && (!response.ok || response.status === 206)) { // Retry only transient HTTP statuses (request timeout, rate limit, 5xx). // Permanent ones (404, 403, ...) will not change on retry. if (isRetryableStatus(response.status) && attempt < MAX_RETRIES) { @@ -1419,15 +1528,55 @@ function createNewAssetRequest(asset: any, noCors = false) { return new Request(assetUrl, requestInit); } -// Removes every Bswup cache THIS APP owns except the current CACHE_NAME. Called after a new -// worker claims clients (SKIP_WAITING / CLAIM_CLIENTS) and on the CLEAN_UP command, so stale -// version-suffixed caches from previous installs are reclaimed once they're no longer needed. -// "Owns" is defined by isOwnStaleCacheKey: this scope's previous versions plus legacy -// scope-less buckets; another scope's buckets are never touched. -async function deleteOldCaches() { - const cacheKeys = await caches.keys(); - const promises = cacheKeys.filter(isOwnStaleCacheKey).map(key => caches.delete(key)); - return Promise.all(promises); +// The one gate every cache-pruning call site goes through. deleteOldCaches() spares only the +// receiver's own CACHE_NAME, so running it while ANOTHER version is mid-install would delete +// the bucket that install just created/migrated - wasting its download at best, activating it +// over an empty cache at worst. That hazard is not unique to CLEAN_UP: the SKIP_WAITING / +// CLAIM_CLIENTS chains and the activate-time prune can all race a concurrent update check +// that started staging a newer version. Skipping is always the safe direction - a deferred +// prune just leaves stale buckets until the next safe point (activation with no clients, +// CLEAN_UP, or the next update's migration reclaiming them), whereas a wrong delete destroys +// a live cache. Failures are logged and swallowed for the same reason as before: pruning is +// best-effort tidiness and must never break the signal that follows it in a command chain. +// Whether another (newer) version of this app's worker is currently staged or staging. +function updateStagedOrStaging() { + try { + const reg = self.registration; + // (self as any).serviceWorker is this worker's own ServiceWorker object (newer + // engines). It excludes the receiver itself when an engine briefly leaves it in the + // waiting slot mid skip-waiting transition; where unsupported, a lingering self in + // `waiting` merely defers the prune - deferring is safe, deleting wrongly is not. + const selfWorker = (self as any).serviceWorker; + return !!(reg && (reg.installing || (reg.waiting && reg.waiting !== selfWorker))); + } catch { + // Reading registration state must never break the caller's chain. + return false; + } +} + +function safeDeleteOldCaches(label: string) { + if (updateStagedOrStaging()) { + diag(`${label} - a newer install is staged or staging; skipping old-cache pruning (it runs at the next safe point).`); + return Promise.resolve(); + } + // Removes every Bswup cache THIS APP owns except the current CACHE_NAME, so stale + // version-suffixed caches from previous installs are reclaimed once they're no longer + // needed. "Owns" is defined by isOwnStaleCacheKey: this scope's previous versions plus + // legacy scope-less buckets; another scope's buckets are never touched. + return (async () => { + const cacheKeys = await caches.keys(); + // Re-checked after the async enumeration: an update poll can start staging a newer + // version in that window, and its freshly created bucket would look "stale" to + // isOwnStaleCacheKey. The remaining race (staging begins mid-delete) cannot be closed + // from here, but this narrows it from "any time during enumeration" to microseconds - + // and a lost bucket only costs a re-download, never correctness (the install's own + // puts land in a resurrected empty bucket and the diff re-fetches what is missing). + if (updateStagedOrStaging()) { + diag(`${label} - a newer install started staging mid-prune; skipping.`); + return; + } + await Promise.all(cacheKeys.filter(isOwnStaleCacheKey).map(key => caches.delete(key))); + })().catch((err: any) => diag(`*** ${label} - old-cache pruning failed (continuing):`, err)); } // Whether a cache bucket is a stale one this registration owns - the shared definition for @@ -1469,7 +1618,30 @@ function uniqueAssets(assets: any) { for (let i = 0; i < assets.length; i++) { const a = assets[i]; const isPattern = a.url instanceof RegExp; - const reqUrl = isPattern ? undefined : new Request(a.url).url; + + // new Request() rejects URLs it cannot represent ('https://', credentials in the URL, + // bad percent-encoding, ...). uniqueAssets runs at module-evaluation time, so an + // unguarded throw here killed the whole worker before any install/error handling could + // run - no structured error, nothing controlling the page, only the page's stall + // watchdog to rescue a first install a minute later. One bad entry (a typo in + // externalAssets, a corrupt manifest line) must cost that one asset, not the app. + let reqUrl: string | undefined; + if (!isPattern) { + try { + reqUrl = new Request(a.url).url; + } catch (err) { + // Deferred to handleInstall via INVALID_ASSET_ERRORS (see its declaration): + // reported once per install instead of on every worker cold start. + diag('*** uniqueAssets - skipping asset with an invalid url:', a.url, err); + INVALID_ASSET_ERRORS.push({ + reason: 'request', + message: 'Skipping asset with an invalid url: ' + String(a.url) + ' (' + (err && (err as any).message || String(err)) + ')', + url: String(a.url), + fatal: false, // the asset is dropped; the install itself continues + }); + continue; + } + } // Dedupe on the *resolved* URL rather than the declared string, so 'index.html', // '/index.html' and 'https://host/index.html' - three spellings of one resource - @@ -1551,7 +1723,10 @@ async function matchScopeClients() { // 'WAITING_SKIPPED') are sent as-is. function sendMessage(message: any) { matchScopeClients() - .then((clients: any) => (clients || []).forEach((client: any) => client.postMessage(typeof message === 'string' ? message : JSON.stringify(message)))); + .then((clients: any) => (clients || []).forEach((client: any) => client.postMessage(typeof message === 'string' ? message : JSON.stringify(message)))) + // clients.matchAll can reject in a dying worker / broken runtime; messaging is + // best-effort by design, so log instead of surfacing an unhandled rejection. + .catch((err: any) => diag('*** sendMessage - client enumeration failed:', err)); } // Reports a structured install/runtime failure: logs it for diagnostics, also writes to the diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index 262e86e83f..ac5a684d57 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -90,6 +90,16 @@ if (!BitBswup.initialized) { function runBswup() { const options = extract(); + // Declared FIRST, before any code path that can start Blazor. startBlazor() runs + // just below - and on a controlled page (the normal steady state) it reaches + // startBlazorCore(), whose disarmStallWatchdog() reads this state. With the + // declarations further down, that read landed in the let temporal dead zone and + // crashed runBswup with a ReferenceError on every controlled load where the + // Blazor script had already evaluated. The watchdog functions themselves are + // function declarations (hoisted); only this mutable state is order-sensitive. + let stallTimer: ReturnType | undefined; + let stallArmed = false; + info('starting...'); if (!('serviceWorker' in navigator)) { @@ -113,8 +123,46 @@ if (!BitBswup.initialized) { granted ? info('persistent storage granted.') : warn('persistent storage was not granted - cached assets remain evictable.')); } - let reload: () => Promise; - let cleanup: () => void; + // Resolved at the end of prepareRegistration, once the registration-aware + // reload/cleanup implementations below have replaced the interim ones - and ALSO + // resolved (with registrationFailed set) when register() fails for good, so the + // interim implementations never hang forever: a secondary tab whose own + // register() rejected still receives the sibling install's broadcasts, and its + // splash teardown must not wait on a registration that will never come. + let registrationFailed = false; + let resolveRegistrationReady: () => void; + const registrationReady = new Promise(res => { resolveRegistrationReady = res; }); + + // Interim implementations for the window before the registration resolves. + // A secondary tab opened during a first install receives the worker's broadcasts + // immediately, but its own register() promise cannot resolve until the running + // install job finishes (register jobs for one scope are serialized behind it) - + // so downloadFinished can dispatch here BEFORE prepareRegistration has assigned + // the real reload/cleanup. Handing handlers an undefined reload made the built-in + // splash teardown throw (data.reload is not a function): the splash froze at 100% + // over an app that the claim's controllerchange had meanwhile booted. The interim + // reload waits for the registration; when the app is by then already running with + // nothing staged (exactly that secondary-tab case - the claim completed the first + // install), it resolves so callers can finalize (hide the splash); otherwise it + // defers to the real implementation, which `reload`/`cleanup` point at by then. + let reload: () => Promise = () => registrationReady.then(() => { + if (registrationFailed) { + // No registration will exist in this page's lifetime, so the real reload + // can never be installed; a full reload restarts the whole registration + // flow and is the only meaningful "try again" left. + window.location.reload(); + return; + } + // The controller check separates the two "running app, nothing staged" + // states: a page CLAIMED by a completed first install (controller set - + // finalize, nothing to activate) versus a page force-started after a fatal + // first-install failure (controller null - a Retry wired to this reload must + // fall through to the real implementation, whose no-worker fallback reloads + // to retry the install, not silently resolve into a dead button). + if (blazorStarted && navigator.serviceWorker.controller && registration && !registration.waiting && !registration.installing) return; + return reload(); + }); + let cleanup: () => void = () => { registrationReady.then(() => { if (!registrationFailed) cleanup(); }); }; let blazorStartResolver: (value: unknown) => void; // Captured once the registration resolves so the polling helpers (timer / @@ -130,13 +178,31 @@ if (!BitBswup.initialized) { // funnel through reloadOnce() so the page navigates exactly one time. let refreshing = false; - // Snapshot of "was an active worker already present when registration resolved". - // This is the stable signal for first-install vs update. Reading - // navigator.serviceWorker.controller at message time is NOT reliable: controller - // is null whenever the current navigation wasn't served by the SW - most notably - // on a hard reload (Ctrl+Shift+R) - even when an active worker exists. Using that - // as the discriminator makes Bswup mistake every hard reload for a first install. - let hadActiveWorkerAtStartup = false; + // Whether an ACTIVE worker is known to exist for this app - the first-install vs + // update discriminator used everywhere below. It starts as a snapshot taken when + // the registration resolves ("was a worker already active when this page + // started?") and - crucially - flips to true the moment the first install + // completes and takes control of this page (CLIENTS_CLAIMED / controllerchange / + // WAITING_SKIPPED). Without the flip, a long-lived tab whose session BEGAN with + // the first install kept classifying every LATER real update as another first + // install: updateReady was never emitted, downloadFinished carried + // firstInstall: true - whose auto-reload posted CLAIM_CLIENTS at the OLD active + // worker, wiping the freshly staged update's cache - and a sibling tab's update + // claim took the start-Blazor no-op path instead of the mandatory reload, + // leaving old app code running against new-version caches. + // Reading navigator.serviceWorker.controller at message time instead is NOT + // reliable: controller is null whenever the current navigation wasn't served by + // the SW - most notably on a hard reload (Ctrl+Shift+R) - even when an active + // worker exists; that mistake made every hard reload look like a first install. + // + // Seeded from the controller RIGHT AWAY (not just when the registration resolves): + // the controllerchange/message listeners go live several tasks before register() + // settles, and a sibling tab accepting an update in that window would otherwise be + // misclassified as a first-install claim on this (controlled!) page - skipping the + // mandatory resync reload. A one-time positive controller is proof enough of an + // active worker; the registration snapshot below only ever upgrades this to true + // (hard reload: controller null, reg.active set), never back to false. + let hasActiveWorker = !!navigator.serviceWorker.controller; // ============================================================ @@ -157,10 +223,8 @@ if (!BitBswup.initialized) { // Armed only when the page starts uncontrolled and disarmed when the resolved // registration reveals a previously-active worker: updates never need it - the // app is already running when an update stalls. Configure via the stallTimeout - // script attribute (seconds); 0 disables. - let stallTimer: ReturnType | undefined; - let stallArmed = false; - + // script attribute (seconds); 0 disables. (State lives at the top of runBswup - + // see the TDZ note there.) function armStallWatchdog() { stallArmed = true; bumpStallWatchdog(); @@ -204,6 +268,8 @@ if (!BitBswup.initialized) { // update (an active worker already existed). if (!navigator.serviceWorker.controller) armStallWatchdog(); } catch (e) { + registrationFailed = true; + resolveRegistrationReady(); startBlazor(true); error('serviceWorker registration failed', e); } @@ -242,6 +308,8 @@ if (!BitBswup.initialized) { // word "scope" ("Failed to register a ServiceWorker for scope (...): A // bad HTTP response code (404) ..."), which would defeat the check. if (!options.scope || !err || err.name !== 'SecurityError') { + registrationFailed = true; + resolveRegistrationReady(); startBlazor(true); return error('serviceWorker register promise failed', err); } @@ -252,6 +320,8 @@ if (!BitBswup.initialized) { .register(options.sw, { updateViaCache: 'none' }) .then(prepareRegistration) .catch((retryErr) => { + registrationFailed = true; + resolveRegistrationReady(); startBlazor(true); error('serviceWorker register promise failed', retryErr); }); @@ -259,16 +329,19 @@ if (!BitBswup.initialized) { } function prepareRegistration(reg: ServiceWorkerRegistration) { - // Capture the install/update discriminator exactly once, at the moment the - // registration resolves. reg.active being set here means a previous version - // was already installed => this is an update; otherwise it's a first install. - hadActiveWorkerAtStartup = !!reg.active; + // Upgrade the install/update discriminator when the registration resolves. + // reg.active being set here means a previous version was already installed => + // this is an update; otherwise it's a first install (until that first install + // completes and flips the flag - see hasActiveWorker above). Never downgraded: + // a controller seen at startup, or a first-install claim that already + // completed, both outrank a registration snapshot. + hasActiveWorker = hasActiveWorker || !!reg.active; // The stall watchdog is a first-install boot guarantee. With a previously // active worker the app either starts normally (the controlled path, or the // uncontrolled hard-reload force-start below) or is already running when a // background update stalls - so it must not fire. - if (hadActiveWorkerAtStartup) disarmStallWatchdog(); + if (hasActiveWorker) disarmStallWatchdog(); // Keep the resolved registration around so checkForUpdate() (page API) and the // optional polling helpers can drive reg.update() without re-resolving it. @@ -301,7 +374,7 @@ if (!BitBswup.initialized) { // is deliberately kept *pending*: the page is about to navigate away, so // resolving early would let callers run teardown (e.g. hiding the splash) // against a page that's already reloading. - if (hadActiveWorkerAtStartup) { + if (hasActiveWorker) { whenStaged().then((waiting) => { if (waiting) { waiting.postMessage('SKIP_WAITING'); @@ -409,6 +482,10 @@ if (!BitBswup.initialized) { reg.active?.postMessage('CLEAN_UP'); }; + // The registration-aware reload/cleanup are in place: release any calls the + // interim implementations queued while register() was still pending. + resolveRegistrationReady(); + // The page can be loaded without a controlling service worker even though // a registration already exists - most notably on a hard reload (Ctrl+F5 / // Shift+Reload), which bypasses the SW for the navigation request. In that @@ -422,12 +499,26 @@ if (!BitBswup.initialized) { startBlazor(true); } + if (reg.installing) { + // An install was already mid-flight when this page loaded: its + // 'updatefound' fired before our listener below existed, so without + // watching it here nothing in this tab would ever observe the worker + // reaching 'installed' - updateReady, stateChanged, and the redundant + // first-install rescue were all silently lost for pages opened during a + // download. + info('registration already installing at load:', reg.installing); + watchInstallingWorker(reg.installing); + } + if (reg.waiting) { info('registration waiting:', reg.waiting); if (reg.installing) { info('registration installing:', reg.installing); } else { info('registration is ready:', reg.waiting); + // Same one-announcement-per-staged-worker marker as the statechange + // path (see watchInstallingWorker). + (reg.waiting as any)._bitBswupUpdateReadyAnnounced = true; handle(BswupMessage.updateReady, { reload }); } } @@ -442,7 +533,25 @@ if (!BitBswup.initialized) { return; } - reg.installing.addEventListener('statechange', function (e: any) { + watchInstallingWorker(reg.installing); + }); + + // Follows one installing worker through its state changes: reports + // stateChanged, rescues a silently-dying first install (redundant), and + // announces updateReady once a real update reaches the waiting slot. Used for + // workers discovered via 'updatefound' AND for one already installing when + // the registration resolves. + function watchInstallingWorker(installingWorker: ServiceWorker) { + // Both attachment paths can see the SAME worker: for an install triggered + // by this page's own register() call, the spec queues the tasks as + // installing-attribute-set -> register-promise-resolved -> updatefound, so + // the at-load check AND the updatefound listener each run for that one + // worker. Duplicate statechange listeners delivered every stateChanged + // twice and - worse - announced updateReady twice, making an autoReload + // handler post SKIP_WAITING twice. Watch each worker exactly once. + if ((installingWorker as any)._bitBswupWatched) return; + (installingWorker as any)._bitBswupWatched = true; + installingWorker.addEventListener('statechange', function (e: any) { debug('state changed', e, 'eventPhase:', e.eventPhase, 'currentTarget.state:', e.currentTarget.state); handle(BswupMessage.stateChanged, e); bumpStallWatchdog(); @@ -455,7 +564,7 @@ if (!BitBswup.initialized) { // error to trigger the force-start. Boot from the network now; this // is idempotent when an error message already force-started Blazor, // and the install itself is retried on the next load. - if (e.currentTarget.state === 'redundant' && !hadActiveWorkerAtStartup && !navigator.serviceWorker.controller) { + if (e.currentTarget.state === 'redundant' && !hasActiveWorker && !navigator.serviceWorker.controller) { disarmStallWatchdog(); warn('first-install service worker went redundant - starting Blazor without a service worker.'); startBlazor(true); @@ -464,7 +573,7 @@ if (!BitBswup.initialized) { if (!reg.waiting) return; - if (!hadActiveWorkerAtStartup) { + if (!hasActiveWorker) { // First install: the worker only passes through 'installed' (waiting) // transiently - with no previous worker and no controlled clients the // browser activates it immediately. This is NOT "an update is staged @@ -479,13 +588,22 @@ if (!BitBswup.initialized) { info('update finished.'); - // Notify listeners that an update is staged and ready. The - // registration-time check only fires updateReady for updates already - // waiting on load; updates discovered in the same session surface here - // instead, so emit it for them too. + // Notify listeners that an update is staged and ready - exactly once + // per staged worker. Statechange listeners outlive the waiting slot: + // when a staged worker B is superseded by a newer install C, B's + // 'redundant' statechange fires while reg.waiting already points at C, + // and without the marker B's listener would re-announce C right after + // C's own 'installed' transition announced it (double prompts; two + // SKIP_WAITINGs under autoReload). The marker lives on the STAGED + // worker, so the one case that must still announce on a 'redundant' + // event keeps working: a worker dying while an un-announced older + // update sits in the waiting slot (staged-at-load with an install in + // flight) announces that older update here for the first time. + if ((reg.waiting as any)._bitBswupUpdateReadyAnnounced) return; + (reg.waiting as any)._bitBswupUpdateReadyAnnounced = true; handle(BswupMessage.updateReady, { reload }); }); - }); + } } function handleControllerChange(e: any) { @@ -506,9 +624,9 @@ if (!BitBswup.initialized) { // must reload to re-sync. See "Stuff I wish I'd known about service // workers" on the controllerchange reload pattern. // - // We distinguish case 2 from case 3 with hadActiveWorkerAtStartup: a controller - // change only signals a real *update* when a worker was already active when this - // page started. First install never had one, so we skip the reload there - and + // We distinguish case 2 from case 3 with hasActiveWorker: a controller + // change only signals a real *update* when an active worker was already known. + // First install never had one, so we skip the reload there - and // instead make sure the app boots. Being claimed on a first install means the // install completed and this page is expected to start Blazor, but the // CLIENTS_CLAIMED reply only goes to the ONE tab that posted CLAIM_CLIENTS: a @@ -518,8 +636,12 @@ if (!BitBswup.initialized) { // until the stall watchdog fired. startBlazorCore is idempotent, so in the // initiating tab (where CLIENTS_CLAIMED also lands) this is a no-op or a // harmless head start. - if (!hadActiveWorkerAtStartup) { + if (!hasActiveWorker) { info('controller changed on first install - starting Blazor instead of reloading.'); + // The first install now controls this page: from here on this session is a + // controlled one, and any LATER install cycle is a real update that must + // announce updateReady and reload - not repeat the first-install flow. + hasActiveWorker = true; startBlazor(true); return; } @@ -560,11 +682,23 @@ if (!BitBswup.initialized) { // to pick up the new version. reloadOnce() coordinates with the // 'controllerchange' that also fires once the new worker claims this client, // so we reload only once. - if (!hadActiveWorkerAtStartup) { + if (!hasActiveWorker) { + // Same transition as the controllerchange path: the first install has + // activated and claimed this page, so later cycles are real updates. + hasActiveWorker = true; startBlazor(true); return; } - reloadOnce(); + // Reload only when actually controlled. The broadcast also reaches + // uncontrolled tabs - a hard-reloaded page during an update, or an + // SW-free page during a cleanup-worker teardown - which already run + // network-fresh code; reloading them only discards their in-page state. + // Make sure they are booted instead (idempotent when already running). + if (navigator.serviceWorker.controller) { + reloadOnce(); + } else { + startBlazor(true); + } return; } @@ -573,6 +707,9 @@ if (!BitBswup.initialized) { // (script-tag/autostart checks, single-start, missing-global protection) // instead of calling Blazor.start() directly. Capture e.source up front // because it can be nulled out by the time the start promise settles. + // The claim also completes the first install for this page - flip the + // discriminator so later install cycles are treated as real updates. + hasActiveWorker = true; const source = e.source; const startPromise = startBlazor(true); @@ -605,8 +742,29 @@ if (!BitBswup.initialized) { // sharing this origin (other scopes / mounted sub-apps), and tearing those // down would break them. getRegistration() (no arg) resolves the // registration whose scope controls this page - this app's own worker. + // + // Reload ONLY when this page is currently controlled: the reload is what + // detaches a controlled page from the worker being removed. An + // uncontrolled page is already running SW-free, and reloading it while + // the served HTML still registers the cleanup worker would re-register, + // re-activate, re-message UNREGISTER - an infinite reload loop. Routed + // through reloadOnce so it coordinates with the controllerchange reload + // that the cleanup worker's takeover also triggers. navigator.serviceWorker.getRegistration().then(reg => { - Promise.resolve(reg?.unregister()).then(() => window.location.reload()); + Promise.resolve(reg?.unregister()).then(() => { + if (navigator.serviceWorker.controller) { + reloadOnce(); + } else { + // An uncontrolled page has nothing to detach from - and while + // a cleanup worker is deployed, this teardown signal is also + // the earliest moment the page knows no install is coming. + // Boot right away instead of waiting for the delayed + // WAITING_SKIPPED nudge (or, worst case, the stall watchdog): + // this keeps app startup instant for the whole + // cleanup-deployment window. + startBlazor(true); + } + }); }); return; } @@ -633,14 +791,19 @@ if (!BitBswup.initialized) { const { type, data } = message; if (type === 'install') { - handle(BswupMessage.downloadStarted, data); + // firstInstall rides along on every UI-facing message (not just + // downloadFinished/error) so the built-in progress UI can tell a + // first-install splash - the whole UI, worth taking the screen over - from + // a background update, where painting a full-viewport overlay on top of a + // healthy running app blocks it for no reason. + handle(BswupMessage.downloadStarted, { ...data, firstInstall: !hasActiveWorker }); } if (type === 'progress') { - handle(BswupMessage.downloadProgress, data); + handle(BswupMessage.downloadProgress, { ...data, firstInstall: !hasActiveWorker }); if (data.percent >= 100) { - const firstInstall = !hadActiveWorkerAtStartup; + const firstInstall = !hasActiveWorker; handle(BswupMessage.downloadFinished, { reload, cleanup, firstInstall }); } } @@ -653,7 +816,7 @@ if (!BitBswup.initialized) { // background update, where the previous worker keeps serving a healthy // running app that must not be covered by an install-failure panel. Same // discriminator the downloadFinished payload already carries. - handle(BswupMessage.error, { ...data, reload, firstInstall: !hadActiveWorkerAtStartup }); + handle(BswupMessage.error, { ...data, reload, firstInstall: !hasActiveWorker }); // Last-resort boot guarantee. A fatal failure means the install aborted, so // this worker never reaches 'active'. On a *first* install that is fatal to @@ -665,14 +828,14 @@ if (!BitBswup.initialized) { // worker, and the next load can retry the install. startBlazorCore is // idempotent, so this is a no-op when the app is already running (the update // case, where the previous worker keeps serving normally). - if (data && data.fatal !== false && !hadActiveWorkerAtStartup && !navigator.serviceWorker.controller) { + if (data && data.fatal !== false && !hasActiveWorker && !navigator.serviceWorker.controller) { warn('install failed before the app could start - starting Blazor without a service worker.'); startBlazor(true); } } if (type === 'bypass') { - const firstInstall = data?.firstTime || !hadActiveWorkerAtStartup; + const firstInstall = data?.firstTime || !hasActiveWorker; handle(BswupMessage.downloadFinished, { reload, cleanup, firstInstall }); } @@ -933,12 +1096,38 @@ if (!BitBswup.initialized) { if (typeof resolved === 'function') { options.handler = resolved; } else { + // Boot guarantee for the no-handler case. The first-install handshake + // is normally driven by a handler calling data.reload() on + // downloadFinished; with no handler registered at all (progress script + // absent, custom handler never assigned) nothing would ever post + // CLAIM_CLIENTS and the app would sit behind the splash until the + // stall watchdog fired a minute later. Drive the completion here + // instead. Deliberately first-install only: auto-activating an UPDATE + // would force a reload the app never consented to - an unhandled + // update simply stays staged, exactly as if a handler ignored + // updateReady, and activates on the next full restart. + const message = args[0]; + const data = args[1]; + if (message === BswupMessage.downloadFinished && data && data.firstInstall && typeof data.reload === 'function') { + warn('no progress handler found - completing the first install automatically.'); + data.reload(); + return; + } warn('progress handler not found or is not a function!'); return; } } - options.handler!(...args); + try { + options.handler!(...args); + } catch (err) { + // An app handler that throws must not break the update pipeline. The 100% + // downloadProgress and downloadFinished dispatch in one synchronous block, + // so an uncaught exception between them would swallow the very message + // whose reload() completes a first install - hanging the app behind the + // splash until the stall watchdog for a bug in page code. + error('the progress handler threw', err); + } } function shouldLog(level: 'error' | 'warn' | 'info' | 'verbose' | 'debug'): boolean { diff --git a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css index 486bc1f5dc..cb56d3d26d 100644 --- a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css +++ b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css @@ -9,6 +9,14 @@ z-index: 999999999; text-align: center; font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; + /* The full-viewport root itself swallows no clicks: when the app is force-started under + a fatal first-install error panel (or any host keeps content behind the splash), the + page stays usable outside the splash content. Interactive children re-enable below. */ + pointer-events: none; +} + +#bit-bswup > * { + pointer-events: auto; } .bit-bswup-container { @@ -46,6 +54,10 @@ position: fixed; top: 10px; right: 10px; + /* The button no longer lives inside #bit-bswup (see BswupProgress.razor), so it no longer + inherits the overlay's stacking; without its own z-index any fixed app chrome (sticky + headers, toasts - usually parked exactly top-right) painted over it. */ + z-index: 999999999; } #bit-bswup-assets { @@ -93,3 +105,18 @@ #bit-bswup-error-retry { display: none; } + +/* Screen-reader-only: keeps the role="status" announcement region in the accessibility tree + without rendering anything (display:none would silence it entirely). */ +.bit-bswup-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + overflow: hidden; + white-space: nowrap; +} diff --git a/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor b/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor index 40ce8b0052..7e7064074b 100644 --- a/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor +++ b/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor @@ -25,5 +25,8 @@ - + @* No hand-written #bit-bswup-reload here: since v-10-5-0 BswupProgress always renders + the update-ready button (and its screen-reader status region) itself, outside the + overlay - even with custom ChildContent. A second copy would be a duplicate id that + shadows the component's working button. Restyle it via ::deep #bit-bswup-reload. *@ diff --git a/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css b/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css index a969e544ed..61597df85b 100644 --- a/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css +++ b/src/Bswup/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css @@ -80,4 +80,7 @@ position: fixed; top: 10px; right: 10px; + /* Above any fixed app chrome - the button sits outside the splash overlay and does not + inherit its stacking. */ + z-index: 999999; } \ No newline at end of file diff --git a/src/Bswup/FullDemo/Client/wwwroot/service-worker.js b/src/Bswup/FullDemo/Client/wwwroot/service-worker.js index fe30d7444d..e6003d80c0 100644 --- a/src/Bswup/FullDemo/Client/wwwroot/service-worker.js +++ b/src/Bswup/FullDemo/Client/wwwroot/service-worker.js @@ -9,7 +9,9 @@ self.assetsInclude = []; self.assetsExclude = [/\.scp\.css$/, /weather\.json$/]; self.defaultUrl = '/'; self.prohibitedUrls = []; -self.assetsUrl = '/service-worker-assets.js'; +// self.assetsUrl is deliberately NOT set: since v-10-5-0 it defaults to a relative +// 'service-worker-assets.js', resolved next to this service-worker file - which keeps +// sub-path-mounted apps working. Set it explicitly only when the file lives elsewhere. // more about SRI (Subresource Integrity) here: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity // online tool to generate integrity hash: https://www.srihash.org/ or https://laysent.github.io/sri-hash-generator/ diff --git a/src/Bswup/FullDemo/Client/wwwroot/service-worker.published.js b/src/Bswup/FullDemo/Client/wwwroot/service-worker.published.js index 61b84abb44..082ff43300 100644 --- a/src/Bswup/FullDemo/Client/wwwroot/service-worker.published.js +++ b/src/Bswup/FullDemo/Client/wwwroot/service-worker.published.js @@ -4,7 +4,9 @@ self.assetsInclude = []; self.assetsExclude = [/\.scp\.css$/, /weather\.json$/]; self.defaultUrl = "/"; self.prohibitedUrls = []; -self.assetsUrl = '/service-worker-assets.js'; +// self.assetsUrl is deliberately NOT set: since v-10-5-0 it defaults to a relative +// 'service-worker-assets.js', resolved next to this service-worker file - which keeps +// sub-path-mounted apps working. Set it explicitly only when the file lives elsewhere. self.externalAssets = [ //{ // "hash": "sha256-lDAEEaul32OkTANWkZgjgs4sFCsMdLsR5NJxrjVcXdo=", diff --git a/src/Bswup/FullDemo/Server/Components/App.razor b/src/Bswup/FullDemo/Server/Components/App.razor index 556bc4f45e..03c639097b 100644 --- a/src/Bswup/FullDemo/Server/Components/App.razor +++ b/src/Bswup/FullDemo/Server/Components/App.razor @@ -30,12 +30,14 @@ + @* blazorScript is omitted on purpose: both default entry scripts are auto-detected + (fingerprints and query strings included) - the attribute is only needed for + non-default script paths. *@ + handler="bitBswupHandler"> diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor index 19a3f2febc..af229ba46e 100644 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor @@ -23,5 +23,8 @@ - + @* No hand-written #bit-bswup-reload here: since v-10-5-0 BswupProgress always renders + the update-ready button (and its screen-reader status region) itself, outside the + overlay - even with custom ChildContent. A second copy would be a duplicate id that + shadows the component's working button. Restyle it via ::deep #bit-bswup-reload. *@ diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css index 218b51c948..60037ea8fb 100644 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css @@ -80,4 +80,7 @@ right: 10px; display: none; position: fixed; + /* Above the fixed page header (z-index: 1000 in MainLayout.razor.css) - the button sits + outside the splash overlay and does not inherit its stacking. */ + z-index: 999999; } \ No newline at end of file diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js index 0431b8cdc5..9024137b2d 100644 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js @@ -11,7 +11,9 @@ self.assetsExclude = [ ]; self.defaultUrl = '/'; self.prohibitedUrls = []; -self.assetsUrl = '/service-worker-assets.js'; +// self.assetsUrl is deliberately NOT set: since v-10-5-0 it defaults to a relative +// 'service-worker-assets.js', resolved next to this service-worker file - which keeps +// sub-path-mounted apps working. Set it explicitly only when the file lives elsewhere. // more about SRI (Subresource Integrity) here: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity // online tool to generate integrity hash: https://www.srihash.org/ or https://laysent.github.io/sri-hash-generator/ diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js index 95f1b23d04..12f9b3c525 100644 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js @@ -6,7 +6,9 @@ self.assetsExclude = [ ]; self.defaultUrl = '/'; self.prohibitedUrls = []; -self.assetsUrl = '/service-worker-assets.js'; +// self.assetsUrl is deliberately NOT set: since v-10-5-0 it defaults to a relative +// 'service-worker-assets.js', resolved next to this service-worker file - which keeps +// sub-path-mounted apps working. Set it explicitly only when the file lives elsewhere. // more about SRI (Subresource Integrity) here: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity // online tool to generate integrity hash: https://www.srihash.org/ or https://laysent.github.io/sri-hash-generator/ diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor index a7a2710331..d0d3029849 100644 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor +++ b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor @@ -23,12 +23,14 @@ + @* blazorScript is omitted on purpose: both default entry scripts are auto-detected + (fingerprints and query strings included) - the attribute is only needed for + non-default script paths. *@ + handler="bitBswupHandler"> diff --git a/src/Bswup/README.md b/src/Bswup/README.md index 17cc8ec070..324b5360cb 100644 --- a/src/Bswup/README.md +++ b/src/Bswup/README.md @@ -47,14 +47,16 @@ app.UseStaticFiles(new StaticFileOptions - `scope`: The scope of the service-worker ([read more](https://developer.chrome.com/docs/workbox/service-worker-lifecycle/#scope)). Defaults to `/`. A service-worker can only control URLs beneath its own folder unless the server sends a `Service-Worker-Allowed` header, so if your app is mounted on a sub-path (e.g. `https://host/myapp/`) set this to that sub-path. If the browser refuses the configured scope, Bswup automatically retries with the default scope - the folder containing the service-worker script - so the app keeps working with offline support rather than losing the service-worker entirely; the fallback is reported as a warning in the console. The scope also namespaces the caches: buckets are named `bit-bswup: - `, so several Bswup apps mounted under different scopes on one origin keep fully independent caches (each app only ever migrates and prunes its own; **changed in v-10-5-0** - the previous scope-less name `bit-bswup - ` made sibling apps evict each other's caches on every update). On upgrade, entries from a legacy-named bucket are migrated into the scoped bucket without re-downloading, and the legacy bucket is then cleaned up; on a multi-app origin a not-yet-upgraded sibling may lose its legacy bucket once during that transition (the old behavior did this on every update) and refills it on its next load. - `log`: The log level of the Bswup logger. Available options are: `none`, `error`, `warn`, `info`, `verbose`, and `debug`. Each level includes everything above it (e.g. `info` also shows `warn` and `error`). Defaults to `warn`. Use `none` to silence all output. -- `sw`: The file path of the service-worker file. -- `handler`: The name of the handler function for the service-worker events. +- `sw`: The file path of the service-worker file. Defaults to `service-worker.js`. +- `handler`: The name of the global handler function for the service-worker events. Defaults to `bitBswupHandler` - which is also the name the built-in progress script (`bit-bswup.progress.js`, see the `BswupProgress` section below) registers, so the two wire up without configuration. The handler is re-resolved until found, so it may be registered after `bit-bswup.js` loads. **If no handler is ever registered, Bswup still completes a first install on its own** (it drives the finish handshake itself so the app boots instead of waiting for the stall watchdog); updates are simply left staged until the next full restart. - `blazorScript`: The path of the Blazor entry-point script (the one you added `autostart="false"` to in step 3). When omitted, Bswup auto-detects both the Blazor Web App script (`_framework/blazor.web.js`) and the standalone Blazor WebAssembly script (`_framework/blazor.webassembly.js`), so you only need to set this if your script lives at a non-default path. Matching is fingerprint-tolerant: the fingerprinted names that .NET 9+ emits when the script is referenced through `@Assets["..."]` / the ImportMap (e.g. `_framework/blazor.web..js`) are recognized automatically, both for the auto-detected defaults and for an explicitly configured `blazorScript` value. - `updateInterval`: Number of seconds between automatic update checks. By default the browser only re-checks the service worker on navigation and roughly every 24 hours, so a long-lived SPA tab can run a stale version for a long time. Set this to a positive number (e.g. `3600` for hourly) to have Bswup call `reg.update()` on a timer. Checks are skipped while the tab is in the background (the browser throttles those timers anyway) and resume when it becomes visible again. Omit or set to `0` to disable (the default). - `updateOnVisibility`: When set to `true`, Bswup checks for an update every time the tab returns to the foreground (the `visibilitychange` event). This is a lightweight way to catch updates right when a user comes back to a tab they left open. Disabled by default. - `stallTimeout`: Number of seconds of complete service-worker *silence* (no message, no lifecycle event) after which, on a **first install** only, Bswup stops waiting and starts Blazor directly from the network. This is the last line of defense against install failures that report nothing - most notably the browser terminating the service worker mid-install (browsers cap how long an install may run; Chromium kills it after ~5 minutes) - which would otherwise leave the app frozen behind the splash forever, since a first install only starts Blazor once the install completes. The page is uncontrolled at that point, so it behaves exactly as if no service worker existed, and the install is retried on the next load. Every progress message resets the timer, so a slow-but-healthy download never triggers it - only true silence does. Defaults to `60`; set `0` to disable. Updates are unaffected: the app is already running when an update stalls. - `persistStorage`: When set to `true`, Bswup asks the browser to make the origin's storage persistent (`navigator.storage.persist()`) at startup. By default everything Bswup caches lives in *best-effort* storage: browsers silently reclaim it under disk pressure, and Safari deletes **all** storage for a site that has not been interacted with for seven days - the user comes back offline to an app that no longer boots. Persistent storage exempts the origin from that eviction. Disabled by default because the request can show a permission prompt (Firefox) and grant odds are engagement-based elsewhere; for the best odds, leave this off and call `BitBswup.persistStorage()` yourself at a high-signal moment (see the JavaScript API below). +- `options`: The name of a global configuration object to read settings from. Defaults to `bitBswup`. Every option above can also be supplied as a property of that object (e.g. `window.bitBswup = { sw: 'my-sw.js', updateInterval: 3600 }` before the script loads); the object is merged over the built-in defaults first, and any script-tag attribute then overrides the matching property. This is the way to configure Bswup when the script is injected dynamically, where attribute-based configuration may not be readable. + > You can remove any of these attributes, and use the default values mentioned above. 5. Add a handler function like the below code to handle multiple events of the Bswup, or you can follow the full sample code which is provided in the Demo projects of this repo. @@ -238,8 +240,8 @@ The other settings are: - `assetsInclude`: The list of file names from the assets list to **include** when the Bswup tries to store them in the cache storage (regex supported). - `assetsExclude`: The list of file names from the assets list to **exclude** when the Bswup tries to store them in the cache storage (regex supported). -- `externalAssets`: The list of external assets to cache that are not included in the auto-generated assets file. For example, if you're not using `index.html` (like `_host.cshtml`), then you should add `{ "url": "/" }`. Cross-origin entries (like the Google Tag Manager example above) are fetched in CORS mode first; when the host does not send CORS headers, Bswup retries with a `no-cors` request and caches the resulting *opaque* response so the asset still works offline (script and img tags consume opaque responses normally). This fallback is skipped for assets with an integrity check enabled, since an opaque body cannot be verified. Note that browsers deliberately pad opaque responses in storage-quota accounting (Chromium reserves several megabytes per entry), so prefer CORS-enabled hosts when you control them. Media assets work too: requests carrying a `Range` header (audio/video elements) are answered with a real `206 Partial Content` sliced from the cached full body when the asset is cached (Safari refuses to play media served as a `200` in response to a ranged request); when it is not cached yet, the ranged request goes to the network with its `Range` header intact so the server can answer `206` itself, and partial responses are never written to the cache - only full bodies are. Entries cached for `RegExp` patterns (server-generated file names unknown ahead of time, e.g. `_framework/resource-collection..js`) are kept across updates so the app still boots offline, but only the newest three generations per pattern survive each update - older fingerprints are evicted so the cache cannot grow without bound. -- `defaultUrl`: The default page URL. Use `/` when using `_Host.cshtml`. The value must match an entry that actually exists in `service-worker-assets.js` or `externalAssets`; the comparison uses *resolved* URLs, so equivalent spellings match (`'index.html'` and `'/index.html'` are the same resource for a root-mounted app - they differ, correctly, for an app mounted on a sub-path). When nothing matches, offline navigation cannot work (navigations silently pass through to the network) and Bswup logs a `defaultUrl ... matches no asset` warning to the console at startup. +- `externalAssets`: The list of external assets to cache that are not included in the auto-generated assets file. For example, if you're not using `index.html` (like `_host.cshtml`), then you should add `{ "url": "/" }`. Accepted entry shapes: an object with a `url` (a concrete string, or a `RegExp` for server-generated names unknown ahead of time), a bare string (shorthand for `{ "url": "..." }`), or a bare `RegExp`; a single value also works without the array. An entry may carry a `hash` alongside its `url` - an SRI digest (`sha256-...`) that participates in the `?v=` cache busting and, when `enableIntegrityCheck` is on, in Subresource Integrity verification, exactly like a manifest asset. Entries whose `url` cannot be parsed as a request URL are skipped with a non-fatal `request` error instead of breaking the worker. Cross-origin entries (like the Google Tag Manager example above) are fetched in CORS mode first; when the host does not send CORS headers, Bswup retries with a `no-cors` request and caches the resulting *opaque* response so the asset still works offline (script and img tags consume opaque responses normally). This fallback is skipped for assets with an integrity check enabled, since an opaque body cannot be verified. Note that browsers deliberately pad opaque responses in storage-quota accounting (Chromium reserves several megabytes per entry), so prefer CORS-enabled hosts when you control them. Media assets work too: requests carrying a `Range` header (audio/video elements) are answered with a real `206 Partial Content` sliced from the cached full body when the asset is cached (Safari refuses to play media served as a `200` in response to a ranged request); when it is not cached yet, the ranged request goes to the network with its `Range` header intact so the server can answer `206` itself, and partial responses are never written to the cache - only full bodies are. Entries cached for `RegExp` patterns (server-generated file names unknown ahead of time, e.g. `_framework/resource-collection..js`) are kept across updates so the app still boots offline, but only the newest three generations per pattern survive each update - older fingerprints are evicted so the cache cannot grow without bound. +- `defaultUrl`: The default page URL, served from cache for navigation requests (the SPA fallback). Defaults to `index.html`; use `/` when using `_Host.cshtml`. The value must match an entry that actually exists in `service-worker-assets.js` or `externalAssets`; the comparison uses *resolved* URLs, so equivalent spellings match (`'index.html'` and `'/index.html'` are the same resource for a root-mounted app - they differ, correctly, for an app mounted on a sub-path). When nothing matches, offline navigation cannot work (navigations silently pass through to the network) and Bswup logs a `defaultUrl ... matches no asset` warning to the console at startup. Navigations whose URL is itself a managed asset are served that asset instead of the default document (**changed in v-10-5-0**): opening `/manifest.json` or an image directly in a tab shows that file, while route URLs (`/counter`, ...) match no asset and still get the app shell. - `assetsUrl`: The file path of the service-worker assets file generated at compile time (the default file name is `service-worker-assets.js`). The default is resolved relative to the service-worker script's own location - which is also where Blazor publishes the file - so it works unchanged for apps mounted on a sub-path (`https://host/myapp/`). Set it explicitly only when the file lives somewhere else; a leading `/` makes the path origin-absolute. - `prohibitedUrls`: The list of file names that should not be accessed (regex supported). Matching requests are answered by the service-worker with `403 Forbidden` and a short `text/plain` body, for every HTTP method. **Changed in v-10-5-0:** previous versions answered `405 Method Not Allowed`; if your code detects a blocked URL by checking the status, look for `403`. **This is a client-side convenience, not a security boundary:** enforcement happens only inside the service worker, which is bypassed whenever the page is not controlled (the very first visit, a hard reload / Shift+Reload, browsers without service-worker support) and by any client that talks to the server directly. Access control for these URLs must be enforced on the server. - `caseInsensitiveUrl`: Enables case-insensitive URL checking. This applies both to the asset cache matching and to every URL-matching regex list (`prohibitedUrls`, `serverHandledUrls`, `serverRenderedUrls`, `assetsInclude`, `assetsExclude`): when enabled, those patterns are compiled with the `i` flag so e.g. `prohibitedUrls: [/\/admin\//]` also blocks `/ADMIN/`. Patterns that already specify the `i` flag are left unchanged. @@ -253,7 +255,13 @@ The other settings are: Note that `/\.wasm/`, `/\.pdb/` and `/\.html/` are deliberately unanchored (mirroring the standard Blazor template), so related variants such as `foo.wasm.br` also match when they appear in the manifest. - `ignoreDefaultExclude`: Ignores the default asset **excludes** array which is provided by the Bswup itself which is like this: ```js - [/^_content\/Bit\.Bswup\/bit-bswup\.sw\.js$/, /^service-worker\.js$/] + [ + /^_content\/Bit\.Bswup\/bit-bswup\.sw\.js$/, + /^_content\/Bit\.Bswup\/bit-bswup\.sw\.min\.js$/, + /^_content\/Bit\.Bswup\/bit-bswup\.sw-cleanup\.js$/, + /^_content\/Bit\.Bswup\/bit-bswup\.sw-cleanup\.min\.js$/, + /^service-worker\.js$/, + ] ``` #### Keep in mind that caching service-worker related files will corrupt the update cycle of the service-worker. Only the browser should handle these files. - `isPassive`: Enables the Bswup's passive mode. In this mode, the assets won't be cached in advance but rather upon initial request. Note that passive mode does not skip the full download entirely: on a *first* install, once Blazor has started, the service worker still tops up the cache in the background with every asset not yet fetched, so the app ends up fully offline-capable - what passive mode buys is that the first paint is never blocked behind a full precache. Assets being lazily fetched by the app while that top-up runs can be downloaded twice in that window (both writes land on the same cache keys, so this is a bandwidth cost, not a correctness issue). @@ -275,6 +283,46 @@ The other settings are: - `AlwaysPrerender`: Enables the prerendering of the default document for every navigation request. - `FullOffline`: Enables the full offline mode where all assets are cached and served from the cache from the first time the app is loaded. +## The built-in progress UI (`BswupProgress`) + +Instead of writing the step-5 handler yourself, you can use the built-in splash/progress UI. It consists of the `BswupProgress` Razor component (the markup: progress bar, percentage, asset log, reload button, failure panel) and the `bit-bswup.progress.js` script, which registers the default `bitBswupHandler` and drives that markup. Reference both in your host document: + +```html + +... + + +``` + +```razor + +``` + +Component parameters (each maps to a `data-bit-bswup-*` attribute the script reads at load - the component emits no inline `