From 825d8af5bc41379f434237930bfa59205a38800b Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 31 May 2026 14:34:10 +0330 Subject: [PATCH 01/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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 ` - -@*
    - -
    -
    Loading...
    -
    -
    -
    -
    -
    -
    *@ - -
    - -
    - - - - - - @* 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 deleted file mode 100644 index 60037ea8fb..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/DemoBswupProgressBar.razor.css +++ /dev/null @@ -1,86 +0,0 @@ -/*::deep #bit-bswup { - display: none; - position: fixed; - left: 0; - bottom: 50px; - text-align: center; - width: 200px; - font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; -} - - ::deep #bit-bswup .bswup-container { - width: 80%; - margin: 0 auto; - } - - ::deep #bit-bswup .bswup-title { - color: white; - font-size: 14px; - margin-bottom: 1px; - } - - ::deep #bit-bswup .bswup-progress { - background-color: rgb(237, 235, 233); - } - - ::deep #bit-bswup #bit-bswup-progress-bar { - background-color: rgb(0, 120, 212); - border-radius: 10px; - height: 3px; - }*/ - -::deep #bit-bswup { - top: 2px; - left: 50%; - display: none; - z-index: 999999; - position: fixed; - text-align: center; - transform: translateX(-50%); - font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; -} - - ::deep #bit-bswup .bswup-container { - width: 3rem; - height: 3rem; - display: block; - position: relative; - } - - ::deep #bit-bswup .bswup-container circle { - fill: none; - stroke: #e0e0e0; - stroke-width: 0.2rem; - transform-origin: 50% 50%; - transform: rotate(-90deg); - } - - ::deep #bit-bswup .bswup-container circle:last-child { - stroke: #1b6ec2; - transition: stroke-dasharray 0.05s ease-in-out; - stroke-dasharray: calc(3.141 * var(--bit-bswup-percent, 0%) * 0.8), 500%; - } - - ::deep #bit-bswup .bswup-progress-text { - top: 50%; - left: 50%; - font-size: 12px; - position: absolute; - text-align: center; - font-weight: normal; - transform: translate(-50%, -50%); - } - - ::deep #bit-bswup .bswup-progress-text::after { - content: var(--bit-bswup-percent-text, ""); - } - -::deep #bit-bswup-reload { - top: 10px; - 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/Layout/MainLayout.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor deleted file mode 100644 index 0fd1b20ecf..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor +++ /dev/null @@ -1,9 +0,0 @@ -@inherits LayoutComponentBase - -@Body - -
    - An unhandled error has occurred. - Reload - 🗙 -
    diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor.css b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor.css deleted file mode 100644 index df8c10ff29..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Layout/MainLayout.razor.css +++ /dev/null @@ -1,18 +0,0 @@ -#blazor-error-ui { - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Counter.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Counter.razor deleted file mode 100644 index e1f857fb6c..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Counter.razor +++ /dev/null @@ -1,32 +0,0 @@ -@page "/counter" - -Counter - - - -
    Counter
    - -
    - -Home - -
    -
    - -
    - -
    - - - -@code { - int count; -} \ No newline at end of file diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Home.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Home.razor deleted file mode 100644 index 1594fe7a5e..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Pages/Home.razor +++ /dev/null @@ -1,17 +0,0 @@ -@page "/" - -Home - -

    111

    - -

    Home

    - -go to counter - -
    -
    -
    - - - - \ No newline at end of file diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Program.cs b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Program.cs deleted file mode 100644 index 519269f21b..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Program.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); - -await builder.Build().RunAsync(); diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Routes.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Routes.razor deleted file mode 100644 index d0df781615..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/_Imports.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/_Imports.razor deleted file mode 100644 index 7d6a36f7aa..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/_Imports.razor +++ /dev/null @@ -1,9 +0,0 @@ -@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.NewDemo.Client diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/app.css b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/app.css deleted file mode 100644 index e398853b8e..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/app.css +++ /dev/null @@ -1,29 +0,0 @@ -h1:focus { - outline: none; -} - -.valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; -} - -.invalid { - outline: 1px solid #e50000; -} - -.validation-message { - color: #e50000; -} - -.blazor-error-boundary { - background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; - padding: 1rem 1rem 1rem 3.7rem; - color: white; -} - - .blazor-error-boundary::after { - content: "An error has occurred." - } - -.darker-border-checkbox.form-check-input { - border-color: #929292; -} diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/bit-bw-64.png b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/bit-bw-64.png deleted file mode 100644 index 00f5e6c3a761e017b86cc362f4f783bd3829814d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1180 zcmV;N1Y`S&P)Px#1ZP1_K>z@;j|==^1poj6j8IHeMOIc;S65e6RaH+@=wz|5y zc6N5#+uQ&D|KsE1VPRo@et!A+`TqX?j*gB}Qc{6|f&Kmcl9G~ufPj&akx@}mP*6~0 zWMqSbgO87odwY9RQ&V_&c#MpUpP!%8)6?(o@Avoj=;-Lh#l?k%g|)S{`1tse@$vBR@M2+9#|=jP_- zT3TA{?Cjm$-RFMd5oSfL$ z*v-w&t*x!9s;YE!bj-}m*4EZ*YimbGM|^yIm6esZx3|8&zMGqyOG`^-Wo2n;X{Dv5 z)z#I%zrU=ktj^BPh=_=4YHHx%;HIXgz`($il$74y-inHf!otFyo}RC-uX1v7va+(U zu&`cUUabX5ivR!s32;bRa{vGf6951U69E94oEQKA0+~rfK~z{r?Uid=5@Mw9rB*O3Q0die-g`SyXhc_?_y;Jow6+~Ohygr89QJ~WnuoXhgy`_=Dr_Ep1OdRd?f@3Tii~9nBp5Egp7&sqi zB>MXL4Sb$Fl^nol9c)1=eOgrToyq8DaRSZ_3NdsZ2)iJ}u(P-pwxA=RvTa5}hBVM# zAx80ONWoa$d$t7*sU9^P@3ttRR@cOY9|w&JF1Gn2p=c~6#~yiTSv{B2v&6cDl{Rb{ z^)8|!xI8(fb8c$+X&GHHDFqS?6R@rZXM~7f>l#yM`!`AuGwgPtM7w?i!x^jqUJznd zyLn4aBc9IM-@*|L7Ax&Ur}{BY&FN6QEyR4wkew~LQ@dIn!QH9t{Xs_Nv@$5&L-G9w zb}~{f?X`o4rV0`E2wyzbpeO|yh(5vVoKG(-)@gaWnNmBuQG1GlXUq{Sar3#n1*1}* zyWlT+u#jHv6yV{Bg4)3n>{kac8OQanw5p$v;EUG{3y6vHVw6`FP%|&<`i;@BfTeN0 zNW66_jdFr@Bc|U$!vc6OYcIK+lg9we`$76vG}^F$vX3$)@aZ#W#_Doe2IH$h8Wv!` z&dMZ_9PzB-PkYk;hdLFBr+o+36LUFvY?XSkqtN>jOoHMoRT zs63hlXz8E2CwlCB&04Qufw)h+MZM|Q;R@PNc`OF%tUJ5#6?S>k&PUAVGUdz6IsC z%^CW>X%cGZpozBFt@&AK|2zpXRQ1HvPe2>SpmIM7{@=i0ATrEHUBu`atoh!+H~PP7 GP4^8biMB@o diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/icon-512.png b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/icon-512.png deleted file mode 100644 index da8055f6cafc603b64d43c483a4f84c17f4a71ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7574 zcmeHs_g7Qf*7iya5IQ35&{Y(qBO*;m=y(wUrHN7m=}42_5{{xkfN%~9h(bUvO`1_a ziacQ2SG|`38cO8{tRlE9BowY18`wrcHm7wnpLyo7?c}uFvGvjzBXFM23lOxt|8I6{(E!!#%(T;dBHu;e z8mt?(KL)mJ)5UyJK5D(_f8+rj2~hp$xWKyTRrirK4_#TLzQAIAa(pjtM-6>xfBEHJ z>nw?yVCTI5OFjapPt#+NqJt{GKESyu1EhQ8ZAB;`BrG3=+v3gsxrumSe8#^C*G?!)JnXn(STfF^kd@qL+DBiP+-g@xtmHTbY zYI%y3t82mYTcZMuE}f%!(sFuao09B`iV$dg4y^Hd#jhC0@5Em8(?j!*Hh4Wrm9Cm7 zDDs;qnxm_nu|~ICCaYYoEhj%AldRf9t+c88&Md%b1lXSEI3%uW<~(N`c^Z-@!Gd;F z4_>S0WC3A7p1+GRE&M*MmHeJZzrN_FB0vM{zeJ8|>y}Zn6r5v_4%7P!g>0V6YV5Y( zy2kb^X6P3$(hG`YaUVkHF54XSpLmhi^%yM40NX-LcHEE5DAUR|ZmgUo!2$f>wZm*Y7vmhCNr9#>Vl^wl`ncCmuCa;>?Jysc@*behWYA z3Zm7vO$*iZ9Ti$})ZWD?#VzRJH}LhIlws1p#NfRyjZ@U`vy>f*0%8@y_R$$zb3egh zVzf}O_9I*IHf?E5L+hxY#M1G4cWO0^N3uM&StzhVg&kFLGd3=MukBWv+@IYujGFJX<(7*uB8AwICHD1Wf46&r z!$NDEJKa6{2s^u$dQKLEnS;g~JEZSb3c6;z+j8VV&0bAAGb@??%3a1&KqI~u(SrYjnDObCkBoTnynf2;i$%ojhqapSO|H{${FM=) z8bW-NYLigs>yhxb)`Ss5oJ+^b8TU(QUG|gZJdvf@(=nQQGHlHPo}AI$q7ry3V%4Ll z-3tE247jBE?I4ZMJpYKk<${QdZhWGVjh;=Tgu67O(SyknbO%%)uY->(Q6v0-}3 z#Bj@Fjj`}>HDzr$s4~AzE{@kq8TrL$%$GbW>rzI3S%PUGT`~^VyOLCh7au%s_M_4N zKtRx*Hsl;-m$EfhV+VqqKk?Nj;)hN2qdHMtM|@_4=t8_sg)i&+8SqAigYmVVvt}W@ zs($)T)|lSK`->CqTFQg^XFqL6@D%7Prgk6_h&m1aFj!)erTz7zCJ z9wcjl&Xj$cj(hBj4up1psQ$r5%a7I>Lx-E^oW%{|bqDEA4YY>?nTfcIoT-~}WvE!I z@Xfx{scdmoI}Q4Crh}44Gdr`Fd3y-#fNHL8rsqeJ`XinCE@<^dLqt5&87^93*E2bJkap;l@UPqU=L)epd+3_xP*+1R+XVa7z1hXAy6 zc^Q##Q~Cr1#3&oVb0q|qkF3tO%LN4v9peV+vg{DRDs2QGqpUQQ>LD%=Bc)McKTLr;9me=H6??9C5KC57^Eq@rYX*x4At29vnDDLV9o z;416`{ayMpq1>9s?$;>dXkyfIE+v*fLch7;s4a*`|3`lt?v8v=udCvNZB*Hs(MjOe z_e$=wPxM;sg1NR9t3^8t4krI(4v~9g8Kx%;{!J|ew#yF?@h%mUbhhOB`;x+pL}X8A#?}h zg^iIiE#AERPAlqs!_Yxflqp)9I@rRI`2rAZ^R>e9S9S+-TJ;jExuI%*>W?h?d+;&? z>h~*|9&E5+e<--w@6z~|L|SFV3s&o;Uhepwa)c?ty6b_=96&Jcl0!JH=+bGG2Fh3b zYs%yI?x;WOfA2Q9#Q{S3L2mBh`p9^ql#!Ub+G1m1`HRv|RwQXCD-VFYOWhUPc6Z7~lDwVnG$vxd-dfLIONj5-aAkmDlK(6)%C8p8zw!2(u1EiESH+(C4hc&<&fWL8wUBd*=-NEt z`ab>{)_T!jm%jJIgVQENWZ(4wRB~dEjEjf;b>Q}o0OOgj z6>pWzk5#C#jUE#qzZKXh#80`m>C(4C9^uxmcg_mV|JjZi)q?)@ux$M_6O(QBs}Eh9 za${@4F>{=PtZ3hB`JLu^rke^EtV5i`;__3cs=JNRfp8q&1ozg~*mV*HyC81rdCGQVB_M+u} zd9~@R$-VseE8&9ccyJY+jjrHV%1DL|&TX_9zCjXg*P+ z*r3`q&aP+``$&D74>T%OGk&I2pih-4XXl0KHK(i7{Bvl9xM#b^;mo5jz%RINpqkxZ zcv`Y44y$rozsqHecDOv>QAd?tf)RZ{5b= z{+fOEEJfk@&&Pm(6`@6{aD2m)Hnbv|KDR*pb`Rs6bv2K@=vfv?fp9P-J zVG%p7gPzn?jcj|JFBZ?&n+LZFGXry?#kt4d({(Ot2xQCANr!W`Z8gSG4G~_%4=&-% z1N-b=dTdOr_55qdWfBsk5D_Tn7dbMc?6=h(3k9Pu`M_&i3)PKX_kyv^bh3Y%L;E*#g@na+BNSz_+0yVYW_nWnnP{6&(i0SgFcziO8hp^BU zM#z|rv&*<;I|(a-8=KM(h+>vT^0kw(f?+-jD=km9_r5q*$wg3BdveTNj^DCF$5UO? z$i+K|-Aa`gNaOgITLzyZUN)N`CmoteS?QHuyOTZj3J6=Q=ZYoY~s6@@y9ixQTu92#B0gf zdY?*hW=CTm?i~2}N_DRZim*a}1X{9ud8Fxy6712(oyjz2{NE~I`+OA;-17^_bBtzQ zKSYSQD%WFx)V|M+R2k}nyy511{3%FX2y$qwlmmr)k_Jet+}9AM47#BFE{9vC22d4& zB13ytye?dW5Q5?VVHq-CW-I@$VnUso6_YNU72$yZz|RkY8!fDIYapNh$&%`BIW*=T z)bQ3YpF_Z7DP^GOh(kG&ox=h#q-ytIg;`~(3KUQ!UVEC< z^B@rc(^E77LN_zCs$MzLwP$7w%vfni^O7fPH(|kJBe3Skjd|@QDFFQ5Z}iufu+gaDzxQbz3Kr zX<5v$zNy8+MBQ@ZcRYo! zrh(0)DwZ`<%XtN!ii-?hCVbc~Z5}n7I@u&wvEagl+)K2u!)~;n zMO9Gs59ZRtVTNiP6qz(b-g{qS+KrnHa3JJ%O>_QU>PK+lfOU~b^l&3h-n62Y@jV6| zk}|rtmS|aG`Tz>J3V?M*4ufo2Ve`u-@?UdbO=Qol(ER;rPvq!oEP(3;b9oIqbj+8L zebAQ5Q~9&Fm~z?f7WLsFVo4gRIJ+pTe%RZj~chJhp$+2t45Wy+~5 z#4@6jT5Wd?H}s+8?MfeeT25KyvR{I<8L>88vc=Sf>VJngoBGC=D77^Ik8{(6@I7`2 z<^i*meT`wJi%;%csgIrOlTv<~SAUw2hnDiUr!Yb->@=y;(e1;94PUJ_ce__;J(w0T%gF_?-`O~YTlEw z>by0+r7(AeslGpvRa*$!WNgg%&*X)mecs<{Y$*+dq)T0)(gxBKG<@r}4|e6aR=66) z$&l8B6h3h`aKn{C14mY+`-JE|B+}j3*=ir!i6WepYF-7}+iTr^D*bXU^ zcI-p%YI*L#?TaCH#P&5BgYXg(`@0tAL?pCisFEhPpabFKHUKMfbc!yitTA&scsW z)3jG%T*wDstYFR2i9C*y5a}W=5aSTy_0R98iT;`<$PtCki5{ zD+*7jK;b-)iMV%3urPPu4A!7Ech%>_X9HlaoQiq`HlXXn4OA@o4~T~h=u4CHa7G5m z4sw@U{F+EculTsSF{_}Ol+Al;16PiU+z0F7p8dO6XE7%_L57*Z6i&O8iuL8g-PG1c zciGPWPM;XA6lkVm*TqXT-n-JnM8-8lepwPC$}pT9a5VK z3RK|{&(k`4xAaVcCpaG{!FmjsS?`2z;u;(dwGgg#)$Og_nZ4x@Dbd;MOCOBMaLTCl zb~4e{k(zn&TNg(9&85v`gp0}^6*DSO%wt-t(N>0SMda6BFwFT`E^hy9RiBjQ)zvWl zzF9oZ&nVi<%Xv@R!I+p@f6CPMoA7oS)BSIjmgq&}KS%FGNt{2T*zJ*e$m}3(nl3w% z6OXM`Vb+_sFQ)e2tXjwZ)yDQSN<1#qD4I5&?kRfM7F{#ppB<^!-0*?2kbUt&2>I!r z?5^WY`jM+QXPV8On_`<^eTr+G?7kvqTABl<>t1s?ryY7EFiI9@pCV}6q~)q=TaP_N znaZ;$HcK5_)AuU~M$62FJor=BH0Vk@b+GwjAhDIvjT2y83j?{SB2B->@qyMiVQK@| zH+zRZby}JnS5_=#gP5G#*ZKbLMh!ES_V6Oj4fW479VQv#tiN;ysD`WaR{5HSo@70~ zY{_@_jU1Wj`O)zvsN*x&B=z3-1{l2EoZ7$N?A5zQk}Wl{-__})Xr{I|psqtfK87s@ zmY83R@Rq!Db2!YPg|^;mgU2F%{2VVr>QMS|;ro$gHf~pbO_)SWVL9RjD5Yzt%g59pM3e}R zIqb`e7IW_!`uBH@M;bme+r^}IlJ+R`$CdPH*@piP8T{WQp8xl(|KDxO;ElmoS+Bpl T#>xCq39g!4H?A>s`}6+*Z~s`q diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/manifest.json b/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/manifest.json deleted file mode 100644 index 33796c575a..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Bit.Bswup.NewDemo", - "short_name": "Bit.Bswup.NewDemo", - "start_url": "./", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#03173d", - "icons": [ - { - "src": "icon-512.png", - "type": "image/png", - "sizes": "512x512" - } - ] -} 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 deleted file mode 100644 index 9024137b2d..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.js +++ /dev/null @@ -1,57 +0,0 @@ -// bit version: 10.5.0 - -// In development, always fetch from the network and do not enable offline support. -// This is because caching would make development more difficult (changes would not -// be reflected on the first load after each change). -//self.addEventListener('fetch', () => { }); - -self.assetsInclude = []; -self.assetsExclude = [ - /Bit\.Bswup\.NewDemo\.Client\.styles\.css$/ -]; -self.defaultUrl = '/'; -self.prohibitedUrls = []; -// 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/ -// using only js to generate hash: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest -self.externalAssets = [ - { - "url": "/" - }, - { - "url": "app.css" - }, - { - "url": "_framework/blazor.web.js?v=10.0.0" - }, - { - "url": "Bit.Bswup.NewDemo.styles.css" - }, - { - "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$/ - } -]; - -self.caseInsensitiveUrl = true; - -self.serverHandledUrls = [/\/api\//]; -self.serverRenderedUrls = [/\/privacy$/]; - -self.noPrerenderQuery = 'no-prerender=true'; - -self.isPassive = true; - -//self.enableDiagnostics = true; -//self.enableFetchDiagnostics = true; - -self.importScripts('_content/Bit.Bswup/bit-bswup.sw.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 deleted file mode 100644 index 12f9b3c525..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo.Client/wwwroot/service-worker.published.js +++ /dev/null @@ -1,59 +0,0 @@ -// bit version: 10.5.0 - -self.assetsInclude = []; -self.assetsExclude = [ - /Bit\.Bswup\.NewDemo\.Client\.styles\.css$/ -]; -self.defaultUrl = '/'; -self.prohibitedUrls = []; -// 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/ -// using only js to generate hash: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest -self.externalAssets = [ - { - "url": "/" - }, - { - "url": "app.css" - }, - { - "url": "_framework/blazor.web.js?v=10.0.0" - }, - { - "url": "Bit.Bswup.NewDemo.styles.css" - }, - { - "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$/ - } -]; - -self.caseInsensitiveUrl = true; - -self.serverHandledUrls = [/\/api\//]; -self.serverRenderedUrls = [/\/privacy$/]; - -self.noPrerenderQuery = 'no-prerender=true'; - -self.isPassive = true; - -//self.enableDiagnostics = true; -//self.enableFetchDiagnostics = true; - -//// Resiliency knobs (see the Bswup README for details): -//self.errorTolerance = 'strict'; // abort the install if any asset fails ('lax' = best-effort lazy-fill, the default) -//self.maxRetries = 2; // extra download attempts on transient failures (408/429/5xx, dropped connections) -//self.retryDelay = 300; // base backoff in ms between those retries (exponential, with jitter) -//self.enableIntegrityCheck = true; // attach SRI hashes so tampered assets are rejected (requires byte-identical serving) -//self.cacheVersion = '2026.07.24-abc1234'; // pin/bump the cache bucket independently of the asset manifest - -self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.csproj b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.csproj deleted file mode 100644 index e14bdfa8ce..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Bit.Bswup.NewDemo.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor deleted file mode 100644 index d0d3029849..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/App.razor +++ /dev/null @@ -1,39 +0,0 @@ -@code { - [CascadingParameter] HttpContext HttpContext { get; set; } = default!; -} - -@{ - var noPrerender = HttpContext.Request.Query["no-prerender"].Count > 0; - var renderMode = new InteractiveWebAssemblyRenderMode(!noPrerender); -} - - - - - - - - - - - - - - - - - - @* 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. *@ - - - - - - - diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/Pages/Error.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/Pages/Error.razor deleted file mode 100644 index 576cc2d2f4..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/Pages/Error.razor +++ /dev/null @@ -1,36 +0,0 @@ -@page "/Error" -@using System.Diagnostics - -Error - -

    Error.

    -

    An error occurred while processing your request.

    - -@if (ShowRequestId) -{ -

    - Request ID: @RequestId -

    -} - -

    Development Mode

    -

    - Swapping to Development environment will display more detailed information about the error that occurred. -

    -

    - The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

    - -@code{ - [CascadingParameter] - private HttpContext? HttpContext { get; set; } - - private string? RequestId { get; set; } - private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - protected override void OnInitialized() => - RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; -} diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/_Imports.razor b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/_Imports.razor deleted file mode 100644 index fee2256aae..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Components/_Imports.razor +++ /dev/null @@ -1,12 +0,0 @@ -@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.NewDemo -@using Bit.Bswup.NewDemo.Client -@using Bit.Bswup.NewDemo.Components -@using Bit.Bswup.NewDemo.Client.Layout diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Program.cs b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Program.cs deleted file mode 100644 index 668876cc8a..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Program.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Bit.Bswup.NewDemo.Components; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents() - .AddInteractiveWebAssemblyComponents(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseWebAssemblyDebugging(); -} -else -{ - 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.UseHttpsRedirection(); - -app.MapStaticAssets(); -app.UseAntiforgery(); - -app.MapRazorComponents() - .AddInteractiveServerRenderMode() - .AddInteractiveWebAssemblyRenderMode() - .AddAdditionalAssemblies(typeof(Bit.Bswup.NewDemo.Client._Imports).Assembly); - -app.Run(); diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Properties/launchSettings.json b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Properties/launchSettings.json deleted file mode 100644 index c290d848b9..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/Properties/launchSettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "Bit.Bswup.NewDemo": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5020;https://localhost:5021", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} \ No newline at end of file diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/appsettings.Development.json b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/appsettings.Development.json deleted file mode 100644 index 0c208ae918..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/appsettings.json b/src/Bswup/NewDemo/Bit.Bswup.NewDemo/appsettings.json deleted file mode 100644 index 10f68b8c8b..0000000000 --- a/src/Bswup/NewDemo/Bit.Bswup.NewDemo/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} From 075c94ddf40bb649c7b9088d2c55abda4bb9a677 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 11:18:06 +0330 Subject: [PATCH 35/50] rename BasicDemo --- src/Bit.CI.Release.slnx | 2 +- src/Bit.slnx | 2 +- src/Bswup/Bit.Bswup.slnx | 2 +- .../Demos/{Bit.Bswup.Demo => BasicDemo}/App.razor | 0 .../Bit.Bswup.Demo.csproj | 0 .../Pages/CounterPage.razor | 0 .../Pages/HomePage.razor | 0 .../Demos/{Bit.Bswup.Demo => BasicDemo}/Program.cs | 0 .../Properties/launchSettings.json | 0 .../{Bit.Bswup.Demo => BasicDemo}/_Imports.razor | 0 .../wwwroot/bit-bw-64.png | Bin .../wwwroot/favicon.ico | Bin .../wwwroot/icon-512.png | Bin .../wwwroot/index.html | 0 .../wwwroot/manifest.json | 0 .../wwwroot/service-worker.js | 0 .../wwwroot/service-worker.published.js | 0 17 files changed, 3 insertions(+), 3 deletions(-) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/App.razor (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/Bit.Bswup.Demo.csproj (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/Pages/CounterPage.razor (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/Pages/HomePage.razor (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/Program.cs (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/Properties/launchSettings.json (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/_Imports.razor (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/wwwroot/bit-bw-64.png (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/wwwroot/favicon.ico (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/wwwroot/icon-512.png (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/wwwroot/index.html (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/wwwroot/manifest.json (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/wwwroot/service-worker.js (100%) rename src/Bswup/Demos/{Bit.Bswup.Demo => BasicDemo}/wwwroot/service-worker.published.js (100%) diff --git a/src/Bit.CI.Release.slnx b/src/Bit.CI.Release.slnx index 8ff5705202..e584de5f77 100644 --- a/src/Bit.CI.Release.slnx +++ b/src/Bit.CI.Release.slnx @@ -49,7 +49,7 @@ - + diff --git a/src/Bit.slnx b/src/Bit.slnx index e94c517d58..6a1ad7d7a5 100644 --- a/src/Bit.slnx +++ b/src/Bit.slnx @@ -81,7 +81,7 @@ - + diff --git a/src/Bswup/Bit.Bswup.slnx b/src/Bswup/Bit.Bswup.slnx index 65a54262c5..cee3ce1029 100644 --- a/src/Bswup/Bit.Bswup.slnx +++ b/src/Bswup/Bit.Bswup.slnx @@ -7,7 +7,7 @@ - + diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/App.razor b/src/Bswup/Demos/BasicDemo/App.razor similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/App.razor rename to src/Bswup/Demos/BasicDemo/App.razor diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/Bit.Bswup.Demo.csproj b/src/Bswup/Demos/BasicDemo/Bit.Bswup.Demo.csproj similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/Bit.Bswup.Demo.csproj rename to src/Bswup/Demos/BasicDemo/Bit.Bswup.Demo.csproj diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/Pages/CounterPage.razor b/src/Bswup/Demos/BasicDemo/Pages/CounterPage.razor similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/Pages/CounterPage.razor rename to src/Bswup/Demos/BasicDemo/Pages/CounterPage.razor diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/Pages/HomePage.razor b/src/Bswup/Demos/BasicDemo/Pages/HomePage.razor similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/Pages/HomePage.razor rename to src/Bswup/Demos/BasicDemo/Pages/HomePage.razor diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/Program.cs b/src/Bswup/Demos/BasicDemo/Program.cs similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/Program.cs rename to src/Bswup/Demos/BasicDemo/Program.cs diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/Properties/launchSettings.json b/src/Bswup/Demos/BasicDemo/Properties/launchSettings.json similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/Properties/launchSettings.json rename to src/Bswup/Demos/BasicDemo/Properties/launchSettings.json diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/_Imports.razor b/src/Bswup/Demos/BasicDemo/_Imports.razor similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/_Imports.razor rename to src/Bswup/Demos/BasicDemo/_Imports.razor diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/bit-bw-64.png b/src/Bswup/Demos/BasicDemo/wwwroot/bit-bw-64.png similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/bit-bw-64.png rename to src/Bswup/Demos/BasicDemo/wwwroot/bit-bw-64.png diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/favicon.ico b/src/Bswup/Demos/BasicDemo/wwwroot/favicon.ico similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/favicon.ico rename to src/Bswup/Demos/BasicDemo/wwwroot/favicon.ico diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/icon-512.png b/src/Bswup/Demos/BasicDemo/wwwroot/icon-512.png similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/icon-512.png rename to src/Bswup/Demos/BasicDemo/wwwroot/icon-512.png diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/index.html b/src/Bswup/Demos/BasicDemo/wwwroot/index.html similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/index.html rename to src/Bswup/Demos/BasicDemo/wwwroot/index.html diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/manifest.json b/src/Bswup/Demos/BasicDemo/wwwroot/manifest.json similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/manifest.json rename to src/Bswup/Demos/BasicDemo/wwwroot/manifest.json diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/service-worker.js b/src/Bswup/Demos/BasicDemo/wwwroot/service-worker.js similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/service-worker.js rename to src/Bswup/Demos/BasicDemo/wwwroot/service-worker.js diff --git a/src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/service-worker.published.js b/src/Bswup/Demos/BasicDemo/wwwroot/service-worker.published.js similarity index 100% rename from src/Bswup/Demos/Bit.Bswup.Demo/wwwroot/service-worker.published.js rename to src/Bswup/Demos/BasicDemo/wwwroot/service-worker.published.js From 0cc0a2bd22a6011e1c8cbf92869d80c118ec2ca0 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 11:49:29 +0330 Subject: [PATCH 36/50] add document website project --- .../{Bit.Bswup.Demo.csproj => Bit.Bswup.BasicDemo.csproj} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Bswup/Demos/BasicDemo/{Bit.Bswup.Demo.csproj => Bit.Bswup.BasicDemo.csproj} (100%) diff --git a/src/Bswup/Demos/BasicDemo/Bit.Bswup.Demo.csproj b/src/Bswup/Demos/BasicDemo/Bit.Bswup.BasicDemo.csproj similarity index 100% rename from src/Bswup/Demos/BasicDemo/Bit.Bswup.Demo.csproj rename to src/Bswup/Demos/BasicDemo/Bit.Bswup.BasicDemo.csproj From 6c294e8284ca8629a8c32bf8fd2afa54fa0f8d33 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 11:49:36 +0330 Subject: [PATCH 37/50] add --- src/Bit.CI.Release.slnx | 3 +- src/Bit.slnx | 3 +- src/Bswup/Bit.Bswup.Demo/App.razor | 16 + .../Bit.Bswup.Demo/Bit.Bswup.Demo.csproj | 23 + .../Bit.Bswup.Demo/Pages/CleanupPage.razor | 48 + .../Bit.Bswup.Demo/Pages/EventsPage.razor | 204 +++++ .../Pages/GettingStartedPage.razor | 118 +++ src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor | 122 +++ .../Bit.Bswup.Demo/Pages/JsApiPage.razor | 123 +++ .../Bit.Bswup.Demo/Pages/MigrationPage.razor | 58 ++ .../Bit.Bswup.Demo/Pages/PlaygroundPage.razor | 91 ++ .../Bit.Bswup.Demo/Pages/ProgressUIPage.razor | 150 ++++ .../Pages/ScriptOptionsPage.razor | 175 ++++ .../Pages/ServiceWorkerPage.razor | 336 +++++++ src/Bswup/Bit.Bswup.Demo/Program.cs | 9 + .../Properties/launchSettings.json | 15 + src/Bswup/Bit.Bswup.Demo/Shared/Callout.razor | 19 + .../Bit.Bswup.Demo/Shared/CodeBlock.razor | 12 + .../Bit.Bswup.Demo/Shared/MainLayout.razor | 34 + src/Bswup/Bit.Bswup.Demo/Shared/NavMenu.razor | 20 + src/Bswup/Bit.Bswup.Demo/_Imports.razor | 10 + src/Bswup/Bit.Bswup.Demo/wwwroot/app.css | 842 ++++++++++++++++++ .../Bit.Bswup.Demo/wwwroot/bit-bw-64.png | Bin 0 -> 1180 bytes src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js | 210 +++++ src/Bswup/Bit.Bswup.Demo/wwwroot/favicon.ico | Bin 0 -> 5430 bytes src/Bswup/Bit.Bswup.Demo/wwwroot/icon-512.png | Bin 0 -> 7574 bytes src/Bswup/Bit.Bswup.Demo/wwwroot/index.html | 98 ++ .../Bit.Bswup.Demo/wwwroot/manifest.json | 16 + .../Bit.Bswup.Demo/wwwroot/service-worker.js | 8 + .../wwwroot/service-worker.published.js | 13 + src/Bswup/Bit.Bswup.slnx | 3 +- src/Bswup/Demos/BasicDemo/Program.cs | 2 +- .../BasicDemo/Properties/launchSettings.json | 2 +- src/Bswup/Demos/BasicDemo/_Imports.razor | 2 +- .../Demos/BasicDemo/wwwroot/manifest.json | 4 +- .../FullDemo/Client/wwwroot/manifest.json | 4 +- src/Bswup/README.md | 4 +- 37 files changed, 2785 insertions(+), 12 deletions(-) create mode 100644 src/Bswup/Bit.Bswup.Demo/App.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Bit.Bswup.Demo.csproj create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/MigrationPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/ScriptOptionsPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Pages/ServiceWorkerPage.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Program.cs create mode 100644 src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json create mode 100644 src/Bswup/Bit.Bswup.Demo/Shared/Callout.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Shared/CodeBlock.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/Shared/NavMenu.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/_Imports.razor create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/app.css create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/bit-bw-64.png create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/favicon.ico create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/icon-512.png create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/index.html create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/manifest.json create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js create mode 100644 src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js diff --git a/src/Bit.CI.Release.slnx b/src/Bit.CI.Release.slnx index e584de5f77..c7772d3912 100644 --- a/src/Bit.CI.Release.slnx +++ b/src/Bit.CI.Release.slnx @@ -47,9 +47,10 @@ + - + diff --git a/src/Bit.slnx b/src/Bit.slnx index 6a1ad7d7a5..ed9ec97842 100644 --- a/src/Bit.slnx +++ b/src/Bit.slnx @@ -79,9 +79,10 @@ + - + diff --git a/src/Bswup/Bit.Bswup.Demo/App.razor b/src/Bswup/Bit.Bswup.Demo/App.razor new file mode 100644 index 0000000000..0e41c27ed1 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/App.razor @@ -0,0 +1,16 @@ + + + + + + + + Not found - bit Bswup +
    +

    404

    +

    Sorry, there's nothing at this address.

    + Back to the docs +
    +
    +
    +
    diff --git a/src/Bswup/Bit.Bswup.Demo/Bit.Bswup.Demo.csproj b/src/Bswup/Bit.Bswup.Demo/Bit.Bswup.Demo.csproj new file mode 100644 index 0000000000..cd2808a4cf --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Bit.Bswup.Demo.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + service-worker-assets.js + + + + + + + + + + + + + + + + diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor new file mode 100644 index 0000000000..b0a74b5265 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor @@ -0,0 +1,48 @@ +@page "/cleanup" + +Backing Out of Bswup - bit Bswup + +

    Backing Out of Bswup

    +

    + Fully remove Bswup from a deployed app - dropping offline support, or recovering clients stuck on a + broken worker or cache - with the self-destructing cleanup worker. +

    + +

    Replace the content of your service-worker.js with:

    + + +

    What happens next

    +

    On its next update check, every client installs this self-destructing worker instead. It:

    +
      +
    1. activates immediately,
    2. +
    3. purges this app's Bswup and Blazor caches,
    4. +
    5. unregisters its own registration, and
    6. +
    7. signals open tabs to detach.
    8. +
    +

    + Tabs the previous worker controlled reload once; everything afterwards runs purely from the network, + even while the page keeps referencing bit-bswup.js - each later load just repeats the + register/self-unregister cycle silently, with no reloads. +

    + + Once no client has loaded the old app for as long as your cache headers require, the + bit-bswup.js script tag can be removed from the host document too. + + +

    Client-side reset without backing out

    +

    + If you only need to recover a single broken client (not remove Bswup from the deployment), + prefer BitBswup.forceRefresh() - it clears this + app's caches, unregisters its worker, and reloads, without touching the deployed service worker file. +

    + + diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor new file mode 100644 index 0000000000..3c14b6375a --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor @@ -0,0 +1,204 @@ +@page "/events" + +Events & Handler - bit Bswup + +

    Events & Handler

    +

    + Bswup reports its whole lifecycle through a single global handler function. Use the built-in + progress UI, or write your own handler and drive any markup you like. +

    + +

    + The handler's name is configured through the handler + script attribute (default bitBswupHandler). It receives (type, data) where + type is one of the BswupMessage constants: +

    + +

    Event catalog

    +
    + + + + + + + + + + + + + + + + +
    BswupMessageValueRaised when
    updateFoundUPDATE_FOUNDThe browser found a new service worker version and started installing it.
    stateChangedSTATE_CHANGEDThe installing worker's lifecycle state changed (data.currentTarget.state).
    downloadStartedDOWNLOAD_STARTEDAsset download began. data.version, data.firstInstall.
    downloadProgressDOWNLOAD_PROGRESSAn asset finished. data.percent (0-100), data.index (1-based count), data.asset (url, reqUrl, hash).
    downloadFinishedDOWNLOAD_FINISHEDAll assets are staged. data.firstInstall, data.reload(), data.cleanup().
    updateReadyUPDATE_READYA fully staged update is waiting to be activated (data.reload()).
    activateACTIVATEA new version activated (data.version).
    updateNotFoundUPDATE_NOT_FOUNDAn update check completed and the app is already on the latest version.
    updateCheckFailedUPDATE_CHECK_FAILEDAn update check itself failed transiently (offline, server hiccup). Non-blocking - the app keeps running.
    errorERRORA structured install failure - see below.
    +
    + +

    A complete handler

    + + +

    Finishing a download: reload and cleanup

    +
      +
    • + data.reload() activates the staged version. On a first install it + claims the clients and starts Blazor with no reload; on an update it + performs SKIP_WAITING and reloads. +
    • +
    • + data.cleanup() (optional) asks the active service worker to prune this app's stale + cache buckets right away. It is safe to call at any time - the worker declines while an update is + staged or staging (pruning then happens automatically on activation), and it never touches another + app's caches. Most apps never need it: the same pruning already runs on activation and after every + accepted update. +
    • +
    + + + Since v-10-6-0, the built-in BswupProgress component's AutoReload parameter + defaults to false: when an update finishes downloading, the reload button is shown and the + new version activates when the user accepts it - an unprompted reload discards whatever in-page state + the user has mid-session. Set AutoReload="true" to restore the old behavior. First + installs are unaffected: they always complete the seamless claim-and-start flow (no reload). + + +

    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 controllerchange) 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. +

    + +

    The error payload

    +

    Install failures are structured:

    +
    + + + + + + + + + + + + + + + + + + + + +
    FieldMeaning
    reason + One of manifest, integrity, fetch, + cache, request, install-incomplete, + install-aborted, or install-infra (the install died + before/while touching CacheStorage - storage pressure, a broken private mode - always + fatal). +
    messageHuman-readable description.
    url / hashThe offending asset, when known.
    fatal + Whether the install actually stopped. Under the default lax tolerance a + failed asset is reported with fatal: false - the install still succeeds and + the asset is fetched from the network on first use - so treat it as a warning, not a + dead app. Only fatal: true means no new version was installed. +
    firstInstall + Where a fatal failure landed. true: before the app ever booted - Bswup + starts the app without a service worker so it still boots. false: a + background update failed - the app keeps running on the current version (the + previous worker keeps serving). +
    +
    + + + + +@code { + private const string HandlerCode = @"const appEl = document.getElementById('app'); +const bswupEl = document.getElementById('bit-bswup'); +const progressBar = document.getElementById('bit-bswup-progress-bar'); +const reloadButton = document.getElementById('bit-bswup-reload'); + +function bitBswupHandler(type, data) { + switch (type) { + case BswupMessage.updateFound: return console.log('an update found.'); + + case BswupMessage.stateChanged: return console.log('state:', data.currentTarget.state); + + case BswupMessage.activate: return console.log('new version activated:', data.version); + + case BswupMessage.downloadStarted: + // A background update downloads behind the running app - only a first + // install owns the screen (firstInstall rides on every message). + if (data?.firstInstall === false) return; + appEl.style.display = 'none'; + bswupEl.style.display = 'block'; + return console.log('downloading assets started:', data?.version); + + case BswupMessage.downloadProgress: + progressBar.style.width = `${Math.round(data.percent)}%`; + return console.log('asset downloaded:', data.asset.url, data); + + case BswupMessage.downloadFinished: + if (data.firstInstall) { + // First install: claim + start Blazor, no page reload. + data.reload().then(() => { + appEl.style.display = 'block'; + bswupEl.style.display = 'none'; + }); + } else { + // Update: let the user accept it. + reloadButton.style.display = 'block'; + reloadButton.onclick = data.reload; + } + return console.log('downloading assets finished.'); + + case BswupMessage.updateReady: + reloadButton.style.display = 'block'; + reloadButton.onclick = data.reload; + return console.log('new update ready.'); + + case BswupMessage.updateNotFound: + return console.log('already on the latest version.'); + + case BswupMessage.updateCheckFailed: + return console.warn('could not check for updates right now:', data); + + case BswupMessage.error: + if (data.fatal === false) { + // lax tolerance: the install continued; the asset lazy-fills later. + return console.warn('Bswup asset skipped:', data.reason, data.message); + } + return console.error('Bswup install error:', data.reason, data.message, data); + } +}"; + + private const string ErrorHandlerCode = @"case BswupMessage.error: + if (data.fatal === false) { + console.warn('Bswup asset skipped:', data.reason, data.message, data); + return; + } + if (data.firstInstall === false) { + // A background update failed - the running app is untouched. + return; + } + // Fatal first-install failure: Bswup force-starts Blazor from the network; + // reveal the app instead of leaving it booted behind the splash. + appEl.style.display = 'block'; + bswupEl.style.display = 'none'; + return;"; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor new file mode 100644 index 0000000000..bca0a29d3d --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor @@ -0,0 +1,118 @@ +@page "/getting-started" + +Getting Started - bit Bswup + +

    Getting Started

    +

    + Add install progress, controlled updates, and offline support to a Blazor WebAssembly app + in five small steps. +

    + +

    1. Install the NuGet package

    + + +

    2. Enable static file caching (server-hosted apps)

    +

    + Long-lived HTTP caching makes repeat visits and updates dramatically faster - the service worker + revalidates content by hash, so stale HTTP caches are never a correctness problem. +

    + + +

    3. Defer Blazor's startup

    +

    + Bswup must start Blazor at the right moment (after the first install completes, or immediately when + the app is already cached), so disable auto-start on the Blazor script in your host document + (index.html or App.razor): +

    + + +

    4. Add the Bswup script

    +

    Reference bit-bswup.js right after the Blazor script:

    + +

    + Every attribute is optional - see the Script Options reference for all + of them and their defaults. +

    + +

    5. Configure the service worker

    +

    + Create (or edit) wwwroot/service-worker.js and wwwroot/service-worker.published.js. + The only mandatory line is the importScripts call; everything else is tuning: +

    + +

    Then make sure the service worker is registered in your project file:

    + +

    See the Service Worker Settings reference for every available option.

    + +

    6. Pick a progress UI

    +

    You have two options:

    +
      +
    • + Use the built-in progress UI - the BswupProgress component + plus bit-bswup.progress.js - and you are done. This is what this site uses. +
    • +
    • + Write your own handler function and drive any markup you like - see + Events & Handler for the full event catalog and a complete sample. +
    • +
    + + + This documentation site is a live Bswup app. Open dev tools → Application → Service Workers to + inspect the registration, or visit the Live Playground to watch events and + drive the update lifecycle by hand. + + + + +@code { + private const string ServerCacheCode = @"app.UseStaticFiles(new StaticFileOptions +{ + OnPrepareResponse = ctx => + { + if (env.IsDevelopment() is false) + { + // https://bitplatform.dev/templates/cache-mechanism + ctx.Context.Response.GetTypedHeaders().CacheControl = new() + { + MaxAge = TimeSpan.FromDays(7), + Public = true + }; + } + } +});"; + + private const string AutostartCode = @""; + + private const string ScriptTagCode = @""; + + private const string ServiceWorkerCode = @"// wwwroot/service-worker.published.js +self.assetsExclude = [/\.scp\.css$/]; +self.caseInsensitiveUrl = true; + +// The one mandatory line - imports the Bswup service worker engine: +self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js');"; + + private const string CsprojCode = @" + service-worker-assets.js + + + + +"; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor new file mode 100644 index 0000000000..6492af9f25 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/HomePage.razor @@ -0,0 +1,122 @@ +@page "/" + +bit Bswup - Blazor Service Worker Update Progress + +
    + Blazor Service Worker Update Progress +

    Ship Blazor PWAs that install, update & work offline - beautifully.

    +

    + Bswup replaces the default Blazor service worker experience with a real install progress UI, + controlled background updates, resilient downloads, and a tiny JavaScript API - all with a + single script tag. +

    + +
    + dotnet add package Bit.Bswup +
    +
    + +
    + +
    📥
    +

    First-install splash

    +

    A branded full-screen progress UI while assets download - the very splash this site booted with.

    +
    + +
    🔄
    +

    Controlled updates

    +

    Updates download silently in the background and wait for the user to accept - no unprompted reloads, consistent across every open tab.

    +
    + +
    📴
    +

    Offline support

    +

    Full precache or lazy passive mode, SPA navigation fallback, media range requests, and external asset caching.

    +
    + +
    🛡️
    +

    Resilient installs

    +

    Retries with backoff, lax/strict error tolerance, a first-install stall watchdog, and structured error reporting.

    +
    + +
    🧭
    +

    Update polling

    +

    Interval and on-focus update checks, a check-for-update API, and up-to-date / check-failed events.

    +
    + +
    ⚙️
    +

    Configurable, not magical

    +

    Everything is opt-in via script-tag attributes and service worker settings, with sensible defaults.

    +
    + +
    💾
    +

    Durable storage

    +

    One call to request eviction-resistant storage so offline apps survive browser storage pressure.

    +
    + +
    🧹
    +

    Clean exit path

    +

    A self-destructing cleanup worker recovers broken clients or removes Bswup entirely from a deployed app.

    +
    +
    + +

    What is Bswup?

    +

    + bit Bswup (Blazor service-worker update progress) is a + NuGet package for Blazor WebAssembly apps that takes over the service worker lifecycle: + it downloads and caches your app's assets with visible progress, verifies their integrity, serves them + offline, and manages version updates without ever leaving your users staring at a blank page - or + running a stale build for days. +

    +

    + This documentation site is itself a Blazor WebAssembly app powered by Bswup - open the browser dev + tools, go offline, and reload. Then head over to the Live Playground to watch + the service worker events in real time. +

    + +

    How it fits together

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PieceWhat it does
    bit-bswup.jsPage-side script: registers the service worker, coordinates the install/update handshake, starts Blazor at the right moment, polls for updates, and raises lifecycle events.
    bit-bswup.sw.jsThe service worker engine: precaches assets (with retries and integrity checks), serves fetches from cache, handles SPA navigation fallback, and stages new versions. Imported from your service-worker.js.
    bit-bswup.progress.js + BswupProgressThe optional built-in progress UI: splash screen, progress bar, reload button, and failure panel - CSP-friendly and screen-reader aware.
    BitBswupA small JavaScript API to check for updates, activate staged versions, request persistent storage, and force-reset a broken client.
    bit-bswup.sw-cleanup.jsA self-destructing worker to back out of Bswup or recover clients stuck on a broken worker or cache.
    +
    + +

    Demo projects

    +

    Besides this documentation site, the repository ships two runnable samples:

    +
      +
    • Demos/BasicDemo (Bit.Bswup.BasicDemo) - a minimal standalone Blazor WebAssembly app with a hand-written handler, deliberately including a failing external asset to exercise the error flow.
    • +
    • Demos/FullDemo (Bit.Bswup.FullDemo.*) - a Blazor Web App (server + client) using the BswupProgress component with a custom progress bar.
    • +
    + + diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor new file mode 100644 index 0000000000..8b79130511 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor @@ -0,0 +1,123 @@ +@page "/javascript-api" + +JavaScript API - bit Bswup + +

    JavaScript API

    +

    + Bswup exposes a small global BitBswup object so you can drive the update lifecycle from + your own code - a "check for updates" button, a custom poller, a "reset app" action. +

    + + + The Live Playground wires each of these methods to a button on this very site. + + +

    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 (updateFoundstateChanged → + updateReady/downloadFinished). If the app is already on the latest version, + Bswup raises updateNotFound so you can stop a spinner or show an "up to date" message. +

    +

    + If the check itself fails for a transient reason (offline, server hiccup, a throttled background tab), + Bswup raises the non-blocking updateCheckFailed event instead of the install-path + error event, so the default progress handler does not hide the app or + show the install-failed UI; the payload still carries reason/message so you + can surface it yourself. Registration-aware and safe to call as often as you like - it is what powers + the built-in polling. +

    + +

    BitBswup.persistStorage()

    +

    + Requests durable, eviction-resistant storage for the origin via navigator.storage.persist() + and resolves with a boolean saying whether storage is now persistent. Without it the caches are + best-effort and can be reclaimed by the browser (Safari deletes all storage for a site not interacted + with for seven days). Calling it from a user gesture - after login, from an "install app" button - has + the best chance of being granted. Safe to call repeatedly: an already-persistent origin resolves + true without prompting again, and unsupported browsers resolve false with a + console warning. +

    + +

    BitBswup.skipWaiting()

    +

    + If an update has finished downloading and is waiting, this activates it immediately (equivalent to + calling the reload callback from updateReady/downloadFinished). + Returns true when there was a waiting worker to activate, otherwise false. +

    + +

    BitBswup.forceRefresh(cacheFilter?)

    +

    + Clears caches, unregisters the service worker controlling the current page, and reloads. Use this as a + last-resort "reset" when a client gets into a bad state. It only removes this app's own registration - + other apps mounted under different scopes on the same origin are left untouched. +

    +

    + By default it clears only the caches this app and Blazor own: the app's scope-qualified Bswup buckets, + legacy scope-less buckets, and blazor-resources caches. A sibling Bswup app's scoped + buckets are spared, and app-owned CacheStorage buckets (Workbox add-ons, offline app data, cached API + responses) are left intact, since those can hold data with no other copy. To change what gets cleared, + pass an optional cacheFilter: a string (prefix match), a RegExp, or a + predicate (key) => boolean: +

    + + +

    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 two ways to check + more often: +

    +
      +
    1. + Set updateInterval (and/or + updateOnVisibility) on the script tag + for built-in polling. Simplest, no extra code - this site uses both. +
    2. +
    3. Call BitBswup.checkForUpdate() yourself, from a timer or after a user action:
    4. +
    + +

    + Either way, the result surfaces through your handler: a found update flows through + updateFound/updateReady, "nothing new" flows through + updateNotFound, and a transient check failure flows through + updateCheckFailed: +

    + + + Built-in polling skips checks while the tab is in the background (the browser throttles those timers + anyway); the next timer tick after the tab is foregrounded runs normally. For an immediate + check the moment the user comes back, also set updateOnVisibility="true". + + + + +@code { + private const string ForceRefreshCode = @"BitBswup.forceRefresh(); // Bswup + Blazor caches (default) +BitBswup.forceRefresh(() => true); // every cache on the origin +BitBswup.forceRefresh('bit-bswup'); // only Bswup's own caches +BitBswup.forceRefresh(/^(bit-bswup|my-app-data)/) // a specific set"; + + private const string PollingCode = @"// 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();"; + + private const string PollingHandlerCode = @"window.bitBswupHandler = (message, data) => { + switch (message) { + case 'UPDATE_NOT_FOUND': /* already up to date - stop the spinner */ break; + case 'UPDATE_CHECK_FAILED': /* transient failure - keep running, optionally notify */ break; + // updateFound / stateChanged / updateReady / downloadFinished drive the update UI + } +};"; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/MigrationPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/MigrationPage.razor new file mode 100644 index 0000000000..cc189317bf --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/MigrationPage.razor @@ -0,0 +1,58 @@ +@page "/migration" + +Migrating to 10-6-0 - bit Bswup + +

    Migrating to v-10-6-0

    +

    + v-10-6-0 is a resilience-focused release. Most changes are internal hardening; this page lists what is + visible when upgrading. +

    + +

    Action items

    + +

    1. Updates no longer auto-reload. BswupProgress.AutoReload now defaults to false - updates announce themselves through the reload button. Set AutoReload="true" to restore the old behavior.

    +

    2. Blocked URLs answer 403. prohibitedUrls matches are answered with 403 Forbidden (previously 405). Code detecting a blocked URL by status must check for 403.

    +

    3. String entries now take effect. Strings in the URL-matching lists (assetsInclude, assetsExclude, prohibitedUrls, serverHandledUrls, serverRenderedUrls) are now matched literally as substrings. Previous releases silently ignored them - audit any strings sitting in those lists, because they take effect for the first time after upgrading.

    +

    4. Hand-written splash markup needs one edit. Move <button id="bit-bswup-reload"> outside the #bit-bswup overlay (and give it a z-index), optionally adding a visually-hidden <span id="bit-bswup-reload-status" role="status"> next to it. The built-in handling no longer reveals the overlay for updates, so a button left inside it would never become visible. The BswupProgress component ships this layout already.

    +
    + +

    Behavior changes

    +
      +
    • Cache buckets are scope-qualified: bit-bswup:<scope-path> - <version> (previously bit-bswup - <version>). Multiple Bswup apps on one origin no longer evict each other's caches; the migration from legacy-named buckets is automatic and does not re-download.
    • +
    • Under the default lax tolerance, the asset download now runs inside the install event (waitUntil): the browser keeps the worker alive for the whole download, and updateReady is only raised once the new version is fully staged - previously it could fire while the download had barely started.
    • +
    • assetsUrl defaults to a path resolved against the service worker script's own location (previously root-absolute), so apps mounted on a sub-path work without configuration.
    • +
    • The cleanup callback only prunes when no update is staged or staging, and BitBswup.forceRefresh()'s default filter clears only this app's own, legacy, and Blazor caches. The same staged-or-staging guard protects every cache-pruning path, so a prune can never race a newer install's freshly written bucket.
    • +
    • Requests carrying a Range header are answered with real 206 Partial Content slices from cached bodies (cached media now plays in Safari), and partial responses are never written to the cache.
    • +
    • Updates found later in the same session as the first install are treated as real updates: updateReady is raised, downloadFinished carries firstInstall: false, and accepting them runs the normal SKIP_WAITING flow. Previously a long-lived tab kept classifying every later update as another first install.
    • +
    • Navigations whose URL is itself a managed asset (e.g. opening /manifest.json directly) are served that asset instead of the SPA default document.
    • +
    • An update's re-download of the default document and of hash-less assets no longer deletes the existing cache entry first: if the refresh fails (offline mid-update), the previous copy keeps serving - including offline navigation.
    • +
    • The built-in progress UI stays out of the way during background updates: the full-screen splash is first-install only (downloadStarted/downloadProgress payloads carry firstInstall so custom handlers can do the same), the reload button lives outside the overlay, it is announced via a role="status" region, and the overlay no longer swallows clicks outside its content.
    • +
    • A first install completes even when no handler function is registered at all (previously the app waited out the full stallTimeout behind the splash).
    • +
    • The cleanup worker unregisters its own registration during teardown and no longer claims clients; the page reloads on UNREGISTER only while actually controlled, removing a reload-loop hazard.
    • +
    • The passive-mode background top-up after first boot is deterministic: it fills every asset still missing from the cache.
    • +
    • The default asset excludes cover all shipped worker-script variants (bit-bswup.sw.min.js, bit-bswup.sw-cleanup.js, bit-bswup.sw-cleanup.min.js).
    • +
    • A manifest or externalAssets entry whose URL cannot be parsed is skipped with a non-fatal request error instead of killing the whole service worker at startup.
    • +
    • A navigation to a URL that only a RegExp externalAssets pattern matches is served the app shell, never the pattern asset - and a shell cache miss during navigation is refilled from the shell's own URL, never from the navigated route's URL.
    • +
    • WAITING_SKIPPED and UNREGISTER never reload an uncontrolled page anymore; they make sure the app is booted instead. Activating a first install through BitBswup.skipWaiting() completes the seamless claim-and-start flow instead of reloading.
    • +
    • A page that loads while an update is already mid-install now observes it: updateReady / stateChanged fire in that tab when the update finishes staging.
    • +
    • An exception thrown by the app's bitBswupHandler no longer breaks the update pipeline (it is logged, and the remaining messages still dispatch).
    • +
    + +

    New capabilities

    +
      +
    • Update polling: the updateInterval / updateOnVisibility script attributes, a registration-aware BitBswup.checkForUpdate(), and the updateNotFound / updateCheckFailed events.
    • +
    • Install robustness: errorTolerance (lax/strict), transient-failure retries (maxRetries, retryDelay), the stallTimeout first-install watchdog, and structured error payloads (reason, fatal, firstInstall - including the terminal install-infra reason).
    • +
    • Storage: persistStorage / BitBswup.persistStorage() for eviction-resistant storage, and cacheVersion for manual control of cache-bucket rotation.
    • +
    • Registration: automatic retry with the default scope when the browser rejects the configured scope, and fingerprint-tolerant Blazor entry-script detection for .NET 9+ @@Assets[...] references.
    • +
    + + diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor new file mode 100644 index 0000000000..9b89a6c65e --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor @@ -0,0 +1,91 @@ +@page "/playground" + +Live Playground - bit Bswup + +
    +

    Live Playground

    +

    + This documentation site is itself a Blazor WebAssembly app powered by Bswup. Inspect its service + worker, drive the JavaScript API, and watch every lifecycle event + arrive in real time. +

    + + +

    + Open dev tools → Network and switch to Offline, then reload - the app keeps working from + cache. Or open this site in a second tab, accept an update there, and watch this tab reload + itself onto the new version. +

    +
    + +
    +
    +

    Service worker status

    +
    +
    Service worker
    +
    This page
    +
    Scope
    +
    Active worker
    +
    Update staged
    +
    Storage
    +
    +
    + +
    +
    + +
    +

    Cache buckets

    +

    + Bswup's buckets are scope-qualified: + bit-bswup:<scope-path> - <version>. +

    +
      +
      + +
      +

      Update lifecycle

      +

      + checkForUpdate() re-fetches the service worker script; if nothing changed you + will see UPDATE_NOT_FOUND in the log below. skipWaiting() + activates a staged update immediately. +

      +
      + + +
      +
      + +
      +

      Storage & reset

      +

      + persistStorage() requests eviction-resistant storage (browsers may prompt or + apply engagement heuristics). forceRefresh() clears this app's caches, + unregisters its worker, and reloads - the last-resort client reset. +

      +
      + + +
      +
      +
      + +

      Event log

      +

      + Every BswupMessage raised on this page - fed by a custom handler chained after the + built-in progress UI via the Handler/data-bit-bswup-handler option + (newest first, capped at 200 entries). +

      +
        +
        + + diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor new file mode 100644 index 0000000000..50c28ac96c --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor @@ -0,0 +1,150 @@ +@page "/progress-ui" + +Progress UI - bit Bswup + +

        The Built-in Progress UI

        +

        + Instead of writing a handler yourself, use the built-in splash/progress UI: the + BswupProgress Razor component (the markup) plus bit-bswup.progress.js + (the behavior). It registered the splash you saw when this site first installed. +

        + +

        Reference both in your host document:

        + +

        Then render the component:

        + + +

        Component parameters

        +

        + Each parameter maps to a data-bit-bswup-* attribute that the script reads at load time. + The component emits no inline <script>, so it works under a strict + Content-Security-Policy and when rendered by an interactive Blazor renderer. +

        +
        + + + + + + + + + + + + + + +
        ParameterDefaultDescription
        AutoReloadfalseReload automatically when an update finishes instead of showing the reload button. Changed in v-10-6-0 - previously defaulted to true.
        ShowLogsfalseLog lifecycle messages to the console.
        ShowAssetsfalseList each downloaded asset inside the splash.
        AppContainer#appSelector of the element to hide while installing (used with HideApp). An invalid selector is tolerated - only the hiding is skipped.
        HideAppfalseHide the app container while the first install downloads.
        AutoHidefalseHide the splash automatically when the download finishes.
        Handler-Name of an additional custom handler invoked after the built-in handling, so you can layer your own behavior without replacing the UI. (Do not point it at bitBswupHandler itself - self-references are detected and ignored.)
        ChildContent-Replaces the default splash markup with your own - see below.
        +
        + +

        Behavior worth knowing

        +
          +
        • + The full-screen splash is first-install only. A background update downloads + silently behind the running app; when it finishes - or when an update is already staged at page + load - the reload button appears on its own, with a screen-reader announcement through a + role="status" region. +
        • +
        • + Clicks outside the splash content pass through the overlay, so an app force-started under a failure + panel stays usable. +
        • +
        • + A failure panel with retry appears for fatal first-install errors; deterministic failures + (an unparseable manifest, an integrity mismatch) hide the retry button since reloading would fail + identically. +
        • +
        • + Runtime toggles are available via + BitBswupProgress.config({ autoReload, showLogs, showAssets, hideApp, autoHide }). +
        • +
        + +

        Custom splash content

        +

        + Pass ChildContent to replace the default splash markup. The component keeps initializing + automatically, and the built-in behavior drives your markup through the documented element ids - + include whichever you want driven: +

        +
        + + + + + + + + + + + +
        Element idDriven as
        bit-bswup-progress-barWidth + aria-valuenow set to the download percentage; the splash root also gets --bit-bswup-percent / --bit-bswup-percent-text CSS variables.
        bit-bswup-percentText content set to NN%.
        bit-bswup-assetsEach downloaded asset prepended as a list item.
        bit-bswup-error (+ -message, -details, -retry)The failure panel for fatal first-install errors.
        bit-bswup-reload / bit-bswup-reload-statusThe update-ready button and its screen-reader status region.
        +
        + + The update-ready button (#bit-bswup-reload) and its status region are always rendered by + the component itself, outside the overlay, even with custom content - they are the only way a + finished update surfaces under the default AutoReload="false". A custom splash should not + render its own copy of those two; restyle them by id instead. + + +

        Standalone WebAssembly apps (like this site)

        +

        + In a standalone Blazor WebAssembly app the splash must exist before Blazor starts - on a first + install, Blazor only boots after the download completes - so the BswupProgress component + (which renders as part of the app) cannot paint the first-install splash. Hand-write the same markup in + index.html instead; bit-bswup.progress.js self-initializes from the + data-bit-bswup-* attributes exactly as it does for the component: +

        + + + Keep #bit-bswup-reload and its status region outside the #bit-bswup + overlay (a finished background update must surface the button without revealing the splash), and give + the button its own z-index if your app has fixed chrome in the top-right corner. View the + source of this very page for a complete working example. + + + + +@code { + private const string HostReferencesCode = @" +... + +"; + + private const string ComponentCode = @""; + + private const string HandWrittenCode = @"
        +
        +

        Installing the app

        +
        +
        +
        +

        0%

        +
        +

        Update failed to install

        +

        +
        
        +            
        +        
        +
        +
        + + +"; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/ScriptOptionsPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/ScriptOptionsPage.razor new file mode 100644 index 0000000000..5876bbdaf0 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/ScriptOptionsPage.razor @@ -0,0 +1,175 @@ +@page "/script-options" + +Script Options - bit Bswup + +

        Script Options

        +

        + Every option of the bit-bswup.js script tag. All of them are optional - remove any + attribute to fall back to its default. +

        + + + +

        Quick reference

        +
        + + + + + + + + + + + + + + + + +
        AttributeDefaultSummary
        scope/Service worker scope; also namespaces the cache buckets.
        logwarnLog level: none, error, warn, info, verbose, debug.
        swservice-worker.jsPath of the service worker file.
        handlerbitBswupHandlerName of the global function receiving lifecycle events.
        blazorScriptauto-detectedPath of the Blazor entry script, when it lives at a non-default path.
        updateInterval0 (off)Seconds between automatic update checks.
        updateOnVisibilityfalseCheck for an update whenever the tab returns to the foreground.
        stallTimeout60Seconds of total service worker silence before a first install falls back to a network boot.
        persistStoragefalseRequest eviction-resistant storage at startup.
        optionsbitBswupName of a global object to read all of these settings from.
        +
        + +

        scope

        +

        + The scope of the service worker. 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 offline support rather than losing + the service worker entirely; the fallback is reported as a console warning. +

        + + The scope also namespaces the caches: buckets are named bit-bswup:<scope-path> - <version>, + so several Bswup apps mounted under different scopes on one origin keep fully independent caches. + The previous scope-less name made sibling apps evict each other's caches on every update. On upgrade, + entries from a legacy-named bucket are migrated without re-downloading. + + +

        log

        +

        + The log level of the Bswup logger. Available options: 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. Defaults to service-worker.js.

        + +

        handler

        +

        + The name of the global handler function for service worker events. Defaults to + bitBswupHandler - which is also the name the built-in progress script 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). + 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 .NET 9+ emits when the script is referenced + through @@Assets["..."] / the ImportMap (e.g. _framework/blazor.web.<fingerprint>.js) + are recognized automatically, both for the auto-detected defaults and for an explicitly configured 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 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 and resume + when it becomes visible again. Omit or set 0 to disable (the default). +

        + +

        updateOnVisibility

        +

        + When true, Bswup checks for an update every time the tab returns to the foreground (the + visibilitychange event) - 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 (Chromium kills installs after + ~5 minutes) - which would otherwise leave the app frozen behind the splash forever. +

        +

        + 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 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. +

        + + This is disabled by default because the request can show a permission prompt (Firefox) and grant odds + are engagement-based elsewhere. For the best odds, leave it off and call + BitBswup.persistStorage() yourself from a user + gesture - after login, or from an "install app" button. + + +

        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; 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. +

        + + + + +@code { + private const string ScriptTagCode = @""; + + private const string OptionsObjectCode = @" + +"; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/ServiceWorkerPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/ServiceWorkerPage.razor new file mode 100644 index 0000000000..4c5f7f2bc5 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Pages/ServiceWorkerPage.razor @@ -0,0 +1,336 @@ +@page "/service-worker" + +Service Worker Settings - bit Bswup + +

        Service Worker Settings

        +

        + Everything you can configure in service-worker.js before importing the Bswup engine. +

        + + + +

        + The most important line is the last one - the only mandatory config in this file: +

        + + + +

        + Unlike the assets in service-worker-assets.js (which Bswup verifies with Subresource + Integrity), the service worker script itself cannot be integrity-pinned: browsers support neither an + integrity option on register() nor SRI for importScripts(). + A tampered service-worker.js or bit-bswup.sw.js is effectively persistent, + fully-privileged XSS. Serve both over HTTPS from an origin you control and apply a strict + Content-Security-Policy. +

        +

        + Bswup registers with updateViaCache: 'none' so update checks bypass the HTTP cache for + the whole service-worker.jsbit-bswup.sw.jsservice-worker-assets.js + import chain. As defense-in-depth (support is uneven on older Safari/iOS, and proxies are not bound + by it), also send Cache-Control: no-cache for these two files so every fetch + revalidates against the origin. +

        +
        + +

        How the URL-matching lists are matched

        +

        + assetsInclude, assetsExclude, prohibitedUrls, + serverHandledUrls and serverRenderedUrls all accept the same two kinds of entry: +

        +
          +
        • a RegExp (e.g. /\/admin\//) is used as the pattern it is - what you want for anything non-trivial;
        • +
        • a string (e.g. '/admin/') is matched literally as a substring of the URL. It is regex-escaped, so 'v1.0' matches only v1.0 and never v1X0.
        • +
        + + 'app.css' matches /css/app.css anywhere in the URL; anchor with a + RegExp such as /\/app\.css$/ if that is too broad. Prefer a RegExp + whenever you need anchoring, alternation or wildcards. Also note: before v-10-6-0 string entries were + silently ignored - see the migration guide. + + +

        Asset selection

        + +

        assetsInclude

        +

        The list of file names from the assets list to include when Bswup stores them in cache storage (regex supported).

        + +

        assetsExclude

        +

        The list of file names from the assets list to exclude when Bswup stores them in cache storage (regex supported).

        + +

        ignoreDefaultInclude / ignoreDefaultExclude

        +

        Ignore the default include / exclude arrays Bswup provides. The defaults are:

        + +

        + /\.wasm/, /\.pdb/ and /\.html/ are deliberately unanchored + (mirroring the standard Blazor template), so variants such as foo.wasm.br also match. + The default excludes keep the service worker's own scripts out of the cache: +

        + + + Caching service-worker-related files corrupts the update cycle of the service worker. Only the browser + should handle these files. + + +

        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), add + { "url": "/" }. +

        + +

        Accepted entry shapes and behaviors:

        +
          +
        • An object with a url (a concrete string, or a RegExp for server-generated names unknown ahead of time), a bare string, or a bare RegExp; a single value also works without the array.
        • +
        • An entry may carry a hash (an SRI digest, sha256-...) that participates in ?v= cache busting and - when enableIntegrityCheck is on - in integrity verification, exactly like a manifest asset.
        • +
        • Entries whose url cannot be parsed are skipped with a non-fatal request error instead of breaking the worker.
        • +
        • Cross-origin entries are fetched in CORS mode first; when the host sends no CORS headers, Bswup retries with no-cors and caches the resulting opaque response so the asset still works offline. This fallback is skipped for integrity-checked assets. Browsers pad opaque responses in quota accounting (Chromium reserves several megabytes per entry), so prefer CORS-enabled hosts when you control them.
        • +
        • Media works too: requests carrying a Range header are answered with a real 206 Partial Content sliced from the cached full body (Safari refuses media served as 200 for a ranged request); uncached ranged requests pass through with their Range intact, and partial responses are never written to the cache.
        • +
        • Entries cached for RegExp patterns are kept across updates so the app still boots offline, but only the newest three generations per pattern survive each update.
        • +
        + +

        Navigation & URLs

        + +

        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. When nothing matches, + offline navigation cannot work and Bswup logs a defaultUrl ... matches no asset warning + at startup. +

        +
          +
        • Navigations whose URL is itself a managed asset are served that asset instead of the default document (changed in v-10-6-0): opening /manifest.json directly shows that file, while route URLs (/counter, ...) still get the app shell.
        • +
        • If your host answers the shell URL with a redirect (e.g. //index.html, common on Cloudflare Pages and Netlify), Bswup rebuilds that response so offline deep-link navigation keeps working instead of failing with a redirect-mode error.
        • +
        + +

        assetsUrl

        +

        + The path of the compile-time assets manifest (default file name 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. Set it explicitly + only when the file lives somewhere else; a leading / makes the path origin-absolute. +

        + +

        prohibitedUrls

        +

        + URLs that should not be accessed (regex supported). Matching requests are answered by the service + worker with 403 Forbidden for every HTTP method (changed in v-10-6-0 - previously + 405). +

        + + Enforcement happens only inside the service worker, which is bypassed whenever the page is not + controlled (the very first visit, a hard reload, browsers without service worker support) and by any + client that talks to the server directly. Access control must be enforced on the server. + + +

        serverHandledUrls

        +

        URLs that skip the service worker offline pipeline entirely and are handled only by the server (regex supported) - e.g. /api, /swagger.

        + +

        serverRenderedUrls

        +

        URLs that should be rendered by the server rather than the client while navigating (regex supported) - e.g. /about.html, /privacy.

        + +

        caseInsensitiveUrl

        +

        + Enables case-insensitive URL checking - for asset cache matching and every URL-matching regex + list: when enabled, patterns are compiled with the i flag so e.g. + prohibitedUrls: [/\/admin\//] also blocks /ADMIN/. +

        + +

        noPrerenderQuery

        +

        + The query string attached to the default document request to disable server prerendering, so an + unwanted prerendered result is not cached - e.g. no-prerender=true. +

        + +

        forcePrerender

        +

        + Forces prerendering of the default document for every navigation request to ensure the server always + has the latest version of the app. Useful for server-rendered apps. +

        + +

        Install behavior & resiliency

        + +

        isPassive

        +

        + Enables passive mode: assets are not cached in advance but upon first request. 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 lazily fetched by the app while the top-up runs can be downloaded twice in that + window - a bandwidth cost, not a correctness issue. +

        + +

        errorTolerance

        +

        Controls how the service worker reacts to asset download / cache failures during install:

        +
        + + + + + + + + + + + + + + +
        ValueBehavior
        lax (default) + Best-effort install. Asset failures never fail the install; missing assets are filled in + lazily on first fetch. Failures are reported through the error event with + fatal: false and still count toward progress so the bar reaches 100%. + Tolerates optional externalAssets that may legitimately 404, and avoids + leaving a first visit with no service worker to complete the startup handshake. The + download runs under the install event's waitUntil, so + updateReady is only announced once the background fill has settled. +
        strict + Mirrors the standard Microsoft template / Workbox behavior. If any required asset fails, + the install rejects, the partial cache is discarded, and the previous service worker (if + any) keeps serving. Failed assets are not counted toward progress, so 100% means + every asset succeeded; the abort is reported with reason: 'install-aborted' + and fatal: true. On a first install (no previous version to fall back to) + Bswup starts the app without a service worker so it still boots, and the install is + retried on the next load. +
        +
        + +

        maxRetries / retryDelay

        +

        + maxRetries (default 2) is the number of additional download attempts + after the first when an asset fails transiently during install (a rejected fetch, or HTTP 408/429/5xx). + Deterministic failures - 404/403 and other permanent statuses, and SRI mismatches - are never retried, + since identical bytes would fail identically. +

        +

        + retryDelay (default 300) is the base backoff in milliseconds: attempt + n waits retryDelay * 2^(n-1) plus random jitter, so a mass failure doesn't re-hit + the origin in one synchronized burst. +

        + +

        enableIntegrityCheck

        +

        + Enables the browsers' built-in integrity verification by setting the integrity attribute + on the requests the service worker creates to fetch assets. +

        + +

        Caching & updates

        + +

        cacheVersion

        +

        + Overrides the value used to name the cache bucket (bit-bswup:<scope-path> - <version>). + By default this tracks Blazor's assetsManifest.version (a hash over the published assets), + so the cache rotates automatically whenever any asset hash changes - and only then. Set it to take + manual control: +

        +
          +
        • Pin it to a stable string so noisy dev rebuilds don't needlessly evict the whole cache.
        • +
        • Bump it to force a refresh when a meaningful change lives outside Blazor's asset manifest.
        • +
        • Feed it a build-stamped value (commit SHA, build timestamp) so it bumps automatically per publish.
        • +
        +

        + Only the cache bucket name is affected: per-asset ?v= cache busting and Subresource + Integrity keep using each asset's own hash. +

        + +

        disableHashlessAssetsUpdate

        +

        + Disables the automatic re-download of hash-less assets (e.g. external assets) that Bswup performs every + time an update is found. +

        + +

        enableCacheControl

        +

        + Adds cache-busting to each asset request (cache: 'no-store' plus a + cache-control: no-cache header). The header is only attached to same-origin requests: it + is not CORS-safelisted, so on a cross-origin asset it would force a preflight most third-party hosts + reject; cross-origin requests rely on the cache: no-store option alone. +

        + +

        Diagnostics

        +
          +
        • enableDiagnostics: pushes service worker logs to the browser console.
        • +
        • enableFetchDiagnostics: additionally logs every fetch event the worker handles.
        • +
        + +

        Modes

        +

        + A mode is a preset bundle of defaults for the individual settings above + (isPassive, defaultUrl, forcePrerender, + errorTolerance, caseInsensitiveUrl, noPrerenderQuery): it only + fills settings you have not assigned yourself, so any explicit assignment always wins over the preset - + including explicit falsy values such as caseInsensitiveUrl = false. +

        +
        + + + + + + + + + + +
        ModeBehavior
        NoPrerenderDisables prerendering of the default document for every navigation request.
        InitialPrerenderEnables prerendering of the default document only for the initial navigation request.
        AlwaysPrerenderEnables prerendering of the default document for every navigation request.
        FullOfflineFull offline mode: all assets are cached and served from cache from the first time the app loads.
        +
        + + + +@code { + private const string FullConfigCode = @"self.assetsInclude = [/\/data\.db$/]; +self.assetsExclude = [/\.scp\.css$/, /weather\.json$/]; +self.defaultUrl = '/'; +self.prohibitedUrls = [/\/admin\//]; +self.serverHandledUrls = [/\/api\//]; +self.serverRenderedUrls = [/\/privacy$/]; +self.externalAssets = [ + { + ""url"": ""/"" + }, + { + ""url"": ""https://www.googletagmanager.com/gtag/js?id=G-G123456789"" + } +]; +self.assetsUrl = '/service-worker-assets.js'; +self.noPrerenderQuery = 'no-prerender=true'; +self.cacheVersion = '2026.05.31-abc1234'; + +self.caseInsensitiveUrl = true; +self.ignoreDefaultInclude = true; +self.ignoreDefaultExclude = true; +self.isPassive = true; +self.enableIntegrityCheck = true; +self.enableDiagnostics = true; +self.enableFetchDiagnostics = true; + +self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js');"; + + private const string DefaultIncludeCode = @"[/\.dll$/, /\.wasm/, /\.pdb/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, + /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.svg$/, /\.woff2$/, /\.ttf$/, /\.webp$/]"; + + private const string DefaultExcludeCode = @"[ + /^_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$/, +]"; + + private const string ExternalAssetsCode = @"self.externalAssets = [ + { ""url"": ""/"" }, // the host page itself (e.g. _host.cshtml) + ""https://fonts.example.com/app-font.woff2"", // bare string shorthand + { ""url"": /_framework\/resource-collection\..*\.js/ }, // RegExp for server-generated names + { ""url"": ""/lib/chart.js"", ""hash"": ""sha256-..."" } // with SRI hash (cache busting + integrity) +];"; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Program.cs b/src/Bswup/Bit.Bswup.Demo/Program.cs new file mode 100644 index 0000000000..0523827786 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Program.cs @@ -0,0 +1,9 @@ +using Bit.Bswup.Demo; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +await builder.Build().RunAsync(); diff --git a/src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json b/src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json new file mode 100644 index 0000000000..29e59b6075 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Bit.Bswup.Demo": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5030;https://localhost:5031", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Bswup/Bit.Bswup.Demo/Shared/Callout.razor b/src/Bswup/Bit.Bswup.Demo/Shared/Callout.razor new file mode 100644 index 0000000000..e1f393e632 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Shared/Callout.razor @@ -0,0 +1,19 @@ +
        +
        @(Title ?? DefaultTitle)
        +
        @ChildContent
        +
        + +@code { + /// note | tip | warning | danger + [Parameter] public string Type { get; set; } = "note"; + [Parameter] public string? Title { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private string DefaultTitle => Type switch + { + "tip" => "Tip", + "warning" => "Warning", + "danger" => "Caution", + _ => "Note" + }; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Shared/CodeBlock.razor b/src/Bswup/Bit.Bswup.Demo/Shared/CodeBlock.razor new file mode 100644 index 0000000000..b15ddd7934 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Shared/CodeBlock.razor @@ -0,0 +1,12 @@ +
        +
        + @Language + +
        +
        @Code.Trim('\r', '\n')
        +
        + +@code { + [Parameter] public string Code { get; set; } = string.Empty; + [Parameter] public string Language { get; set; } = "code"; +} diff --git a/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor b/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor new file mode 100644 index 0000000000..c11ffbc96d --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor @@ -0,0 +1,34 @@ +@inherits LayoutComponentBase + +
        + + + bit platform logo + bit Bswup + + v10-6-0 +
        + NuGet + GitHub + +
        + + +
        + +
        +
        + @Body +
        + +
        diff --git a/src/Bswup/Bit.Bswup.Demo/Shared/NavMenu.razor b/src/Bswup/Bit.Bswup.Demo/Shared/NavMenu.razor new file mode 100644 index 0000000000..dadca62f71 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/Shared/NavMenu.razor @@ -0,0 +1,20 @@ +@* Clicking any link also closes the mobile sidebar; on desktop the class toggle is a no-op. *@ + diff --git a/src/Bswup/Bit.Bswup.Demo/_Imports.razor b/src/Bswup/Bit.Bswup.Demo/_Imports.razor new file mode 100644 index 0000000000..c24caf5fb9 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/_Imports.razor @@ -0,0 +1,10 @@ +@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 Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using Bit.Bswup.Demo +@using Bit.Bswup.Demo.Shared diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/app.css b/src/Bswup/Bit.Bswup.Demo/wwwroot/app.css new file mode 100644 index 0000000000..a35a3acf30 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/app.css @@ -0,0 +1,842 @@ +/* ============================================================================ + bit Bswup docs - design system + Light/dark theming via CSS custom properties; the html[data-theme="dark"] + attribute is set before first paint by the inline script in index.html. + ============================================================================ */ + +:root { + --brand: #0065ef; + --brand-strong: #004fc0; + --brand-soft: rgba(0, 101, 239, 0.10); + --navy: #03173d; + + --bg: #ffffff; + --bg-soft: #f6f8fb; + --bg-header: rgba(255, 255, 255, 0.85); + --surface: #ffffff; + --border: #e3e8f0; + --text: #1c2433; + --text-soft: #55627a; + --text-faint: #8b95a9; + --code-bg: #0e1726; + --code-text: #dce4f2; + --inline-code-bg: #eef2f8; + --inline-code-text: #b02a63; + --shadow: 0 1px 3px rgba(16, 24, 40, 0.07), 0 8px 24px rgba(16, 24, 40, 0.05); + + --note: #0065ef; + --tip: #0e9f6e; + --warning: #c27803; + --danger: #d92d20; + + --header-height: 3.75rem; + --sidebar-width: 17rem; + --content-width: 52rem; +} + +html[data-theme="dark"] { + --brand: #4d94ff; + --brand-strong: #77adff; + --brand-soft: rgba(77, 148, 255, 0.14); + + --bg: #0b1220; + --bg-soft: #101a2c; + --bg-header: rgba(11, 18, 32, 0.85); + --surface: #121d31; + --border: #223047; + --text: #e6ecf5; + --text-soft: #a7b4c9; + --text-faint: #6d7c94; + --code-bg: #0a111e; + --code-text: #d7e1f0; + --inline-code-bg: #1b2940; + --inline-code-text: #ff8fb8; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3); +} + +* { + box-sizing: border-box; +} + +html { + scroll-padding-top: calc(var(--header-height) + 1rem); +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; + font-size: 1rem; + line-height: 1.65; + -webkit-text-size-adjust: 100%; +} + +/* ---------------------------------------------------------------- boot screen */ + +.app-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + min-height: 100vh; + color: var(--text-soft); +} + +/* ---------------------------------------------------------------- Bswup splash + Branded overrides on top of _content/Bit.Bswup/bit-bswup.progress.css. */ + +#bit-bswup { + background: var(--navy); + background: linear-gradient(160deg, #03173d 0%, #0a2a66 60%, #0065ef 160%); + color: #eaf1ff; +} + +.splash-container { + max-width: 26rem; + margin-top: 22vh; +} + +.splash-logo { + border-radius: 1.25rem; + margin-bottom: 0.75rem; +} + +#bit-bswup .bit-bswup-title { + font-size: 1.5rem; + font-weight: 600; + margin: 0.25rem 0; +} + +#bit-bswup .bit-bswup-description { + color: #b9cdf3; +} + +#bit-bswup .bit-bswup-progress { + background-color: rgba(255, 255, 255, 0.18); + border-radius: 999px; + height: 0.375rem; + overflow: hidden; +} + +#bit-bswup #bit-bswup-progress-bar { + background: #ffffff; + height: 0.375rem; + border-radius: 999px; + transition: width 0.2s ease; +} + +#bit-bswup #bit-bswup-percent { + color: #ffffff; + font-variant-numeric: tabular-nums; +} + +#bit-bswup-reload { + background: var(--brand); + color: #ffffff; + border: none; + border-radius: 999px; + padding: 0.625rem 1.25rem; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + box-shadow: var(--shadow); +} + +#bit-bswup-reload:hover { + background: var(--brand-strong); +} + +/* ---------------------------------------------------------------- shell */ + +.docs-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 0.75rem; + height: var(--header-height); + padding: 0 1rem; + background: var(--bg-header); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 0.625rem; + text-decoration: none; + color: var(--text); + font-weight: 700; + font-size: 1.125rem; +} + +.brand img { + width: 2rem; + height: 2rem; + border-radius: 0.5rem; +} + +.brand-product { + color: var(--text-faint); + font-weight: 400; +} + +.version-badge { + font-size: 0.75rem; + font-weight: 600; + color: var(--brand); + background: var(--brand-soft); + border-radius: 999px; + padding: 0.125rem 0.625rem; +} + +.header-spacer { + flex: 1; +} + +.header-link { + color: var(--text-soft); + text-decoration: none; + font-size: 0.875rem; + padding: 0.375rem 0.625rem; + border-radius: 0.5rem; +} + +.header-link:hover { + color: var(--text); + background: var(--bg-soft); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: transparent; + color: var(--text-soft); + cursor: pointer; + font-size: 1rem; +} + +.icon-button:hover { + color: var(--text); + background: var(--bg-soft); +} + +.menu-button { + display: none; +} + +.theme-icon-dark, +html[data-theme="dark"] .theme-icon-light { + display: none; +} + +html[data-theme="dark"] .theme-icon-dark { + display: inline; +} + +/* ---------------------------------------------------------------- sidebar */ + +.docs-sidebar { + position: fixed; + top: var(--header-height); + bottom: 0; + left: 0; + width: var(--sidebar-width); + overflow-y: auto; + padding: 1.25rem 0.875rem 2rem; + border-right: 1px solid var(--border); + background: var(--bg); +} + +.docs-nav { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.nav-section { + margin: 1.25rem 0.625rem 0.375rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); +} + +.nav-section:first-child { + margin-top: 0; +} + +.docs-nav a { + display: block; + padding: 0.4375rem 0.625rem; + border-radius: 0.5rem; + color: var(--text-soft); + text-decoration: none; + font-size: 0.9063rem; +} + +.docs-nav a:hover { + color: var(--text); + background: var(--bg-soft); +} + +.docs-nav a.active { + color: var(--brand); + background: var(--brand-soft); + font-weight: 600; +} + +.docs-sidebar-backdrop { + display: none; +} + +/* ---------------------------------------------------------------- main */ + +.docs-main { + padding: calc(var(--header-height) + 2rem) 2rem 2rem; + margin-left: var(--sidebar-width); +} + +.docs-content { + max-width: var(--content-width); + margin: 0 auto; +} + +.docs-footer { + max-width: var(--content-width); + margin: 4rem auto 0; + padding-top: 1.25rem; + border-top: 1px solid var(--border); + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + color: var(--text-faint); + font-size: 0.8438rem; +} + +.docs-footer a { + color: var(--text-soft); +} + +/* ---------------------------------------------------------------- typography */ + +.docs-content h1 { + font-size: 2.125rem; + line-height: 1.2; + letter-spacing: -0.02em; + margin: 0 0 0.75rem; +} + +.docs-content h2 { + font-size: 1.5rem; + letter-spacing: -0.01em; + margin: 2.5rem 0 0.75rem; + padding-top: 1.25rem; + border-top: 1px solid var(--border); +} + +.docs-content h3 { + font-size: 1.1875rem; + margin: 1.75rem 0 0.5rem; +} + +.docs-content a { + color: var(--brand); + text-decoration: none; +} + +.docs-content a:hover { + text-decoration: underline; +} + +.docs-lead { + font-size: 1.125rem; + color: var(--text-soft); + margin: 0 0 1.5rem; +} + +.docs-content code:not(pre code) { + font-family: Consolas, "Cascadia Code", Menlo, monospace; + font-size: 0.85em; + background: var(--inline-code-bg); + color: var(--inline-code-text); + border-radius: 0.3125rem; + padding: 0.125rem 0.375rem; +} + +/* ---------------------------------------------------------------- tables */ + +.docs-table-wrapper { + overflow-x: auto; + margin: 1rem 0; + border: 1px solid var(--border); + border-radius: 0.75rem; +} + +.docs-content table { + width: 100%; + border-collapse: collapse; + font-size: 0.9063rem; +} + +.docs-content th { + text-align: left; + font-size: 0.8125rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-faint); + background: var(--bg-soft); + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.docs-content td { + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.docs-content tr:last-child td { + border-bottom: none; +} + +.docs-content td:first-child { + white-space: nowrap; +} + +/* ---------------------------------------------------------------- code blocks */ + +.code-block { + margin: 1rem 0; + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.375rem 0.5rem 0.375rem 1rem; + background: var(--code-bg); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.code-block-lang { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7d8db0; +} + +.code-block-copy { + border: 1px solid rgba(255, 255, 255, 0.15); + background: transparent; + color: #aebad2; + font-size: 0.75rem; + border-radius: 0.375rem; + padding: 0.25rem 0.625rem; + cursor: pointer; +} + +.code-block-copy:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; +} + +.code-block pre { + margin: 0; + padding: 1rem; + overflow-x: auto; + background: var(--code-bg); +} + +.code-block code { + font-family: Consolas, "Cascadia Code", Menlo, monospace; + font-size: 0.8438rem; + line-height: 1.6; + color: var(--code-text); + white-space: pre; +} + +/* ---------------------------------------------------------------- callouts */ + +.callout { + display: grid; + gap: 0.25rem; + margin: 1.25rem 0; + padding: 0.875rem 1rem; + border-radius: 0.75rem; + border: 1px solid var(--border); + border-left: 0.25rem solid var(--note); + background: var(--bg-soft); + font-size: 0.9375rem; +} + +.callout-title { + font-weight: 700; + font-size: 0.875rem; +} + +.callout-note { border-left-color: var(--note); } +.callout-note .callout-title { color: var(--note); } +.callout-tip { border-left-color: var(--tip); } +.callout-tip .callout-title { color: var(--tip); } +.callout-warning { border-left-color: var(--warning); } +.callout-warning .callout-title { color: var(--warning); } +.callout-danger { border-left-color: var(--danger); } +.callout-danger .callout-title { color: var(--danger); } + +.callout p { + margin: 0.25rem 0; +} + +/* ---------------------------------------------------------------- buttons */ + +.button-primary, +.button-secondary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border-radius: 0.625rem; + padding: 0.625rem 1.25rem; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + border: 1px solid transparent; +} + +.button-primary { + background: var(--brand); + color: #ffffff !important; +} + +.button-primary:hover { + background: var(--brand-strong); + text-decoration: none !important; +} + +.button-secondary { + background: var(--surface); + color: var(--text); + border-color: var(--border); +} + +.button-secondary:hover { + background: var(--bg-soft); + text-decoration: none !important; +} + +.button-danger { + border-color: var(--danger); + color: var(--danger); +} + +/* ---------------------------------------------------------------- home page */ + +.hero { + padding: 2.5rem 0 1rem; + text-align: center; +} + +.hero-badge { + display: inline-block; + font-size: 0.8125rem; + font-weight: 600; + color: var(--brand); + background: var(--brand-soft); + border-radius: 999px; + padding: 0.25rem 0.875rem; + margin-bottom: 1.25rem; +} + +.hero h1 { + font-size: 2.875rem; + line-height: 1.1; + letter-spacing: -0.03em; + margin: 0 0 1rem; +} + +.hero .accent { + color: var(--brand); +} + +.hero p { + max-width: 38rem; + margin: 0 auto 1.75rem; + font-size: 1.1875rem; + color: var(--text-soft); +} + +.hero-actions { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.hero-install { + display: inline-flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.75rem; + font-family: Consolas, "Cascadia Code", Menlo, monospace; + font-size: 0.9063rem; + background: var(--code-bg); + color: var(--code-text); + border-radius: 0.625rem; + padding: 0.625rem 1.125rem; +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 1rem; + margin: 2.5rem 0; +} + +.feature-card { + display: block; + padding: 1.125rem 1.25rem; + border: 1px solid var(--border); + border-radius: 0.875rem; + background: var(--surface); + box-shadow: var(--shadow); + color: var(--text) !important; + text-decoration: none !important; +} + +.feature-card:hover { + border-color: var(--brand); + transform: translateY(-2px); + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.feature-icon { + font-size: 1.5rem; + line-height: 1; + margin-bottom: 0.625rem; +} + +.feature-card h3 { + margin: 0 0 0.375rem; + font-size: 1.0313rem; +} + +.feature-card p { + margin: 0; + font-size: 0.875rem; + color: var(--text-soft); +} + +/* ---------------------------------------------------------------- playground */ + +.playground-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.playground-card { + border: 1px solid var(--border); + border-radius: 0.875rem; + background: var(--surface); + box-shadow: var(--shadow); + padding: 1.125rem 1.25rem; +} + +.playground-card h3 { + margin: 0 0 0.75rem; + font-size: 1.0313rem; +} + +.playground-card .actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.status-list { + margin: 0; + display: grid; + gap: 0.375rem; + font-size: 0.9063rem; +} + +.status-list > div { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.status-list dt { + color: var(--text-soft); +} + +.status-list dd { + margin: 0; + font-weight: 600; + text-align: right; + overflow-wrap: anywhere; +} + +.cache-list { + margin: 0.5rem 0 0; + padding-left: 1.25rem; + font-family: Consolas, "Cascadia Code", Menlo, monospace; + font-size: 0.8125rem; + color: var(--text-soft); +} + +.event-log { + list-style: none; + margin: 0.75rem 0 0; + padding: 0.5rem; + max-height: 22rem; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--bg-soft); + font-family: Consolas, "Cascadia Code", Menlo, monospace; + font-size: 0.8125rem; +} + +.event-log li { + display: flex; + gap: 0.625rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + align-items: baseline; +} + +.event-log li:nth-child(odd) { + background: var(--bg); +} + +.event-time { + color: var(--text-faint); + flex-shrink: 0; +} + +.event-type { + color: var(--brand); + flex-shrink: 0; +} + +.event-detail { + color: var(--text-soft); + overflow-wrap: anywhere; +} + +.event-empty { + color: var(--text-faint); +} + +/* ---------------------------------------------------------------- misc */ + +.docs-not-found { + text-align: center; + padding: 4rem 0; +} + +.docs-not-found h1 { + font-size: 4rem; + margin-bottom: 0; +} + +.pager { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-top: 3rem; +} + +.pager a { + display: inline-flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + text-decoration: none !important; + color: var(--text) !important; + min-width: 10rem; +} + +.pager a:hover { + border-color: var(--brand); +} + +.pager .pager-label { + font-size: 0.75rem; + color: var(--text-faint); +} + +.pager .pager-next { + text-align: right; + margin-left: auto; +} + +.pager b { + color: var(--brand); +} + +/* ---------------------------------------------------------------- responsive */ + +@media (max-width: 60rem) { + .menu-button { + display: inline-flex; + } + + .header-link-text { + display: none; + } + + .docs-sidebar { + transform: translateX(-100%); + transition: transform 0.2s ease; + z-index: 90; + box-shadow: none; + } + + body.sidebar-open .docs-sidebar { + transform: translateX(0); + box-shadow: var(--shadow); + } + + body.sidebar-open .docs-sidebar-backdrop { + display: block; + position: fixed; + inset: var(--header-height) 0 0 0; + z-index: 80; + background: rgba(3, 10, 24, 0.45); + } + + .docs-main { + margin-left: 0; + padding: calc(var(--header-height) + 1.25rem) 1rem 1.5rem; + } + + .hero h1 { + font-size: 2.125rem; + } +} diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/bit-bw-64.png b/src/Bswup/Bit.Bswup.Demo/wwwroot/bit-bw-64.png new file mode 100644 index 0000000000000000000000000000000000000000..00f5e6c3a761e017b86cc362f4f783bd3829814d GIT binary patch literal 1180 zcmV;N1Y`S&P)Px#1ZP1_K>z@;j|==^1poj6j8IHeMOIc;S65e6RaH+@=wz|5y zc6N5#+uQ&D|KsE1VPRo@et!A+`TqX?j*gB}Qc{6|f&Kmcl9G~ufPj&akx@}mP*6~0 zWMqSbgO87odwY9RQ&V_&c#MpUpP!%8)6?(o@Avoj=;-Lh#l?k%g|)S{`1tse@$vBR@M2+9#|=jP_- zT3TA{?Cjm$-RFMd5oSfL$ z*v-w&t*x!9s;YE!bj-}m*4EZ*YimbGM|^yIm6esZx3|8&zMGqyOG`^-Wo2n;X{Dv5 z)z#I%zrU=ktj^BPh=_=4YHHx%;HIXgz`($il$74y-inHf!otFyo}RC-uX1v7va+(U zu&`cUUabX5ivR!s32;bRa{vGf6951U69E94oEQKA0+~rfK~z{r?Uid=5@Mw9rB*O3Q0die-g`SyXhc_?_y;Jow6+~Ohygr89QJ~WnuoXhgy`_=Dr_Ep1OdRd?f@3Tii~9nBp5Egp7&sqi zB>MXL4Sb$Fl^nol9c)1=eOgrToyq8DaRSZ_3NdsZ2)iJ}u(P-pwxA=RvTa5}hBVM# zAx80ONWoa$d$t7*sU9^P@3ttRR@cOY9|w&JF1Gn2p=c~6#~yiTSv{B2v&6cDl{Rb{ z^)8|!xI8(fb8c$+X&GHHDFqS?6R@rZXM~7f>l#yM`!`AuGwgPtM7w?i!x^jqUJznd zyLn4aBc9IM-@*|L7Ax&Ur}{BY&FN6QEyR4wkew~LQ@dIn!QH9t{Xs_Nv@$5&L-G9w zb}~{f?X`o4rV0`E2wyzbpeO|yh(5vVoKG(-)@gaWnNmBuQG1GlXUq{Sar3#n1*1}* zyWlT+u#jHv6yV{Bg4)3n>{kac8OQanw5p$v;EUG{3y6vHVw6`FP%|&<`i;@BfTeN0 zNW66_jdFr@Bc|U$!vc6OYcIK+lg9we`$76vG}^F$vX3$)@aZ#W#_Doe2IH$h8Wv!` z&dMZ_9PzB-Pk MAX_EVENTS) events.length = MAX_EVENTS; + renderEvents(); + } + + function describe(message, data) { + try { + switch (message) { + case 'DOWNLOAD_STARTED': + return `version: ${data && data.version ? data.version : '?'}${data && data.firstInstall === false ? ' (background update)' : ''}`; + case 'DOWNLOAD_PROGRESS': + return `${Math.round(Number(data.percent) || 0)}% - ${data.asset ? data.asset.url : ''}`; + case 'DOWNLOAD_FINISHED': + return data && data.firstInstall ? 'first install complete' : 'update staged, ready to activate'; + case 'STATE_CHANGED': + return `state: ${data && data.currentTarget ? data.currentTarget.state : '?'}`; + case 'ACTIVATE': + return `version: ${data && data.version ? data.version : '?'}`; + case 'ERROR': + return `[${data && data.reason ? data.reason : 'unknown'}] fatal: ${data ? data.fatal : '?'} - ${data && data.message ? data.message : ''}`; + case 'UPDATE_CHECK_FAILED': + return `[${data && data.reason ? data.reason : 'unknown'}] ${data && data.message ? data.message : ''}`; + default: + return ''; + } + } catch (err) { + return ''; + } + } + + function renderEvents() { + const el = document.getElementById('bswup-demo-events'); + if (!el) return; + // Guard for the MutationObserver below: re-render only when the log actually grew, + // otherwise our own DOM writes would re-trigger the observer forever. + if (el.dataset.rendered === String(events.length)) return; + el.dataset.rendered = String(events.length); + + el.textContent = ''; + if (events.length === 0) { + const empty = document.createElement('li'); + empty.className = 'event-empty'; + empty.textContent = 'No events yet - Bswup raises events on install, update checks, downloads, and activation.'; + el.append(empty); + return; + } + for (const evt of events) { + const li = document.createElement('li'); + const time = document.createElement('span'); + time.className = 'event-time'; + time.textContent = evt.time.toLocaleTimeString(); + const type = document.createElement('b'); + type.className = 'event-type'; + type.textContent = evt.type; + const detail = document.createElement('span'); + detail.className = 'event-detail'; + detail.textContent = evt.detail; + li.append(time, type, detail); + el.append(li); + } + } + + // ---------------------------------------------------------------- playground + + function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; + } + + async function refreshStatus() { + if (!('serviceWorker' in navigator)) { + setText('pg-sw-supported', 'not supported'); + return; + } + setText('pg-sw-supported', 'supported'); + setText('pg-sw-controller', navigator.serviceWorker.controller ? 'this page is controlled' : 'this page is NOT controlled'); + try { + const reg = await navigator.serviceWorker.getRegistration(); + setText('pg-sw-scope', reg ? reg.scope : 'no registration'); + setText('pg-sw-state', reg && reg.active ? reg.active.state : 'none'); + setText('pg-sw-waiting', reg && reg.waiting ? 'yes - an update is staged' : 'no'); + } catch (err) { + setText('pg-sw-scope', String(err)); + } + try { + const persisted = navigator.storage && navigator.storage.persisted ? await navigator.storage.persisted() : undefined; + setText('pg-storage-persisted', persisted === undefined ? 'unknown' : (persisted ? 'persistent' : 'best-effort')); + } catch (err) { + setText('pg-storage-persisted', 'unknown'); + } + try { + const keys = await caches.keys(); + const list = document.getElementById('pg-cache-list'); + if (list) { + list.textContent = ''; + if (keys.length === 0) { + const li = document.createElement('li'); + li.textContent = '(no caches yet)'; + list.append(li); + } + for (const key of keys) { + const li = document.createElement('li'); + li.textContent = key; + list.append(li); + } + } + } catch (err) { /* CacheStorage unavailable (e.g. some private modes) */ } + } + + function checkForUpdate() { + record('(playground)', 'BitBswup.checkForUpdate() called'); + if (window.BitBswup && BitBswup.checkForUpdate) BitBswup.checkForUpdate(); + } + + function skipWaiting() { + if (window.BitBswup && BitBswup.skipWaiting) { + const result = BitBswup.skipWaiting(); + record('(playground)', `BitBswup.skipWaiting() returned ${result} (${result ? 'a staged update is being activated' : 'no update was waiting'})`); + } + } + + async function persistStorage() { + if (window.BitBswup && BitBswup.persistStorage) { + const result = await BitBswup.persistStorage(); + record('(playground)', `BitBswup.persistStorage() resolved ${result}`); + refreshStatus(); + } + } + + function forceRefresh() { + if (!confirm('Force refresh clears this app\'s caches, unregisters its service worker, and reloads the page. Continue?')) return; + if (window.BitBswup && BitBswup.forceRefresh) BitBswup.forceRefresh(); + } + + // ---------------------------------------------------------------- theme & layout + + function toggleTheme() { + const root = document.documentElement; + const dark = root.getAttribute('data-theme') === 'dark'; + if (dark) { + root.removeAttribute('data-theme'); + } else { + root.setAttribute('data-theme', 'dark'); + } + try { localStorage.setItem('bswup-demo-theme', dark ? 'light' : 'dark'); } catch (err) { } + } + + function toggleSidebar() { + document.body.classList.toggle('sidebar-open'); + } + + function closeSidebar() { + document.body.classList.remove('sidebar-open'); + } + + function copyCode(button) { + const block = button.closest('.code-block'); + const code = block ? block.querySelector('pre code') : null; + if (!code) return; + navigator.clipboard.writeText(code.textContent || '').then(() => { + const original = button.textContent; + button.textContent = 'Copied!'; + button.disabled = true; + setTimeout(() => { button.textContent = original; button.disabled = false; }, 1500); + }); + } + + // Blazor renders pages after this script runs (and on every navigation), so watch for the + // playground elements appearing and (re)hydrate them. The dataset guards keep this cheap: + // the callback exits immediately when nothing relevant changed. + if (typeof MutationObserver !== 'undefined') { + new MutationObserver(() => { + renderEvents(); + const playground = document.getElementById('bswup-playground'); + if (playground && playground.dataset.init !== 'true') { + playground.dataset.init = 'true'; + refreshStatus(); + } + }).observe(document.documentElement, { childList: true, subtree: true }); + } + + window.BswupDemo = { + toggleTheme: toggleTheme, + toggleSidebar: toggleSidebar, + closeSidebar: closeSidebar, + copyCode: copyCode, + refreshStatus: refreshStatus, + checkForUpdate: checkForUpdate, + skipWaiting: skipWaiting, + persistStorage: persistStorage, + forceRefresh: forceRefresh + }; +}()); diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/favicon.ico b/src/Bswup/Bit.Bswup.Demo/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..63e859b476eff5055e0e557aaa151ca8223fbeef GIT binary patch literal 5430 zcmc&&Yj2xp8Fqnv;>&(QB_ve7>^E#o2mu=cO~A%R>DU-_hfbSRv1t;m7zJ_AMrntN zy0+^f&8be>q&YYzH%(88lQ?#KwiCzaCO*ZEo%j&v;<}&Lj_stKTKK>#U3nin@AF>w zb3ONSAFR{u(S1d?cdw53y}Gt1b-Hirbh;;bm(Rcbnoc*%@jiaXM|4jU^1WO~`TYZ~ zC-~jh9~b-f?fX`DmwvcguQzn*uV}c^Vd&~?H|RUs4Epv~gTAfR(B0lT&?RWQOtduM z^1vUD9{HQsW!{a9|0crA34m7Z6lpG^}f6f?={zD+ zXAzk^i^aKN_}s2$eX81wjSMONE#WVdzf|MT)Ap*}Vsn!XbvsI#6o&ij{87^d%$|A{ z=F{KB%)g%@z76yBzbb7seW**Ju8r4e*Z3PWNX3_tTDgzZatz7)Q6ytwB%@&@A|XT; zecM`Snxx5po$C)%yCP!KEtos~eOS)@2=kX-RIm)4glMCoagTEFxrBeSX%Euz734Fk z%7)x(k~T!@Hbg_37NSQL!vlTBXoURSzt~I**Zw`&F24fH*&kx=%nvZv|49SC*daD( zIw<~%#=lk8{2-l(BcIjy^Q$Q&m#KlWL9?UG{b8@qhlD z;umc+6p%|NsAT~0@DgV4-NKgQuWPWrmPIK&&XhV&n%`{l zOl^bbWYjQNuVXTXESO)@|iUKVmErPUDfz2Wh`4dF@OFiaCW|d`3paV^@|r^8T_ZxM)Z+$p5qx# z#K=z@%;aBPO=C4JNNGqVv6@UGolIz;KZsAro``Rz8X%vq_gpi^qEV&evgHb_=Y9-l z`)imdx0UC>GWZYj)3+3aKh?zVb}=@%oNzg7a8%kfVl)SV-Amp1Okw&+hEZ3|v(k8vRjXW9?ih`&FFM zV$~{j3IzhtcXk?Mu_!12;=+I7XK-IR2>Yd%VB^?oI9c^E&Chb&&je$NV0P-R;ujkP z;cbLCCPEF6|22NDj=S`F^2e~XwT1ZnRX8ra0#DaFa9-X|8(xNW_+JhD75WnSd7cxo z2>I_J5{c|WPfrgl7E2R)^c}F7ry()Z>$Jhk9CzZxiPKL#_0%`&{MX>P_%b~Dx0D^S z7xP1(DQ!d_Icpk!RN3I1w@~|O1ru#CO==h#9M~S4Chx*@?=EKUPGBv$tmU+7Zs_al z`!jR?6T&Z7(%uVq>#yLu`abWk!FBlnY{RFNHlj~6zh*;@u}+}viRKsD`IIxN#R-X3 z@vxu#EA_m}I503U(8Qmx^}u;)KfGP`O9E1H1Q|xeeksX8jC%@!{YT1)!lWgO=+Y3*jr=iSxvOW1}^HSy=y){tOMQJ@an>sOl4FYniE z;GOxd7AqxZNbYFNqobpv&HVO$c-w!Y*6r;$2oJ~h(a#(Bp<-)dg*mNigX~9rPqcHv z^;c*|Md?tD)$y?6FO$DWl$jUGV`F1G_^E&E>sY*YnA~ruv3=z9F8&&~Xpm<<75?N3 z>x~`I&M9q)O1=zWZHN9hZWx>RQ}zLP+iL57Q)%&_^$Sme^^G7;e-P~CR?kqU#Io#( z(nH1Wn*Ig)|M>WLGrxoU?FZrS`4GO&w;+39A3f8w{{Q7eg|$+dIlNFPAe+tN=FOYU z{A&Fg|H73+w1IK(W=j*L>JQgz$g0 z7JpKXLHIh}#$wm|N`s}o-@|L_`>*(gTQ~)wr3Eap7g%PVNisKw82im;Gdv#85x#s+ zoqqtnwu4ycd>cOQgRh-=aEJbnvVK`}ja%+FZx}&ehtX)n(9nVfe4{mn0bgijUbNr7Tf5X^$*{qh2%`?--%+sbSrjE^;1e3>% zqa%jdY16{Y)a1hSy*mr0JGU05Z%=qlx5vGvTjSpTt6k%nR06q}1DU`SQh_ZAeJ}A@`hL~xvv05U?0%=spP`R>dk?cOWM9^KNb7B?xjex>OZo%JMQQ1Q zB|q@}8RiP@DWn-(fB;phPaIOP2Yp)XN3-Fsn)S3w($4&+p8f5W_f%gac}QvmkHfCj$2=!t`boCvQ zCW;&Dto=f8v##}dy^wg3VNaBy&kCe3N;1|@n@pUaMPT?(aJ9b*(gJ28$}(2qFt$H~u5z94xcIQkcOI++)*exzbrk?WOOOf*|%k5#KV zL=&ky3)Eirv$wbRJ2F2s_ILQY--D~~7>^f}W|Aw^e7inXr#WLI{@h`0|jHud2Y~cI~Yn{r_kU^Vo{1gjacQ2SG|`38cO8{tRlE9BowY18`wrcHm7wnpLyo7?c}uFvGvjzBXFM23lOxt|8I6{(E!!#%(T;dBHu;e z8mt?(KL)mJ)5UyJK5D(_f8+rj2~hp$xWKyTRrirK4_#TLzQAIAa(pjtM-6>xfBEHJ z>nw?yVCTI5OFjapPt#+NqJt{GKESyu1EhQ8ZAB;`BrG3=+v3gsxrumSe8#^C*G?!)JnXn(STfF^kd@qL+DBiP+-g@xtmHTbY zYI%y3t82mYTcZMuE}f%!(sFuao09B`iV$dg4y^Hd#jhC0@5Em8(?j!*Hh4Wrm9Cm7 zDDs;qnxm_nu|~ICCaYYoEhj%AldRf9t+c88&Md%b1lXSEI3%uW<~(N`c^Z-@!Gd;F z4_>S0WC3A7p1+GRE&M*MmHeJZzrN_FB0vM{zeJ8|>y}Zn6r5v_4%7P!g>0V6YV5Y( zy2kb^X6P3$(hG`YaUVkHF54XSpLmhi^%yM40NX-LcHEE5DAUR|ZmgUo!2$f>wZm*Y7vmhCNr9#>Vl^wl`ncCmuCa;>?Jysc@*behWYA z3Zm7vO$*iZ9Ti$})ZWD?#VzRJH}LhIlws1p#NfRyjZ@U`vy>f*0%8@y_R$$zb3egh zVzf}O_9I*IHf?E5L+hxY#M1G4cWO0^N3uM&StzhVg&kFLGd3=MukBWv+@IYujGFJX<(7*uB8AwICHD1Wf46&r z!$NDEJKa6{2s^u$dQKLEnS;g~JEZSb3c6;z+j8VV&0bAAGb@??%3a1&KqI~u(SrYjnDObCkBoTnynf2;i$%ojhqapSO|H{${FM=) z8bW-NYLigs>yhxb)`Ss5oJ+^b8TU(QUG|gZJdvf@(=nQQGHlHPo}AI$q7ry3V%4Ll z-3tE247jBE?I4ZMJpYKk<${QdZhWGVjh;=Tgu67O(SyknbO%%)uY->(Q6v0-}3 z#Bj@Fjj`}>HDzr$s4~AzE{@kq8TrL$%$GbW>rzI3S%PUGT`~^VyOLCh7au%s_M_4N zKtRx*Hsl;-m$EfhV+VqqKk?Nj;)hN2qdHMtM|@_4=t8_sg)i&+8SqAigYmVVvt}W@ zs($)T)|lSK`->CqTFQg^XFqL6@D%7Prgk6_h&m1aFj!)erTz7zCJ z9wcjl&Xj$cj(hBj4up1psQ$r5%a7I>Lx-E^oW%{|bqDEA4YY>?nTfcIoT-~}WvE!I z@Xfx{scdmoI}Q4Crh}44Gdr`Fd3y-#fNHL8rsqeJ`XinCE@<^dLqt5&87^93*E2bJkap;l@UPqU=L)epd+3_xP*+1R+XVa7z1hXAy6 zc^Q##Q~Cr1#3&oVb0q|qkF3tO%LN4v9peV+vg{DRDs2QGqpUQQ>LD%=Bc)McKTLr;9me=H6??9C5KC57^Eq@rYX*x4At29vnDDLV9o z;416`{ayMpq1>9s?$;>dXkyfIE+v*fLch7;s4a*`|3`lt?v8v=udCvNZB*Hs(MjOe z_e$=wPxM;sg1NR9t3^8t4krI(4v~9g8Kx%;{!J|ew#yF?@h%mUbhhOB`;x+pL}X8A#?}h zg^iIiE#AERPAlqs!_Yxflqp)9I@rRI`2rAZ^R>e9S9S+-TJ;jExuI%*>W?h?d+;&? z>h~*|9&E5+e<--w@6z~|L|SFV3s&o;Uhepwa)c?ty6b_=96&Jcl0!JH=+bGG2Fh3b zYs%yI?x;WOfA2Q9#Q{S3L2mBh`p9^ql#!Ub+G1m1`HRv|RwQXCD-VFYOWhUPc6Z7~lDwVnG$vxd-dfLIONj5-aAkmDlK(6)%C8p8zw!2(u1EiESH+(C4hc&<&fWL8wUBd*=-NEt z`ab>{)_T!jm%jJIgVQENWZ(4wRB~dEjEjf;b>Q}o0OOgj z6>pWzk5#C#jUE#qzZKXh#80`m>C(4C9^uxmcg_mV|JjZi)q?)@ux$M_6O(QBs}Eh9 za${@4F>{=PtZ3hB`JLu^rke^EtV5i`;__3cs=JNRfp8q&1ozg~*mV*HyC81rdCGQVB_M+u} zd9~@R$-VseE8&9ccyJY+jjrHV%1DL|&TX_9zCjXg*P+ z*r3`q&aP+``$&D74>T%OGk&I2pih-4XXl0KHK(i7{Bvl9xM#b^;mo5jz%RINpqkxZ zcv`Y44y$rozsqHecDOv>QAd?tf)RZ{5b= z{+fOEEJfk@&&Pm(6`@6{aD2m)Hnbv|KDR*pb`Rs6bv2K@=vfv?fp9P-J zVG%p7gPzn?jcj|JFBZ?&n+LZFGXry?#kt4d({(Ot2xQCANr!W`Z8gSG4G~_%4=&-% z1N-b=dTdOr_55qdWfBsk5D_Tn7dbMc?6=h(3k9Pu`M_&i3)PKX_kyv^bh3Y%L;E*#g@na+BNSz_+0yVYW_nWnnP{6&(i0SgFcziO8hp^BU zM#z|rv&*<;I|(a-8=KM(h+>vT^0kw(f?+-jD=km9_r5q*$wg3BdveTNj^DCF$5UO? z$i+K|-Aa`gNaOgITLzyZUN)N`CmoteS?QHuyOTZj3J6=Q=ZYoY~s6@@y9ixQTu92#B0gf zdY?*hW=CTm?i~2}N_DRZim*a}1X{9ud8Fxy6712(oyjz2{NE~I`+OA;-17^_bBtzQ zKSYSQD%WFx)V|M+R2k}nyy511{3%FX2y$qwlmmr)k_Jet+}9AM47#BFE{9vC22d4& zB13ytye?dW5Q5?VVHq-CW-I@$VnUso6_YNU72$yZz|RkY8!fDIYapNh$&%`BIW*=T z)bQ3YpF_Z7DP^GOh(kG&ox=h#q-ytIg;`~(3KUQ!UVEC< z^B@rc(^E77LN_zCs$MzLwP$7w%vfni^O7fPH(|kJBe3Skjd|@QDFFQ5Z}iufu+gaDzxQbz3Kr zX<5v$zNy8+MBQ@ZcRYo! zrh(0)DwZ`<%XtN!ii-?hCVbc~Z5}n7I@u&wvEagl+)K2u!)~;n zMO9Gs59ZRtVTNiP6qz(b-g{qS+KrnHa3JJ%O>_QU>PK+lfOU~b^l&3h-n62Y@jV6| zk}|rtmS|aG`Tz>J3V?M*4ufo2Ve`u-@?UdbO=Qol(ER;rPvq!oEP(3;b9oIqbj+8L zebAQ5Q~9&Fm~z?f7WLsFVo4gRIJ+pTe%RZj~chJhp$+2t45Wy+~5 z#4@6jT5Wd?H}s+8?MfeeT25KyvR{I<8L>88vc=Sf>VJngoBGC=D77^Ik8{(6@I7`2 z<^i*meT`wJi%;%csgIrOlTv<~SAUw2hnDiUr!Yb->@=y;(e1;94PUJ_ce__;J(w0T%gF_?-`O~YTlEw z>by0+r7(AeslGpvRa*$!WNgg%&*X)mecs<{Y$*+dq)T0)(gxBKG<@r}4|e6aR=66) z$&l8B6h3h`aKn{C14mY+`-JE|B+}j3*=ir!i6WepYF-7}+iTr^D*bXU^ zcI-p%YI*L#?TaCH#P&5BgYXg(`@0tAL?pCisFEhPpabFKHUKMfbc!yitTA&scsW z)3jG%T*wDstYFR2i9C*y5a}W=5aSTy_0R98iT;`<$PtCki5{ zD+*7jK;b-)iMV%3urPPu4A!7Ech%>_X9HlaoQiq`HlXXn4OA@o4~T~h=u4CHa7G5m z4sw@U{F+EculTsSF{_}Ol+Al;16PiU+z0F7p8dO6XE7%_L57*Z6i&O8iuL8g-PG1c zciGPWPM;XA6lkVm*TqXT-n-JnM8-8lepwPC$}pT9a5VK z3RK|{&(k`4xAaVcCpaG{!FmjsS?`2z;u;(dwGgg#)$Og_nZ4x@Dbd;MOCOBMaLTCl zb~4e{k(zn&TNg(9&85v`gp0}^6*DSO%wt-t(N>0SMda6BFwFT`E^hy9RiBjQ)zvWl zzF9oZ&nVi<%Xv@R!I+p@f6CPMoA7oS)BSIjmgq&}KS%FGNt{2T*zJ*e$m}3(nl3w% z6OXM`Vb+_sFQ)e2tXjwZ)yDQSN<1#qD4I5&?kRfM7F{#ppB<^!-0*?2kbUt&2>I!r z?5^WY`jM+QXPV8On_`<^eTr+G?7kvqTABl<>t1s?ryY7EFiI9@pCV}6q~)q=TaP_N znaZ;$HcK5_)AuU~M$62FJor=BH0Vk@b+GwjAhDIvjT2y83j?{SB2B->@qyMiVQK@| zH+zRZby}JnS5_=#gP5G#*ZKbLMh!ES_V6Oj4fW479VQv#tiN;ysD`WaR{5HSo@70~ zY{_@_jU1Wj`O)zvsN*x&B=z3-1{l2EoZ7$N?A5zQk}Wl{-__})Xr{I|psqtfK87s@ zmY83R@Rq!Db2!YPg|^;mgU2F%{2VVr>QMS|;ro$gHf~pbO_)SWVL9RjD5Yzt%g59pM3e}R zIqb`e7IW_!`uBH@M;bme+r^}IlJ+R`$CdPH*@piP8T{WQp8xl(|KDxO;ElmoS+Bpl T#>xCq39g!4H?A>s`}6+*Z~s`q literal 0 HcmV?d00001 diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/index.html b/src/Bswup/Bit.Bswup.Demo/wwwroot/index.html new file mode 100644 index 0000000000..7323fb4b21 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/index.html @@ -0,0 +1,98 @@ + + + + + + + bit Bswup - Blazor Service Worker Update Progress + + + + + + + + + + + + + +
        +
        + +

        bit Bswup

        +

        Preparing the app for offline use, please wait...

        +
        +
        +
        +

        0%

        + + +
        +
        + + + + +
        +
        + + Loading bit Bswup docs... +
        +
        + + + + + + + + + diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/manifest.json b/src/Bswup/Bit.Bswup.Demo/wwwroot/manifest.json new file mode 100644 index 0000000000..91f455e630 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "bit Bswup", + "short_name": "Bswup", + "description": "Documentation and live showcase of bit Bswup - install progress, background updates, and offline support for Blazor WebAssembly apps.", + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#03173d", + "icons": [ + { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js new file mode 100644 index 0000000000..97ab20a5a6 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js @@ -0,0 +1,8 @@ +// bit version: 10.5.0 + +// Development service worker of the Bswup docs site. The site dogfoods Bswup even during +// development so the full first-install / update experience is visible on plain F5. +self.assetsExclude = [/\.scp\.css$/]; +self.caseInsensitiveUrl = true; + +self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js new file mode 100644 index 0000000000..fbb1eaf7c1 --- /dev/null +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.published.js @@ -0,0 +1,13 @@ +// bit version: 10.5.0 + +self.assetsExclude = [/\.scp\.css$/]; +self.caseInsensitiveUrl = true; + +//// Resiliency knobs (see the Bswup README for details): +//self.errorTolerance = 'strict'; // abort the install if any asset fails ('lax' = best-effort lazy-fill, the default) +//self.maxRetries = 2; // extra download attempts on transient failures (408/429/5xx, dropped connections) +//self.retryDelay = 300; // base backoff in ms between those retries (exponential, with jitter) +//self.enableIntegrityCheck = true; // attach SRI hashes so tampered assets are rejected (requires byte-identical serving) +//self.cacheVersion = '2026.07.28-abc1234'; // pin/bump the cache bucket independently of the asset manifest + +self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); diff --git a/src/Bswup/Bit.Bswup.slnx b/src/Bswup/Bit.Bswup.slnx index cee3ce1029..85af14d2f4 100644 --- a/src/Bswup/Bit.Bswup.slnx +++ b/src/Bswup/Bit.Bswup.slnx @@ -7,11 +7,12 @@
        - + + diff --git a/src/Bswup/Demos/BasicDemo/Program.cs b/src/Bswup/Demos/BasicDemo/Program.cs index 0523827786..85dca32b62 100644 --- a/src/Bswup/Demos/BasicDemo/Program.cs +++ b/src/Bswup/Demos/BasicDemo/Program.cs @@ -1,4 +1,4 @@ -using Bit.Bswup.Demo; +using Bit.Bswup.BasicDemo; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; diff --git a/src/Bswup/Demos/BasicDemo/Properties/launchSettings.json b/src/Bswup/Demos/BasicDemo/Properties/launchSettings.json index 8093b9bbbe..a6b612a4d0 100644 --- a/src/Bswup/Demos/BasicDemo/Properties/launchSettings.json +++ b/src/Bswup/Demos/BasicDemo/Properties/launchSettings.json @@ -1,7 +1,7 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "Bit.Bswup.Demo": { + "Bit.Bswup.BasicDemo": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, diff --git a/src/Bswup/Demos/BasicDemo/_Imports.razor b/src/Bswup/Demos/BasicDemo/_Imports.razor index 772684f47c..203bcce7d7 100644 --- a/src/Bswup/Demos/BasicDemo/_Imports.razor +++ b/src/Bswup/Demos/BasicDemo/_Imports.razor @@ -6,4 +6,4 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop -@using Bit.Bswup.Demo +@using Bit.Bswup.BasicDemo diff --git a/src/Bswup/Demos/BasicDemo/wwwroot/manifest.json b/src/Bswup/Demos/BasicDemo/wwwroot/manifest.json index 4c9c96590a..a70e09bceb 100644 --- a/src/Bswup/Demos/BasicDemo/wwwroot/manifest.json +++ b/src/Bswup/Demos/BasicDemo/wwwroot/manifest.json @@ -1,6 +1,6 @@ { - "name": "Bit.Bswup.Demo", - "short_name": "Bit.Bswup.Demo", + "name": "Bit.Bswup.BasicDemo", + "short_name": "Bit.Bswup.BasicDemo", "start_url": "./", "display": "standalone", "background_color": "#ffffff", diff --git a/src/Bswup/Demos/FullDemo/Client/wwwroot/manifest.json b/src/Bswup/Demos/FullDemo/Client/wwwroot/manifest.json index 4c9c96590a..9d0997fb58 100644 --- a/src/Bswup/Demos/FullDemo/Client/wwwroot/manifest.json +++ b/src/Bswup/Demos/FullDemo/Client/wwwroot/manifest.json @@ -1,6 +1,6 @@ { - "name": "Bit.Bswup.Demo", - "short_name": "Bit.Bswup.Demo", + "name": "Bit.Bswup.FullDemo", + "short_name": "Bit.Bswup.FullDemo", "start_url": "./", "display": "standalone", "background_color": "#ffffff", diff --git a/src/Bswup/README.md b/src/Bswup/README.md index 2c55ebc08f..ab96fbf432 100644 --- a/src/Bswup/README.md +++ b/src/Bswup/README.md @@ -370,9 +370,9 @@ window.bitBswupHandler = (message, data) => { > those timers anyway); the next timer tick after the tab is foregrounded runs normally. For > an *immediate* check the moment the user comes back, also set `updateOnVisibility="true"`. -## Upgrading to v-10.5.0 +## Upgrading to v-10-6-0 -v-10.5.0 is a resilience-focused release. Most changes are internal hardening, but the following are visible when upgrading; each is described in detail in its section above. +v-10-6-0 is a resilience-focused release. Most changes are internal hardening, but the following are visible when upgrading; each is described in detail in its section above. **Behavior changes** From c04b464029a0bbe646a9229e00d75e4ea6e3ada7 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 11:55:06 +0330 Subject: [PATCH 38/50] fix version badge --- src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor b/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor index c11ffbc96d..73c18b7f91 100644 --- a/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor +++ b/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor @@ -6,7 +6,7 @@ bit platform logo bit Bswup - v10-6-0 + v10.5.0
        NuGet GitHub From dc36feb9a7cbc7a4ca3d7f59e9ca51272e4ff129 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 11:57:32 +0330 Subject: [PATCH 39/50] fix port numbers --- src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json | 2 +- src/Bswup/Demos/FullDemo/Server/Properties/launchSettings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json b/src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json index 29e59b6075..e4eff42357 100644 --- a/src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json +++ b/src/Bswup/Bit.Bswup.Demo/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5030;https://localhost:5031", + "applicationUrl": "http://localhost:5024;https://localhost:5025", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Bswup/Demos/FullDemo/Server/Properties/launchSettings.json b/src/Bswup/Demos/FullDemo/Server/Properties/launchSettings.json index a257a7aa19..458c81ce1c 100644 --- a/src/Bswup/Demos/FullDemo/Server/Properties/launchSettings.json +++ b/src/Bswup/Demos/FullDemo/Server/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5020;https://localhost:5021", + "applicationUrl": "http://localhost:5022;https://localhost:5023", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From 7bcb248c56705130f5d8b462610da3e38ea9a60e Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 13:31:17 +0330 Subject: [PATCH 40/50] resolve review comments 15 --- .github/workflows/bit.ci.Bswup.yml | 2 +- .../Bit.Bswup.Demo/Pages/CleanupPage.razor | 6 ++++- .../Bit.Bswup.Demo/Pages/EventsPage.razor | 26 +++++++++++++++---- .../Pages/GettingStartedPage.razor | 2 +- .../Bit.Bswup.Demo/Pages/JsApiPage.razor | 10 ++++--- .../Bit.Bswup.Demo/Pages/ProgressUIPage.razor | 2 +- src/Bswup/Bit.Bswup.Demo/wwwroot/app.css | 2 +- src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js | 10 ++++--- src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 19 +++++++++++++- .../Bit.Bswup/Styles/bit-bswup.progress.css | 1 - .../Demos/BasicDemo/Pages/CounterPage.razor | 2 +- .../Shared/DemoBswupProgressBar.razor.css | 4 +-- src/Bswup/README.md | 19 +++++++++----- .../Tests/bit-bswup-js/page-script.test.js | 25 ++++++++++++++++++ 14 files changed, 102 insertions(+), 28 deletions(-) diff --git a/.github/workflows/bit.ci.Bswup.yml b/.github/workflows/bit.ci.Bswup.yml index 2e74e2aad2..3387fba9c7 100644 --- a/.github/workflows/bit.ci.Bswup.yml +++ b/.github/workflows/bit.ci.Bswup.yml @@ -59,5 +59,5 @@ jobs: - name: JS tests (service worker, page script, progress UI) run: | cd src/Bswup/Tests/bit-bswup-js - npm ci || npm install + npm ci npm test diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor index b0a74b5265..6489a8db60 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/CleanupPage.razor @@ -8,7 +8,11 @@ broken worker or cache - with the self-destructing cleanup worker.

        -

        Replace the content of your service-worker.js with:

        +

        + Replace the content of your service-worker.js and + service-worker.published.js (the file deployed builds actually ship, via the + ServiceWorker item's PublishedContent mapping) with: +

        What happens next

        diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor index 3c14b6375a..c7e8796b26 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/EventsPage.razor @@ -99,7 +99,10 @@ Whether the install actually stopped. Under the default lax tolerance a failed asset is reported with fatal: false - the install still succeeds and the asset is fetched from the network on first use - so treat it as a warning, not a - dead app. Only fatal: true means no new version was installed. + dead app. fatal: true means no usable staged version is available to this + page - though a worker may still have been installed (a lax + install-infra failure resolves the install so the worker can keep serving + as a network pass-through). @@ -155,11 +158,17 @@ function bitBswupHandler(type, data) { case BswupMessage.downloadFinished: if (data.firstInstall) { - // First install: claim + start Blazor, no page reload. - data.reload().then(() => { + // First install: claim + start Blazor, no page reload. Reveal the app even if + // the claim/start stalls or rejects - a hidden #app with no way out is worse + // than an app shown behind a stale splash. + const reveal = () => { appEl.style.display = 'block'; bswupEl.style.display = 'none'; - }); + }; + const failSafe = setTimeout(reveal, 10000); + data.reload() + .catch(err => console.error('Bswup first install failed to start:', err)) + .then(() => { clearTimeout(failSafe); reveal(); }); } else { // Update: let the user accept it. reloadButton.style.display = 'block'; @@ -183,7 +192,14 @@ function bitBswupHandler(type, data) { // lax tolerance: the install continued; the asset lazy-fills later. return console.warn('Bswup asset skipped:', data.reason, data.message); } - return console.error('Bswup install error:', data.reason, data.message, data); + console.error('Bswup install error:', data.reason, data.message, data); + if (data.firstInstall) { + // Fatal first-install failure: Bswup force-starts Blazor from the network; + // reveal the app instead of leaving it booted behind the splash. + appEl.style.display = 'block'; + bswupEl.style.display = 'none'; + } + return; } }"; diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor index bca0a29d3d..33058d424c 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/GettingStartedPage.razor @@ -5,7 +5,7 @@

        Getting Started

        Add install progress, controlled updates, and offline support to a Blazor WebAssembly app - in five small steps. + in six small steps.

        1. Install the NuGet package

        diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor index 8b79130511..f378f2defe 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor @@ -107,11 +107,15 @@ BitBswup.forceRefresh(() => true); // every cache on the origin BitBswup.forceRefresh('bit-bswup'); // only Bswup's own caches BitBswup.forceRefresh(/^(bit-bswup|my-app-data)/) // a specific set"; - private const string PollingCode = @"// check every hour from your own code (equivalent to updateInterval=""3600"") -setInterval(() => BitBswup.checkForUpdate(), 60 * 60 * 1000); + private const string PollingCode = @"// consume the returned promise so a failed check is reported, not an unhandled rejection +const checkNow = () => BitBswup.checkForUpdate() + .catch(err => console.warn('update check failed:', err)); + +// check every hour from your own code (equivalent to updateInterval=""3600"") +setInterval(checkNow, 60 * 60 * 1000); // or check whenever the user clicks a button, and react to the result -document.getElementById('check-updates').onclick = () => BitBswup.checkForUpdate();"; +document.getElementById('check-updates').onclick = checkNow;"; private const string PollingHandlerCode = @"window.bitBswupHandler = (message, data) => { switch (message) { diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor index 50c28ac96c..861f5d67ab 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/ProgressUIPage.razor @@ -132,7 +132,7 @@

        Installing the app

        -

        0%

        diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/app.css b/src/Bswup/Bit.Bswup.Demo/wwwroot/app.css index a35a3acf30..d559eea45f 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/app.css +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/app.css @@ -807,7 +807,7 @@ html[data-theme="dark"] .theme-icon-dark { display: inline-flex; } - .header-link-text { + .header-link { display: none; } diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js index abafd98125..73eaa8e27f 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js @@ -174,12 +174,14 @@ const block = button.closest('.code-block'); const code = block ? block.querySelector('pre code') : null; if (!code) return; - navigator.clipboard.writeText(code.textContent || '').then(() => { - const original = button.textContent; - button.textContent = 'Copied!'; + const original = button.textContent; + const flash = text => { + button.textContent = text; button.disabled = true; setTimeout(() => { button.textContent = original; button.disabled = false; }, 1500); - }); + }; + if (!navigator.clipboard || !navigator.clipboard.writeText) return flash('Copy failed'); + navigator.clipboard.writeText(code.textContent || '').then(() => flash('Copied!'), () => flash('Copy failed')); } // Blazor renders pages after this script runs (and on every navigation), so watch for the diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index c9a7ba3087..c0899f5dea 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -898,7 +898,24 @@ if (!BitBswup.initialized) { // 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 { + // + // Overlapping calls share one in-flight check (the public API is documented as + // safe to call as often as you like): the check is reg.update() followed by a + // yield and an installing/waiting inspection, so two interleaved runs could each + // read the registration mid-way through the other's update and announce a + // spurious updateNotFound - or double-announce the same outcome. Joining the + // in-flight promise gives every concurrent caller the same single check/report. + let updateCheckInFlight: Promise | undefined; + function checkForUpdate(): Promise { + if (updateCheckInFlight) { + verbose('update check already in flight - joining it.'); + return updateCheckInFlight; + } + updateCheckInFlight = runUpdateCheck().finally(() => updateCheckInFlight = undefined); + return updateCheckInFlight; + } + + async function runUpdateCheck(): Promise { if (!registration) { warn('checkForUpdate called before the service worker registration was ready.'); return; diff --git a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css index cb56d3d26d..7167497afd 100644 --- a/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css +++ b/src/Bswup/Bit.Bswup/Styles/bit-bswup.progress.css @@ -115,7 +115,6 @@ 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/Demos/BasicDemo/Pages/CounterPage.razor b/src/Bswup/Demos/BasicDemo/Pages/CounterPage.razor index 13ee69793a..7341d2fbf9 100644 --- a/src/Bswup/Demos/BasicDemo/Pages/CounterPage.razor +++ b/src/Bswup/Demos/BasicDemo/Pages/CounterPage.razor @@ -4,7 +4,7 @@

        Counter

        - +
        diff --git a/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css b/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css index 61597df85b..0b023e5224 100644 --- a/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css +++ b/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor.css @@ -5,7 +5,7 @@ bottom: 50px; text-align: center; width: 200px; - font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; + font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; } ::deep #bit-bswup .bswup-container { @@ -37,7 +37,7 @@ transform: translateX(-50%); text-align: center; z-index: 999999; - font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; + font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; } ::deep #bit-bswup .bswup-container { diff --git a/src/Bswup/README.md b/src/Bswup/README.md index ab96fbf432..a66e2d4b12 100644 --- a/src/Bswup/README.md +++ b/src/Bswup/README.md @@ -127,8 +127,11 @@ function bitBswupHandler(type, data) { // data.fatal says whether the install actually stopped. Under the default 'lax' // tolerance a failed asset is reported with `fatal: false` - the install still // succeeds and that asset is fetched from the network on first use - so treat it - // as a warning, not a dead app. Only `fatal: true` (an invalid manifest, or an - // abort under errorTolerance 'strict') means no new version was installed. + // as a warning, not a dead app. `fatal: true` (an invalid manifest, an abort under + // errorTolerance 'strict', or an 'install-infra' failure) means no usable staged + // version is available to this page. Note that a worker may still have been + // installed: a lax 'install-infra' failure resolves the install so the worker can + // keep serving as a network pass-through. // // data.firstInstall distinguishes where a fatal failure landed. `true`: it happened // before the app ever booted - Bswup starts the app without a service worker so it @@ -263,7 +266,7 @@ The other settings are: /^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. + **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). - `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`: Controls how the service worker reacts to asset download / cache failures during install. Possible values: @@ -315,7 +318,7 @@ Behavior worth knowing: ## Backing out of Bswup (the cleanup worker) -To fully remove Bswup from a deployed app (dropping offline support, or recovering clients stuck on a broken worker/cache), replace the *content* of your `service-worker.js` with: +To fully remove Bswup from a deployed app (dropping offline support, or recovering clients stuck on a broken worker/cache), replace the *content* of your `service-worker.js` **and** `service-worker.published.js` (the file deployed builds actually ship, via the `ServiceWorker` item's `PublishedContent` mapping) with: ```js self.importScripts('_content/Bit.Bswup/bit-bswup.sw-cleanup.js'); @@ -347,11 +350,15 @@ By default a service worker is only re-checked by the browser on navigation and 2. Call `BitBswup.checkForUpdate()` yourself, for example from a timer or after a user action. ```js +// consume the returned promise so a failed check is reported, not an unhandled rejection +const checkNow = () => BitBswup.checkForUpdate() + .catch(err => console.warn('update check failed:', err)); + // check every hour from your own code (equivalent to updateInterval="3600") -setInterval(() => BitBswup.checkForUpdate(), 60 * 60 * 1000); +setInterval(checkNow, 60 * 60 * 1000); // or check whenever the user clicks a button, and react to the result -document.getElementById('check-updates').onclick = () => BitBswup.checkForUpdate(); +document.getElementById('check-updates').onclick = checkNow; ``` Either way, the result surfaces through your `bitBswupHandler`: a found update flows through `updateFound`/`updateReady`, "nothing new" flows through `updateNotFound`, and a transient check failure flows through `updateCheckFailed` (handle it the same way as the other events, e.g. stop your spinner and optionally show a "couldn't check right now" hint - the app keeps running on the current version): diff --git a/src/Bswup/Tests/bit-bswup-js/page-script.test.js b/src/Bswup/Tests/bit-bswup-js/page-script.test.js index 8e1a1b8ec4..f9534c10f2 100644 --- a/src/Bswup/Tests/bit-bswup-js/page-script.test.js +++ b/src/Bswup/Tests/bit-bswup-js/page-script.test.js @@ -1132,6 +1132,31 @@ describe('checkForUpdate outcomes', () => { expect(seen.some(([message]) => message === 'UPDATE_CHECK_FAILED')).toBe(true); expect(seen.some(([message]) => message === 'ERROR')).toBe(false); }); + + it('overlapping calls share one in-flight check (one reg.update, one outcome report)', async () => { + let updates = 0; + const { ctx, seen } = checkPage(async () => { updates++; }); + await ctx.settle(); + + // Documented as "safe to call as often as you like": concurrent callers must join + // the in-flight check instead of racing reg.update() / the registration inspection. + await Promise.all([ + ctx.window.BitBswup.checkForUpdate(), + ctx.window.BitBswup.checkForUpdate(), + ctx.window.BitBswup.checkForUpdate(), + ]); + await letTimersFire(); + await ctx.settle(); + + expect(updates).toBe(1); + expect(seen.filter(([message]) => message === 'UPDATE_NOT_FOUND').length).toBe(1); + + // A later, non-overlapping call runs a fresh check again. + await ctx.window.BitBswup.checkForUpdate(); + await letTimersFire(); + await ctx.settle(); + expect(updates).toBe(2); + }); }); describe('update polling', () => { From f718b2cf7ab70ed734cca109246c69f524c8a454 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 15:25:36 +0330 Subject: [PATCH 41/50] resolve review comments --- src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 29 +++++++++--- src/Bswup/Demos/BasicDemo/wwwroot/index.html | 9 ++++ .../Demos/FullDemo/Client/Pages/Counter.razor | 2 +- .../bit-bswup-js/fetch-resilience.test.js | 44 +++++++++++-------- src/Bswup/Tests/bit-bswup-js/harness.js | 16 +++++++ .../Tests/bit-bswup-js/progress-ui.test.js | 7 +-- .../bit-bswup-js/version-consistency.test.js | 20 ++++++--- 7 files changed, 90 insertions(+), 37 deletions(-) diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index 94c3fbaf50..f88675ab25 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -50,9 +50,7 @@ interface BitBswupGlobals { // compilation the interface for the *other* lib (WorkerGlobalScope under DOM, Window under // WebWorker) is simply a harmless, unused standalone declaration. interface WorkerGlobalScope extends BitBswupGlobals { } -interface Window extends BitBswupGlobals { - // importScripts: any - } +interface Window extends BitBswupGlobals { } // 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. @@ -85,6 +83,14 @@ try { diag('*** importScripts failed:', ASSETS_URL, err); } +// State for sendError's non-fatal console cap (see the sendError implementation below for what +// it does). Declared HERE, ahead of the manifest validation, rather than beside sendError: +// sendError is a hoisted function declaration and the validation below calls it during module +// evaluation, so `let`/`const` bindings placed next to it would still be in their temporal dead +// zone at that moment - a ReferenceError waiting on the first non-fatal error reported early. +const MAX_NONFATAL_CONSOLE_ERRORS = 10; +let nonFatalConsoleErrors = 0; + const MANIFEST_ERRORS = validateAssetsManifest(self.assetsManifest); // When the manifest is missing/malformed the service worker must not proceed to enumerate // or cache assets - doing so would dereference self.assetsManifest.assets and crash, or @@ -1403,7 +1409,17 @@ async function createAssetsCache(ignoreProgressReport = false) { // hasIntegrity - not isIntegrity - so a CORS-shaped TypeError can never sneak // an integrity-checked asset into the unverified path. if (!hasIntegrity && isCrossOrigin) { - response = await tryFetch(createNewAssetRequest(asset, true)); + // createNewAssetRequest can throw on a pathological composed URL (as the + // guarded call sites elsewhere assume). Letting it escape from inside this + // catch block would reject addCache without ever running sendError/doReport, + // so the page would see neither an error nor the progress tick. Swallow it + // and leave `response` undefined: the block below then classifies and + // reports the ORIGINAL fetch failure, which is the meaningful one. + try { + response = await tryFetch(createNewAssetRequest(asset, true)); + } catch (noCorsErr) { + diag('*** addCache - no-cors fallback request build failed:', noCorsErr, asset.reqUrl); + } } if (!response) { @@ -1823,9 +1839,8 @@ function sendMessage(message: any) { // dropping while 200 assets are still downloading - otherwise floods the console with hundreds // of identical errors that bury the one line explaining what happened. Every failure is still // reported to the page (handlers may count or display them) and to diag; fatal errors are -// always logged. -const MAX_NONFATAL_CONSOLE_ERRORS = 10; -let nonFatalConsoleErrors = 0; +// always logged. (MAX_NONFATAL_CONSOLE_ERRORS / nonFatalConsoleErrors are declared near the +// top of this file, not here - see the note beside them.) function sendError(data: { reason: string; message: string; fatal?: boolean;[key: string]: any }) { diag('*** error:', data); diff --git a/src/Bswup/Demos/BasicDemo/wwwroot/index.html b/src/Bswup/Demos/BasicDemo/wwwroot/index.html index 989930555b..bf457c296f 100644 --- a/src/Bswup/Demos/BasicDemo/wwwroot/index.html +++ b/src/Bswup/Demos/BasicDemo/wwwroot/index.html @@ -132,6 +132,15 @@ data.reload().then(() => { appEl.style.display = 'block'; bswupEl.style.display = 'none'; + }).catch(() => { + // Same recovery the built-in progress UI performs: a rejected + // reload leaves the splash up with a manual retry wired back to + // data.reload, instead of freezing on "starting Blazor..." with + // no way forward. + descriptionLabel.textContent = 'Starting failed, click the button to retry.'; + bswupEl.style.display = 'block'; + reloadButton.style.display = 'block'; + reloadButton.onclick = data.reload; }); } else { descriptionLabel.textContent = 'Downloading finished, ready to reload.'; diff --git a/src/Bswup/Demos/FullDemo/Client/Pages/Counter.razor b/src/Bswup/Demos/FullDemo/Client/Pages/Counter.razor index e1f857fb6c..6526cae88a 100644 --- a/src/Bswup/Demos/FullDemo/Client/Pages/Counter.razor +++ b/src/Bswup/Demos/FullDemo/Client/Pages/Counter.razor @@ -12,7 +12,7 @@ } -
        Counter
        +

        Counter


        diff --git a/src/Bswup/Tests/bit-bswup-js/fetch-resilience.test.js b/src/Bswup/Tests/bit-bswup-js/fetch-resilience.test.js index 1957096203..13446d407c 100644 --- a/src/Bswup/Tests/bit-bswup-js/fetch-resilience.test.js +++ b/src/Bswup/Tests/bit-bswup-js/fetch-resilience.test.js @@ -25,6 +25,12 @@ function boot({ config = {}, assets = [{ url: 'app.js', hash: 'h1' }], fetchHand const NETWORK_DOWN = async () => { throw new TypeError('Failed to fetch'); }; +// The asset cache bucket every test here boots into: `bit-bswup: - ` with +// the harness's root scope and the 'v1' manifest version set in boot(). Named once so a change +// to the cache-key format is a one-line update rather than a sweep through the file. +const ASSET_CACHE = 'bit-bswup:/ - v1'; +const openAssetCache = sw => sw.caches.open(ASSET_CACHE); + describe('requests Bswup does not manage', () => { it('ignores non-GET requests instead of proxying them', async () => { const sw = boot(); @@ -73,7 +79,7 @@ describe('the managed path never rejects', () => { it('serves a stale cached copy when the network is down', async () => { const sw = boot({ config: { isPassive: true }, assets: [managed], fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/app.js`, { ok: true, status: 200, body: 'stale', clone: () => ({}) }); const { handled, response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); @@ -86,7 +92,7 @@ describe('the managed path never rejects', () => { // the same URL is the only thing left that can keep the app alive. it('serves a previous-hash copy when the network is down', async () => { const sw = boot({ config: { isPassive: true }, assets: [{ url: 'app.js', hash: 'sha256-new' }], fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/app.js.sha256-old`, { ok: true, status: 200, body: 'previous-version', clone: () => ({}) }); const { handled, response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); @@ -96,7 +102,7 @@ describe('the managed path never rejects', () => { it('never serves a sibling file (app.js.map / app.js.br) as the stale fallback', async () => { const sw = boot({ config: { isPassive: true }, assets: [{ url: 'app.js', hash: 'sha256-new' }], fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/app.js.map`, { ok: true, status: 200, body: 'a source map', clone: () => ({}) }); await cache.put(`${ORIGIN}/app.js.br`, { ok: true, status: 200, body: 'brotli bytes', clone: () => ({}) }); await cache.put(`${ORIGIN}/app.js.map.sha256-x`, { ok: true, status: 200, body: 'hashed map', clone: () => ({}) }); @@ -163,7 +169,7 @@ describe('the managed path never rejects', () => { it('serves from cache without touching the network', async () => { const sw = boot({ config: { isPassive: true }, assets: [managed], fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/app.js.h1`, { ok: true, status: 200, body: 'cached', clone: () => ({}) }); const { response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); @@ -173,7 +179,7 @@ describe('the managed path never rejects', () => { it('falls back to cache in active mode when the network is down', async () => { const sw = boot({ config: { isPassive: false }, assets: [managed], fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/app.js`, { ok: true, status: 200, body: 'stale', clone: () => ({}) }); const { handled, response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); @@ -185,7 +191,7 @@ describe('the managed path never rejects', () => { // regex semantics that matter (any query tolerated, caseInsensitiveUrl folding) must hold. it('matches an asset URL regardless of its query string (Map lookup is query-tolerant)', async () => { const sw = boot({ config: { isPassive: true }, assets: [managed], fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/app.js.h1`, { ok: true, status: 200, body: 'cached', clone: () => ({}) }); const { handled, response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js?asp-append-version=abc123` }); @@ -195,7 +201,7 @@ describe('the managed path never rejects', () => { it('matches case-insensitively when caseInsensitiveUrl is set', async () => { const sw = boot({ config: { isPassive: true, caseInsensitiveUrl: true }, assets: [managed], fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/app.js.h1`, { ok: true, status: 200, body: 'cached', clone: () => ({}) }); const { handled, response } = await sw.fetchEvent({ url: `${ORIGIN}/APP.JS` }); @@ -219,7 +225,7 @@ describe('range requests', () => { async function bootCached() { const sw = boot({ config: { isPassive: true }, assets: [managed], fetchHandler: NETWORK_DOWN, configure: includeMp4 }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/clip.mp4.h1`, fullBody()); return sw; } @@ -269,7 +275,7 @@ describe('range requests', () => { // The Cache API itself also rejects 206 puts, so an empty cache alone cannot prove // the worker's own guard exists - assert the put was never even attempted. - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); let putAttempts = 0; const originalPut = cache.put.bind(cache); cache.put = async (...args) => { putAttempts++; return originalPut(...args); }; @@ -277,7 +283,7 @@ describe('range requests', () => { const { response } = await sw.fetchEvent({ url: `${ORIGIN}/clip.mp4`, headers: { range: 'bytes=2-3' } }); expect(response.status).toBe(206); // the server's partial passes through untouched expect(putAttempts).toBe(0); - expect(sw.caches.snapshot()['bit-bswup:/ - v1'] || []).toHaveLength(0); + expect(sw.caches.snapshot()[ASSET_CACHE] || []).toHaveLength(0); }); it('sends a ranged passive-mode fetch with the page\'s own request so the server can answer 206', async () => { @@ -302,7 +308,7 @@ describe('range requests', () => { // bytes (corrupt). Fall back to the full response rather than emit a mislabeled 206. it('falls back to the full response when the cached body declares a content encoding', async () => { const sw = boot({ config: { isPassive: true }, assets: [managed], fetchHandler: NETWORK_DOWN, configure: includeMp4 }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/clip.mp4.h1`, new FakeResponse(TEXT, { status: 200, headers: new Headers({ 'content-encoding': 'br', 'content-type': 'video/mp4' }) })); const { response } = await sw.fetchEvent({ url: `${ORIGIN}/clip.mp4`, headers: { range: 'bytes=2-5' } }); @@ -313,7 +319,7 @@ describe('range requests', () => { // 'identity' is the no-op encoding and must still slice normally. it('still slices when the content encoding is identity', async () => { const sw = boot({ config: { isPassive: true }, assets: [managed], fetchHandler: NETWORK_DOWN, configure: includeMp4 }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/clip.mp4.h1`, new FakeResponse(TEXT, { status: 200, headers: new Headers({ 'content-encoding': 'identity' }) })); const { response } = await sw.fetchEvent({ url: `${ORIGIN}/clip.mp4`, headers: { range: 'bytes=2-5' } }); @@ -356,7 +362,7 @@ describe('active-mode lazy heal respects integrity', () => { const { response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); expect(response.type).toBe('error'); // failed, not served - expect(sw.caches.snapshot()['bit-bswup:/ - v1'] || []).toHaveLength(0); + expect(sw.caches.snapshot()[ASSET_CACHE] || []).toHaveLength(0); }); // A ranged heal keeps the page's own request: a 206 can't be SRI-verified and Safari needs @@ -386,7 +392,7 @@ describe('offline navigation (SPA default document)', () => { async function bootOffline(configure) { const sw = boot({ config: { isPassive: true }, assets, fetchHandler: NETWORK_DOWN, configure }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/index.html.idx`, { ok: true, status: 200, body: 'the app shell', clone: () => ({}) }); await cache.put(`${ORIGIN}/manifest.json.mf`, { ok: true, status: 200, body: '{"name":"app"}', clone: () => ({}) }); return sw; @@ -453,7 +459,7 @@ describe('offline navigation (SPA default document)', () => { expect(seen.some(u => u.includes('/counter'))).toBe(false); // the route URL is never fetched // And what landed under the shell's key is the shell, not the route's HTML. - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); const cached = await cache.match(`${ORIGIN}/index.html.idx`); expect(cached && cached.body).toBe('the real shell'); }); @@ -470,7 +476,7 @@ describe('redirected shell responses are cleaned for navigations', () => { it('rebuilds a cached redirected shell served to a deep-link navigation', async () => { const sw = boot({ config: { isPassive: true }, assets, fetchHandler: NETWORK_DOWN }); - const cache = await sw.caches.open('bit-bswup:/ - v1'); + const cache = await openAssetCache(sw); await cache.put(`${ORIGIN}/index.html.idx`, new FakeResponse('the app shell', { status: 200, redirected: true })); const { handled, response } = await sw.fetchEvent({ url: `${ORIGIN}/counter/42`, mode: 'navigate' }); @@ -527,7 +533,7 @@ describe('active mode lazily heals the cache', () => { const { handled, response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); expect(handled).toBe(true); expect(response.body).toBe('from-network'); - expect(sw.caches.snapshot()['bit-bswup:/ - v1']).toContain(`${ORIGIN}/app.js.h1`); + expect(sw.caches.snapshot()[ASSET_CACHE]).toContain(`${ORIGIN}/app.js.h1`); // The healed entry now serves offline: the second request never touches the network. const again = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); @@ -544,7 +550,7 @@ describe('active mode lazily heals the cache', () => { const { response } = await sw.fetchEvent({ url: `${ORIGIN}/app.js` }); expect(response.status).toBe(404); // passed through to the page untouched - expect(sw.caches.snapshot()['bit-bswup:/ - v1'] || []).toHaveLength(0); + expect(sw.caches.snapshot()[ASSET_CACHE] || []).toHaveLength(0); }); // A cross-origin externalAssets host without CORS headers rejects the worker's cors-mode @@ -565,6 +571,6 @@ describe('active mode lazily heals the cache', () => { const { handled, response } = await sw.fetchEvent({ url: CDN, mode: 'no-cors' }); expect(handled).toBe(true); expect(response.body).toBe('opaque-bytes'); - expect(sw.caches.snapshot()['bit-bswup:/ - v1']).toContain(CDN); + expect(sw.caches.snapshot()[ASSET_CACHE]).toContain(CDN); }); }); diff --git a/src/Bswup/Tests/bit-bswup-js/harness.js b/src/Bswup/Tests/bit-bswup-js/harness.js index 8247785017..2fdd4584a4 100644 --- a/src/Bswup/Tests/bit-bswup-js/harness.js +++ b/src/Bswup/Tests/bit-bswup-js/harness.js @@ -42,6 +42,22 @@ export function readBundle(name) { return fs.readFileSync(file, 'utf8'); } +/** + * Polls until `predicate()` returns true. Used where the code under test is driven by a real + * (harness-clamped) timer rather than a promise the test can await: a fixed sleep has to guess + * a duration that is simultaneously long enough not to flake on a loaded CI machine and short + * enough not to pad the suite, and it never gets both. Polling returns on the first tick after + * the timer fires and only spends the full `timeout` when the behavior is genuinely broken - + * where it then fails with a clear message instead of a bare assertion mismatch. + */ +export async function waitFor(predicate, message = 'condition', timeout = 2000) { + const deadline = Date.now() + timeout; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`waitFor timed out after ${timeout}ms waiting for: ${message}`); + await new Promise(r => setTimeout(r, 1)); + } +} + // --------------------------------------------------------------------------- // Minimal Request/Response/CacheStorage // --------------------------------------------------------------------------- diff --git a/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js b/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js index 32875fb18b..a38d20ebde 100644 --- a/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js +++ b/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createPageContext } from './harness.js'; +import { createPageContext, waitFor } from './harness.js'; const SPLASH = { 'data-bit-bswup-config': 'true' }; @@ -80,7 +80,7 @@ describe('MutationObserver lifetime', () => { // clampLongTimers lets the 60s OBSERVE_TIMEOUT fire at the harness's ~5ms clamp. const ctx = progressPage({ clampLongTimers: true }); expect(observing(ctx)).toBe(true); // still waiting - await new Promise(r => setTimeout(r, 20)); + await waitFor(() => !observing(ctx), 'the observer to disconnect on timeout'); expect(observing(ctx)).toBe(false); }); }); @@ -235,7 +235,8 @@ describe('update ready', () => { // Not shown synchronously - the fallback timer is still pending. expect(ctx.elements['bit-bswup-reload'].style.display).not.toBe('inline'); // clampLongTimers fires the 10s AUTO_RELOAD_FALLBACK_MS at the harness's ~5ms clamp. - await new Promise(r => setTimeout(r, 25)); + await waitFor(() => ctx.elements['bit-bswup-reload'].style.display === 'inline', + 'the fallback timer to reveal the reload button'); expect(ctx.elements['bit-bswup-reload'].style.display).toBe('inline'); }); }); diff --git a/src/Bswup/Tests/bit-bswup-js/version-consistency.test.js b/src/Bswup/Tests/bit-bswup-js/version-consistency.test.js index f4c704fe00..0fa5d45ee4 100644 --- a/src/Bswup/Tests/bit-bswup-js/version-consistency.test.js +++ b/src/Bswup/Tests/bit-bswup-js/version-consistency.test.js @@ -8,13 +8,19 @@ import { readBundle } from './harness.js'; // (`self["bit-bswup.sw version"]="v-10-5-0"`). describe('bundle version consistency', () => { it('all four bundles declare the same version', () => { - const versions = ['bit-bswup.js', 'bit-bswup.progress.js', 'bit-bswup.sw.js', 'bit-bswup.sw-cleanup.js'] - .map(name => { - const match = readBundle(name).match(/version["']\]\s*=\s*["']([^"']+)["']/); - expect(match, `${name} declares no version`).toBeTruthy(); - return match[1]; - }); + const bundles = ['bit-bswup.js', 'bit-bswup.progress.js', 'bit-bswup.sw.js', 'bit-bswup.sw-cleanup.js']; + const versions = bundles.map(name => { + const match = readBundle(name).match(/version["']\]\s*=\s*["']([^"']+)["']/); + expect(match, `${name} declares no version`).toBeTruthy(); + return match[1]; + }); - expect(new Set(versions).size).toBe(1); + // Assert on the per-bundle mapping rather than `new Set(versions).size`: a size + // mismatch reports "expected 2 to be 1", which says nothing about WHICH bundle was + // missed on a version bump - the one fact needed to fix it. Comparing every entry to + // the first prints the full name -> version list on failure. + const expected = Object.fromEntries(bundles.map(name => [name, versions[0]])); + const actual = Object.fromEntries(bundles.map((name, i) => [name, versions[i]])); + expect(actual, 'bundles declare mismatched versions').toEqual(expected); }); }); From 73ecd0d2b7cecf9fb19620526cc7665088134960 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 16:29:02 +0330 Subject: [PATCH 42/50] resolve review comments 17 --- src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor | 6 ++++-- src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js | 4 ++-- src/Bswup/README.md | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor index f378f2defe..14c5e79668 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/JsApiPage.razor @@ -107,9 +107,11 @@ BitBswup.forceRefresh(() => true); // every cache on the origin BitBswup.forceRefresh('bit-bswup'); // only Bswup's own caches BitBswup.forceRefresh(/^(bit-bswup|my-app-data)/) // a specific set"; - private const string PollingCode = @"// consume the returned promise so a failed check is reported, not an unhandled rejection + private const string PollingCode = @"// an ordinary failed check (offline, server hiccup) does NOT reject - it is reported through +// the UPDATE_CHECK_FAILED message below. This catch is only for unexpected errors, such as a +// call made before the registration is ready. const checkNow = () => BitBswup.checkForUpdate() - .catch(err => console.warn('update check failed:', err)); + .catch(err => console.warn('unexpected update check error:', err)); // check every hour from your own code (equivalent to updateInterval=""3600"") setInterval(checkNow, 60 * 60 * 1000); diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js index 73eaa8e27f..1ee0394970 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js @@ -129,9 +129,9 @@ if (window.BitBswup && BitBswup.checkForUpdate) BitBswup.checkForUpdate(); } - function skipWaiting() { + async function skipWaiting() { if (window.BitBswup && BitBswup.skipWaiting) { - const result = BitBswup.skipWaiting(); + const result = await BitBswup.skipWaiting(); record('(playground)', `BitBswup.skipWaiting() returned ${result} (${result ? 'a staged update is being activated' : 'no update was waiting'})`); } } diff --git a/src/Bswup/README.md b/src/Bswup/README.md index a66e2d4b12..5149dc8be9 100644 --- a/src/Bswup/README.md +++ b/src/Bswup/README.md @@ -350,9 +350,11 @@ By default a service worker is only re-checked by the browser on navigation and 2. Call `BitBswup.checkForUpdate()` yourself, for example from a timer or after a user action. ```js -// consume the returned promise so a failed check is reported, not an unhandled rejection +// an ordinary failed check (offline, server hiccup) does NOT reject - it is reported through +// the updateCheckFailed message below. This catch is only for unexpected errors, such as a +// call made before the registration is ready. const checkNow = () => BitBswup.checkForUpdate() - .catch(err => console.warn('update check failed:', err)); + .catch(err => console.warn('unexpected update check error:', err)); // check every hour from your own code (equivalent to updateInterval="3600") setInterval(checkNow, 60 * 60 * 1000); From b7269d044a28269abad9c624a5d8b23a2d4a966f Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 28 Jul 2026 19:36:00 +0330 Subject: [PATCH 43/50] resolve review comments 18 --- src/Bswup/Bit.Bswup.Demo/App.razor | 2 +- .../Bit.Bswup.Demo/Shared/MainLayout.razor | 2 +- src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 1 + src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts | 41 ++++++++++++------- .../Tests/bit-bswup-js/page-script.test.js | 21 ++++++---- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/Bswup/Bit.Bswup.Demo/App.razor b/src/Bswup/Bit.Bswup.Demo/App.razor index 0e41c27ed1..b063da0764 100644 --- a/src/Bswup/Bit.Bswup.Demo/App.razor +++ b/src/Bswup/Bit.Bswup.Demo/App.razor @@ -9,7 +9,7 @@

        404

        Sorry, there's nothing at this address.

        - Back to the docs + Back to the docs
        diff --git a/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor b/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor index 73c18b7f91..488e217490 100644 --- a/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor +++ b/src/Bswup/Bit.Bswup.Demo/Shared/MainLayout.razor @@ -2,7 +2,7 @@
        - + bit platform logo bit Bswup diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index f88675ab25..c0aac6026b 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -1129,6 +1129,7 @@ async function createAssetsCache(ignoreProgressReport = false) { // not a semantic; now the top-up deterministically fills whatever is still missing. if (passiveFirstTime && !ignoreProgressReport) { sendMessage({ type: 'bypass', data: { firstTime: true } }); + diagGroupEnd(); return; } diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts index c0899f5dea..76e5a15af5 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.ts @@ -758,22 +758,33 @@ if (!BitBswup.initialized) { // 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(() => { - 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); - } + const finishUnregister = () => { + 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); + } + }; + navigator.serviceWorker.getRegistration() + .then(reg => reg?.unregister()) + .then(finishUnregister) + // Either call can reject (storage errors, a registration removed + // concurrently). Recovering matters more than the unregister itself: + // without this the promise rejected unhandled and finishUnregister + // never ran, so a controlled page stayed attached to the worker being + // torn down and an uncontrolled one waited on the stall watchdog to + // boot. Run the same recovery either way. + .catch(err => { + warn('UNREGISTER teardown failed - recovering anyway', err); + finishUnregister(); }); - }); return; } diff --git a/src/Bswup/Tests/bit-bswup-js/page-script.test.js b/src/Bswup/Tests/bit-bswup-js/page-script.test.js index f9534c10f2..9879535906 100644 --- a/src/Bswup/Tests/bit-bswup-js/page-script.test.js +++ b/src/Bswup/Tests/bit-bswup-js/page-script.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createPageContext, ORIGIN } from './harness.js'; +import { createPageContext, ORIGIN, waitFor } from './harness.js'; /** A page with the bswup + blazor script tags in place, before bit-bswup.js is loaded. */ function page(options = {}, { swOptions, blazor = true } = {}) { @@ -504,6 +504,9 @@ describe('first-install stall watchdog', () => { }; } + // Only for the NEGATIVE tests below, which assert the watchdog never fires: "nothing + // happened" has no state to poll for, so real time genuinely has to pass. Tests that wait + // for an observable effect use waitFor instead, which neither flakes nor pads the run. const letTimersFire = () => new Promise(r => setTimeout(r, 30)); it('starts Blazor after total service-worker silence on a first install', async () => { @@ -511,8 +514,7 @@ describe('first-install stall watchdog', () => { { swOptions: { registration: installingRegistration() } }); ctx.load('bit-bswup.js'); await ctx.settle(); - await letTimersFire(); - await ctx.settle(); + await waitFor(() => ctx.window.__blazorStarted === 1, 'the stall watchdog to start Blazor'); expect(ctx.window.__blazorStarted).toBe(1); expect(ctx.reloads.count).toBe(0); @@ -1095,6 +1097,8 @@ describe('checkForUpdate outcomes', () => { return { ctx, seen, registration }; } + // Kept only for the negative test below (asserting updateNotFound is NOT emitted), where + // there is no state to poll for. Positive waits use waitFor. const letTimersFire = () => new Promise(r => setTimeout(r, 20)); it('emits updateNotFound when the check finds nothing new', async () => { @@ -1102,8 +1106,8 @@ describe('checkForUpdate outcomes', () => { await ctx.settle(); await ctx.window.BitBswup.checkForUpdate(); - await letTimersFire(); - await ctx.settle(); + await waitFor(() => seen.some(([message]) => message === 'UPDATE_NOT_FOUND'), + 'the check to report UPDATE_NOT_FOUND'); expect(seen.some(([message]) => message === 'UPDATE_NOT_FOUND')).toBe(true); }); @@ -1145,16 +1149,15 @@ describe('checkForUpdate outcomes', () => { ctx.window.BitBswup.checkForUpdate(), ctx.window.BitBswup.checkForUpdate(), ]); - await letTimersFire(); - await ctx.settle(); + await waitFor(() => seen.some(([message]) => message === 'UPDATE_NOT_FOUND'), + 'the shared in-flight check to report its single outcome'); expect(updates).toBe(1); expect(seen.filter(([message]) => message === 'UPDATE_NOT_FOUND').length).toBe(1); // A later, non-overlapping call runs a fresh check again. await ctx.window.BitBswup.checkForUpdate(); - await letTimersFire(); - await ctx.settle(); + await waitFor(() => updates === 2, 'the second, non-overlapping check to run'); expect(updates).toBe(2); }); }); From a41f64599b2c6296316a63a8fb5024ccae4c644e Mon Sep 17 00:00:00 2001 From: msynk Date: Wed, 29 Jul 2026 08:33:29 +0330 Subject: [PATCH 44/50] resolve review comments 19 --- src/Bswup/Bit.Bswup/Bit.Bswup.csproj | 22 +++++++++++++--- src/Bswup/Tests/bit-bswup-js/harness.js | 20 +++++++++++++++ .../Tests/bit-bswup-js/page-script.test.js | 25 +------------------ .../Tests/bit-bswup-js/progress-ui.test.js | 17 +------------ 4 files changed, 41 insertions(+), 43 deletions(-) diff --git a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj index 9969d5d918..e355026245 100644 --- a/src/Bswup/Bit.Bswup/Bit.Bswup.csproj +++ b/src/Bswup/Bit.Bswup/Bit.Bswup.csproj @@ -87,10 +87,26 @@ + + + + + + - + wwwroot .js outputs, so a no-op when nothing changed - except after a configuration + switch, which the last-configuration input forces through (see + RecordBswupBuildConfiguration). This always emits the unminified bundles; Release + minification is handled separately by MinifyAssets. --> + diff --git a/src/Bswup/Tests/bit-bswup-js/harness.js b/src/Bswup/Tests/bit-bswup-js/harness.js index 2fdd4584a4..4cdbcc5c63 100644 --- a/src/Bswup/Tests/bit-bswup-js/harness.js +++ b/src/Bswup/Tests/bit-bswup-js/harness.js @@ -554,3 +554,23 @@ export function createPageContext({ elements = {}, appContainer = null, readySta if (!sandbox.navigator) api.setServiceWorker(); return api; } + +/** A fake ServiceWorker whose lifecycle the test drives by hand (via fireStateChange). */ +export function fakeWorker(state) { + const listeners = []; + return { + state, + posted: [], + postMessage(message) { this.posted.push(message); }, + addEventListener(type, fn) { if (type === 'statechange') listeners.push(fn); }, + removeEventListener(type, fn) { + const index = listeners.indexOf(fn); + if (index !== -1) listeners.splice(index, 1); + }, + // Snapshot before dispatch: whenStaged/whenActive remove themselves mid-iteration. + fireStateChange() { listeners.slice().forEach(fn => fn({ currentTarget: this })); }, + }; +} + +/** The 100% progress message the service worker sends just before its install promise resolves. */ +export const progress100 = JSON.stringify({ type: 'progress', data: { percent: 100, index: 1, asset: { url: 'a.js' } } }); diff --git a/src/Bswup/Tests/bit-bswup-js/page-script.test.js b/src/Bswup/Tests/bit-bswup-js/page-script.test.js index 9879535906..6cbe30d778 100644 --- a/src/Bswup/Tests/bit-bswup-js/page-script.test.js +++ b/src/Bswup/Tests/bit-bswup-js/page-script.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createPageContext, ORIGIN, waitFor } from './harness.js'; +import { createPageContext, ORIGIN, waitFor, fakeWorker, progress100 } from './harness.js'; /** A page with the bswup + blazor script tags in place, before bit-bswup.js is loaded. */ function page(options = {}, { swOptions, blazor = true } = {}) { @@ -260,30 +260,11 @@ describe('bypass routing', () => { }); }); -// A fake ServiceWorker whose lifecycle the test drives by hand. -function fakeWorker(state) { - const listeners = []; - return { - state, - posted: [], - postMessage(message) { this.posted.push(message); }, - addEventListener(type, fn) { if (type === 'statechange') listeners.push(fn); }, - removeEventListener(type, fn) { - const index = listeners.indexOf(fn); - if (index !== -1) listeners.splice(index, 1); - }, - // Snapshot before dispatch: whenStaged/whenActive remove themselves mid-iteration. - fireStateChange() { listeners.slice().forEach(fn => fn({ currentTarget: this })); }, - }; -} - // The 100% progress message is sent just before the SW's install promise resolves, so when a // handler calls reload() the new worker can still be 'installing'. reload() must wait for the // state each flow needs instead of racing the lifecycle (the old code picked between a seamless // claim, a SKIP_WAITING reload, and a hard reload depending on which instant the message landed). describe('reload determinism', () => { - const progress100 = JSON.stringify({ type: 'progress', data: { percent: 100, index: 1, asset: { url: 'a.js' } } }); - it('first install: waits for activation, then claims - no reload on any path', async () => { const installing = fakeWorker('installing'); const registration = { active: null, waiting: null, installing, addEventListener() { }, update: async () => { } }; @@ -762,8 +743,6 @@ describe('persistent storage', () => { // at the OLD worker - wiping the freshly staged cache), and a sibling tab's update claim // skipped the mandatory reload, running old app code against new-version caches. describe('updates after a completed first install', () => { - const progress100 = JSON.stringify({ type: 'progress', data: { percent: 100, index: 1, asset: { url: 'a.js' } } }); - function firstInstallPage() { const regListeners = {}; const registration = { @@ -865,8 +844,6 @@ describe('updates after a completed first install', () => { // the CLAIM_CLIENTS handshake never ran and a first install sat behind the splash until the // stall watchdog fired a minute later. describe('no progress handler registered', () => { - const progress100 = JSON.stringify({ type: 'progress', data: { percent: 100, index: 1, asset: { url: 'a.js' } } }); - it('completes a first install by driving reload() itself', async () => { const installing = fakeWorker('installing'); const registration = { active: null, waiting: null, installing, addEventListener() { }, update: async () => { } }; diff --git a/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js b/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js index a38d20ebde..d2145cc2e6 100644 --- a/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js +++ b/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createPageContext, waitFor } from './harness.js'; +import { createPageContext, waitFor, fakeWorker } from './harness.js'; const SPLASH = { 'data-bit-bswup-config': 'true' }; @@ -374,21 +374,6 @@ describe('splash subtree replaced after initialization', () => { // hiding the splash. Every piece is tested elsewhere in isolation; a regression in the seam // between the two bundles would pass all of those and still ship a broken first install. describe('end-to-end first install through the built-in handler', () => { - function fakeWorker(state) { - const listeners = []; - return { - state, - posted: [], - postMessage(message) { this.posted.push(message); }, - addEventListener(type, fn) { if (type === 'statechange') listeners.push(fn); }, - removeEventListener(type, fn) { - const index = listeners.indexOf(fn); - if (index !== -1) listeners.splice(index, 1); - }, - fireStateChange() { listeners.slice().forEach(fn => fn({ currentTarget: this })); }, - }; - } - it('progress -> reload() -> CLAIM_CLIENTS -> CLIENTS_CLAIMED -> Blazor started, splash hidden', async () => { const installing = fakeWorker('installing'); const registration = { active: null, waiting: null, installing, addEventListener() { }, update: async () => { } }; From 8e970da47b0231a5253493d045c9739ebd557d8d Mon Sep 17 00:00:00 2001 From: msynk Date: Wed, 29 Jul 2026 10:33:50 +0330 Subject: [PATCH 45/50] resolve review comments 20 --- .../Bit.Bswup.Demo/Pages/PlaygroundPage.razor | 6 +- src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js | 57 ++++++++++++------- .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 37 ++++++------ src/Bswup/Demos/BasicDemo/wwwroot/index.html | 24 +++++--- .../FullDemo/Client/wwwroot/service-worker.js | 8 +-- .../Tests/bit-bswup-js/progress-ui.test.js | 8 ++- 6 files changed, 87 insertions(+), 53 deletions(-) diff --git a/src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor b/src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor index 9b89a6c65e..28cf3b57d9 100644 --- a/src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor +++ b/src/Bswup/Bit.Bswup.Demo/Pages/PlaygroundPage.razor @@ -21,7 +21,7 @@

        Service worker status

        -
        +
        Service worker
        This page
        Scope
        @@ -76,7 +76,9 @@ built-in progress UI via the Handler/data-bit-bswup-handler option (newest first, capped at 200 entries).

        -
          + @* aria-live pairs with demo.js rendering incrementally: new entries are prepended rather + than the list being rebuilt, so screen readers announce only what is new. *@ +
            diff --git a/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js b/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js index 1ee0394970..6345412789 100644 --- a/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js +++ b/src/Bswup/Bit.Bswup.Demo/wwwroot/demo.js @@ -5,6 +5,10 @@ const MAX_EVENTS = 200; const events = []; + // Total events ever recorded. renderEvents keys off this, not events.length: once the + // log reaches MAX_EVENTS its length stays pinned at the cap, and a length-based guard + // would treat every later event as "nothing changed" and freeze the UI. + let revision = 0; // ---------------------------------------------------------------- event log @@ -17,6 +21,7 @@ function record(type, detail) { events.unshift({ time: new Date(), type: type, detail: detail }); if (events.length > MAX_EVENTS) events.length = MAX_EVENTS; + revision++; renderEvents(); } @@ -45,36 +50,50 @@ } } + function renderEvent(evt) { + const li = document.createElement('li'); + const time = document.createElement('span'); + time.className = 'event-time'; + time.textContent = evt.time.toLocaleTimeString(); + const type = document.createElement('b'); + type.className = 'event-type'; + type.textContent = evt.type; + const detail = document.createElement('span'); + detail.className = 'event-detail'; + detail.textContent = evt.detail; + li.append(time, type, detail); + return li; + } + function renderEvents() { const el = document.getElementById('bswup-demo-events'); if (!el) return; - // Guard for the MutationObserver below: re-render only when the log actually grew, - // otherwise our own DOM writes would re-trigger the observer forever. - if (el.dataset.rendered === String(events.length)) return; - el.dataset.rendered = String(events.length); + // Guard for the MutationObserver below: re-render only when something new was + // recorded, otherwise our own DOM writes would re-trigger the observer forever. + // A missing dataset.rendered means a fresh element (Blazor replaced the subtree) + // that still needs a full render even when the revision itself is unchanged. + if (el.dataset.rendered === String(revision)) return; + const prior = el.dataset.rendered === undefined ? -1 : Number(el.dataset.rendered); + el.dataset.rendered = String(revision); - el.textContent = ''; if (events.length === 0) { + el.textContent = ''; const empty = document.createElement('li'); empty.className = 'event-empty'; empty.textContent = 'No events yet - Bswup raises events on install, update checks, downloads, and activation.'; el.append(empty); return; } - for (const evt of events) { - const li = document.createElement('li'); - const time = document.createElement('span'); - time.className = 'event-time'; - time.textContent = evt.time.toLocaleTimeString(); - const type = document.createElement('b'); - type.className = 'event-type'; - type.textContent = evt.type; - const detail = document.createElement('span'); - detail.className = 'event-detail'; - detail.textContent = evt.detail; - li.append(time, type, detail); - el.append(li); - } + + // Prepend only the entries recorded since this element was last rendered (they are + // the head of `events` - newest first). The list is an aria-live region, and a full + // clear-and-rebuild would make screen readers re-announce the entire log on every + // event; prepending keeps the announcement to just what is new. A fresh element or + // a gap wider than the cap falls back to rendering everything. + const fresh = prior < 0 || revision - prior >= events.length ? events.length : revision - prior; + if (fresh === events.length) el.textContent = ''; + for (let i = fresh - 1; i >= 0; i--) el.prepend(renderEvent(events[i])); + while (el.children.length > MAX_EVENTS) el.lastElementChild.remove(); } // ---------------------------------------------------------------- playground diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index d0fe6ac25b..386c8fbb39 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -341,10 +341,13 @@ // Some failures are deterministic - a plain reload re-fetches the // same broken bytes and fails identically. A manifest that won't // parse or an SRI/integrity mismatch needs a redeploy (or fixed - // 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', 'install-incomplete']; + // CDN/proxy), not a retry; 'install-infra' means CacheStorage + // itself is unusable (storage pressure, a broken private mode), so + // a reload runs into the same wall. 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', 'install-incomplete', 'install-infra']; // 'install-aborted' is deliberately absent: a strict abort is // usually triggered by a transient asset failure, and a reload // genuinely can succeed on the next attempt. @@ -397,16 +400,16 @@ // 'self') and runs regardless of how the host page is rendered. Calling start() manually // is still supported - the data-bit-bswup-initialized guard keeps the two from clashing. function autoStart() { - const el = document.getElementById('bit-bswup'); - if (!el || el.getAttribute('data-bit-bswup-config') !== 'true') return; - if (el.getAttribute('data-bit-bswup-initialized') === 'true') return; + const configEl = document.getElementById('bit-bswup'); + if (!configEl || configEl.getAttribute('data-bit-bswup-config') !== 'true') return; + if (configEl.getAttribute('data-bit-bswup-initialized') === 'true') return; const bool = (name: string, fallback: boolean) => { - const value = el.getAttribute(name); + const value = configEl.getAttribute(name); return value == null ? fallback : value === 'true'; }; - const handlerAttr = el.getAttribute('data-bit-bswup-handler'); + const handlerAttr = configEl.getAttribute('data-bit-bswup-handler'); start( // The fallback only applies to hand-written config markup that omits the @@ -415,7 +418,7 @@ bool('data-bit-bswup-auto-reload', false), bool('data-bit-bswup-show-logs', false), bool('data-bit-bswup-show-assets', false), - el.getAttribute('data-bit-bswup-app-container') || '#app', + configEl.getAttribute('data-bit-bswup-app-container') || '#app', bool('data-bit-bswup-hide-app', false), bool('data-bit-bswup-auto-hide', false), handlerAttr || undefined @@ -467,31 +470,31 @@ }; observer = new MutationObserver(() => { - const el = document.getElementById('bit-bswup'); + const configEl = document.getElementById('bit-bswup'); // Not rendered yet - this is the one case where we keep waiting. - if (!el) return; + if (!configEl) return; - if (el.getAttribute('data-bit-bswup-initialized') === 'true') return stopObserving(); + if (configEl.getAttribute('data-bit-bswup-initialized') === 'true') return stopObserving(); // An #bit-bswup that carries no config attributes is markup we don't own - a // hand-written splash driven by an explicit BitBswupProgress.start(...) call. // autoStart() declines it by design and always will, so there is nothing left to // watch for; staying attached would just burn a callback on every DOM mutation. - if (el.getAttribute('data-bit-bswup-config') !== 'true') return stopObserving(); + if (configEl.getAttribute('data-bit-bswup-config') !== 'true') return stopObserving(); autoStart(); // Stop once initialization took hold. If start() threw, the flag is still unset and // we stay attached so the next mutation can retry. - if (el.getAttribute('data-bit-bswup-initialized') === 'true') stopObserving(); + if (configEl.getAttribute('data-bit-bswup-initialized') === 'true') stopObserving(); }); const startObserving = () => { // autoStart() may have already succeeded on the DOMContentLoaded/immediate path, in // which case there is nothing to observe for in the first place. - const el = document.getElementById('bit-bswup'); - if (el && el.getAttribute('data-bit-bswup-initialized') === 'true') return stopObserving(); + const configEl = document.getElementById('bit-bswup'); + if (configEl && configEl.getAttribute('data-bit-bswup-initialized') === 'true') return stopObserving(); observer?.observe(document.documentElement, { childList: true, subtree: true }); timeoutId = setTimeout(stopObserving, OBSERVE_TIMEOUT); diff --git a/src/Bswup/Demos/BasicDemo/wwwroot/index.html b/src/Bswup/Demos/BasicDemo/wwwroot/index.html index bf457c296f..710cbf2ee4 100644 --- a/src/Bswup/Demos/BasicDemo/wwwroot/index.html +++ b/src/Bswup/Demos/BasicDemo/wwwroot/index.html @@ -129,18 +129,26 @@ case BswupMessage.downloadFinished: if (data.firstInstall) { descriptionLabel.textContent = 'Downloading finished, starting Blazor...'; - data.reload().then(() => { - appEl.style.display = 'block'; - bswupEl.style.display = 'none'; - }).catch(() => { - // Same recovery the built-in progress UI performs: a rejected - // reload leaves the splash up with a manual retry wired back to - // data.reload, instead of freezing on "starting Blazor..." with - // no way forward. + // Same recovery the built-in progress UI performs: a reload that fails + // leaves the splash up with a manual retry wired back to data.reload, + // instead of freezing on "starting Blazor..." with no way forward. The + // timer covers the stall case the .catch cannot: reload() never settles + // when the activation handshake goes missing, so a grace period is the + // only signal left (see autoReloadWithFallback in bit-bswup.progress.ts). + const showRetry = () => { descriptionLabel.textContent = 'Starting failed, click the button to retry.'; bswupEl.style.display = 'block'; reloadButton.style.display = 'block'; reloadButton.onclick = data.reload; + }; + const fallbackTimer = setTimeout(showRetry, 10000); + data.reload().then(() => { + clearTimeout(fallbackTimer); + appEl.style.display = 'block'; + bswupEl.style.display = 'none'; + }).catch(() => { + clearTimeout(fallbackTimer); + showRetry(); }); } else { descriptionLabel.textContent = 'Downloading finished, ready to reload.'; diff --git a/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.js b/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.js index ef37587955..8662f1cfa5 100644 --- a/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.js +++ b/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.js @@ -1,9 +1,9 @@ // bit version: 10.5.0 -// In development, always fetch from the network and do not enable offline support. -// This is because caching would make development more difficult (changes would not -// be reflected on the first load after each change). -//self.addEventListener('fetch', () => { }); +// Development service worker of the FullDemo. Unlike the standard Blazor template - whose +// dev worker is a no-op so caching never hides source changes - this demo runs the full +// Bswup engine even in development (see the importScripts at the bottom), so the complete +// first-install / update experience is exercised on plain F5, matching the other demos. self.assetsInclude = []; // The client's scoped-css bundle is in this app's asset manifest but is never served: in a diff --git a/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js b/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js index d2145cc2e6..4880e23d22 100644 --- a/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js +++ b/src/Bswup/Tests/bit-bswup-js/progress-ui.test.js @@ -165,9 +165,11 @@ describe('error handling', () => { }); it('hides Retry for deterministic failures that a reload cannot fix', () => { - const ctx = progressPage({ elements: fullSplash }); - ctx.window.bitBswupHandler('ERROR', { reason: 'manifest', message: 'malformed', fatal: true }); - expect(ctx.elements['bit-bswup-error-retry'].style.display).toBe('none'); + for (const reason of ['manifest', 'integrity', 'install-incomplete', 'install-infra']) { + const ctx = progressPage({ elements: fullSplash }); + ctx.window.bitBswupHandler('ERROR', { reason, message: 'not retriable', fatal: true }); + expect(ctx.elements['bit-bswup-error-retry'].style.display).toBe('none'); + } }); it('offers Retry for a transient failure', () => { From 03525eb2d3c3e9c51e1d5223da6af6fc0a573de1 Mon Sep 17 00:00:00 2001 From: msynk Date: Wed, 29 Jul 2026 11:38:10 +0330 Subject: [PATCH 46/50] resolve review comments 21 --- src/Bswup/Bit.Bswup/BswupProgress.razor | 2 +- .../Bit.Bswup/Scripts/bit-bswup.progress.ts | 2 +- src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts | 32 +++++++++++-------- .../Client/Shared/DemoBswupProgressBar.razor | 2 +- .../FullDemo/Client/wwwroot/service-worker.js | 2 +- .../wwwroot/service-worker.published.js | 2 +- src/Bswup/README.md | 13 ++++---- src/Bswup/Tests/bit-bswup-js/harness.js | 2 +- .../Tests/bit-bswup-js/page-script.test.js | 2 +- .../Tests/bit-bswup-js/progress-ui.test.js | 2 +- .../Tests/bit-bswup-js/service-worker.test.js | 2 +- .../bit-bswup-js/version-consistency.test.js | 4 +-- 12 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/Bswup/Bit.Bswup/BswupProgress.razor b/src/Bswup/Bit.Bswup/BswupProgress.razor index 1907b2d5e8..35c432f0b0 100644 --- a/src/Bswup/Bit.Bswup/BswupProgress.razor +++ b/src/Bswup/Bit.Bswup/BswupProgress.razor @@ -8,7 +8,7 @@ // behind for a while - and it matches the prompt-then-reload pattern of the standard // Blazor template and this package's own README sample. First installs are unaffected: // they always complete the claim-and-start flow (no reload) regardless of this setting. - // CHANGED in v-10-5-0 - previous versions defaulted to true; set AutoReload="true" + // CHANGED in v-10-6-0 - previous versions defaulted to true; set AutoReload="true" // explicitly to keep the old behavior. [Parameter] public bool AutoReload { get; set; } = false; [Parameter] public bool ShowLogs { get; set; } = false; diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts index 386c8fbb39..96ff24066e 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts @@ -414,7 +414,7 @@ start( // The fallback only applies to hand-written config markup that omits the // attribute (the Razor component always renders it); it must track the - // component's AutoReload default - false since v-10-5-0, see BswupProgress.razor. + // component's AutoReload default - false since v-10-6-0, see BswupProgress.razor. bool('data-bit-bswup-auto-reload', false), bool('data-bit-bswup-show-logs', false), bool('data-bit-bswup-show-assets', false), diff --git a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts index c0aac6026b..99f6067615 100644 --- a/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts +++ b/src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts @@ -52,11 +52,15 @@ interface BitBswupGlobals { interface WorkerGlobalScope extends BitBswupGlobals { } interface Window extends BitBswupGlobals { } -// 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 { +// Minimal shape of the ExtendableEvent / FetchEvent surface we use, so the +// install/activate/fetch/message handlers can call waitUntil()/respondWith() and read +// request without DOM lib types. A dedicated interface rather than an augmentation of the +// global Event: this file is compiled together with bit-bswup.sw-cleanup.ts +// (tsconfig.sw.json), and augmenting Event would silently type every event over there too. +interface BswupExtendableEvent extends Event { waitUntil: any respondWith: any + request: any } diagGroup('bit-bswup'); @@ -64,7 +68,7 @@ diagGroup('bit-bswup'); // Resolved by importScripts against the worker's own location - the directory containing the // app's service-worker.js, which is also where Blazor publishes service-worker-assets.js - so // the relative default works for root-mounted apps and apps mounted on a sub-path -// (https://host/myapp/) alike. Versions before v-10-5-0 defaulted to the root-absolute +// (https://host/myapp/) alike. Versions before v-10-6-0 defaulted to the root-absolute // '/service-worker-assets.js', which pointed a sub-path app at the wrong path and failed its // install with a 'manifest' error unless assetsUrl was set explicitly - even though the page // script goes out of its way (the scope-fallback retry) to keep sub-path apps working. @@ -131,7 +135,7 @@ const CACHE_VERSION = (typeof self.cacheVersion === 'string' && self.cacheVersio // Cache names are qualified with this registration's scope path so that sibling Bswup apps // mounted under different scopes on the SAME origin get disjoint cache namespaces. -// CacheStorage is origin-global, and the pre v-10-5-0 name (`bit-bswup - `) carried +// CacheStorage is origin-global, and the pre v-10-6-0 name (`bit-bswup - `) carried // no scope: every app's deleteOldCaches() saw a sibling's bucket as "a stale version of // mine" and deleted it, so two Bswup apps on one origin perpetually evicted each other's // offline caches on every update. The scope path is the natural qualifier - it is exactly @@ -234,14 +238,14 @@ diag('MAX_RETRIES:', MAX_RETRIES, 'RETRY_DELAY:', RETRY_DELAY); // 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('install', (e: BswupExtendableEvent) => e.waitUntil(handleInstall(e))); +self.addEventListener('activate', (e: BswupExtendableEvent) => e.waitUntil(handleActivate(e))); // handleFetch decides *synchronously* whether to call respondWith, so requests Bswup does not // manage stay on the browser's own network path (see the comment on handleFetch). self.addEventListener('fetch', handleFetch); self.addEventListener('message', handleMessage); -async function handleInstall(e: any) { +async function handleInstall(e: BswupExtendableEvent) { diag('installing version:', VERSION); // Diagnostics-only storage telemetry: quota pressure is the root cause behind most @@ -298,7 +302,7 @@ async function handleInstall(e: any) { // Lax: asset failures never fail the install - createAssetsCache resolves even when // assets were skipped (they lazy-fill in handleFetch) - but the download itself must // still run under the install event's lifetime (handleInstall IS the waitUntil - // promise, so awaiting here extends it). Versions before v-10-5-0 fired the cache build + // promise, so awaiting here extends it). Versions before v-10-6-0 fired the cache build // unawaited so the install settled immediately, which had two nasty consequences: // - Nothing kept the worker alive. Browsers terminate an event-idle service worker // after ~30s (fetches/timers the worker itself starts do not extend its @@ -343,7 +347,7 @@ function reportUnreportedInstallFailure(err: any) { }); } -async function handleActivate(e: any) { +async function handleActivate(e: BswupExtendableEvent) { diag('activate version:', VERSION); sendMessage({ type: 'activate', data: { version: VERSION, isPassive: self.isPassive } }); @@ -529,14 +533,14 @@ diagGroupEnd(); // for anything we do not manage we simply return without calling respondWith - leaving the // browser to do exactly what it would have done with no service worker installed. Only the // managed path calls respondWith, and it is handed serveAsset(), which never rejects. -function handleFetch(e: any) { +function handleFetch(e: BswupExtendableEvent) { const req = e.request as Request; if (PROHIBITED_URLS.some(pattern => pattern.test(req.url))) { diagFetch('+++ handleFetch ended - prohibited:', e, req); // 403 Forbidden: the request was understood and is being refused. Versions before - // v-10-5-0 answered 405 Method Not Allowed, which is wrong on both counts - it says the + // v-10-6-0 answered 405 Method Not Allowed, which is wrong on both counts - it says the // *method* is unsupported for this resource (the URL is blocked here regardless of // method) and RFC 9110 requires a 405 to carry an Allow header listing the permitted // methods, which there is no meaningful value for. Code that detects a Bswup-blocked @@ -960,7 +964,7 @@ async function tryCacheMatch(cache: any, url: string) { // 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) { +function handleMessage(e: MessageEvent & BswupExtendableEvent) { diag('handleMessage:', e); // Trust model: a service worker can only receive postMessage from clients on its own @@ -1794,7 +1798,7 @@ function urlToRegExp(url: string) { // true }) returns every same-origin window client, including pages of unrelated apps mounted // under other scopes on the same host - and only clients under this scope can be controlled // by this worker or depend on its caches. When the scope is unavailable (self.registration -// missing in an exotic runtime) fall back to no filtering, which is the pre v-10-5-0 behavior. +// missing in an exotic runtime) fall back to no filtering, which is the pre v-10-6-0 behavior. // The cleanup worker (bit-bswup.sw-cleanup.ts) applies the same filter for the same reason. async function matchScopeClients() { const scope = self.registration && self.registration.scope; diff --git a/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor b/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor index 7e7064074b..3190246cf6 100644 --- a/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor +++ b/src/Bswup/Demos/FullDemo/Client/Shared/DemoBswupProgressBar.razor @@ -25,7 +25,7 @@ - @* No hand-written #bit-bswup-reload here: since v-10-5-0 BswupProgress always renders + @* No hand-written #bit-bswup-reload here: since v-10-6-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/Demos/FullDemo/Client/wwwroot/service-worker.js b/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.js index 8662f1cfa5..eb6c07a78b 100644 --- a/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.js +++ b/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.js @@ -13,7 +13,7 @@ self.assetsInclude = []; self.assetsExclude = [/^Bit\.Bswup\.FullDemo\.Client\.styles\.css$/, /weather\.json$/]; self.defaultUrl = '/'; self.prohibitedUrls = []; -// self.assetsUrl is deliberately NOT set: since v-10-5-0 it defaults to a relative +// self.assetsUrl is deliberately NOT set: since v-10-6-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. diff --git a/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.published.js b/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.published.js index 338b233a6a..37488c3140 100644 --- a/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.published.js +++ b/src/Bswup/Demos/FullDemo/Client/wwwroot/service-worker.published.js @@ -8,7 +8,7 @@ self.assetsInclude = []; self.assetsExclude = [/^Bit\.Bswup\.FullDemo\.Client\.styles\.css$/, /weather\.json$/]; self.defaultUrl = "/"; self.prohibitedUrls = []; -// self.assetsUrl is deliberately NOT set: since v-10-5-0 it defaults to a relative +// self.assetsUrl is deliberately NOT set: since v-10-6-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 = [ diff --git a/src/Bswup/README.md b/src/Bswup/README.md index 5149dc8be9..53a12cbf06 100644 --- a/src/Bswup/README.md +++ b/src/Bswup/README.md @@ -45,7 +45,7 @@ app.UseStaticFiles(new StaticFileOptions updateOnVisibility="true"> ``` -- `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. +- `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-6-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. 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. @@ -208,8 +208,9 @@ self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js'); ``` > **Security note - the service worker is part of your trusted base.** Unlike the assets in -> `service-worker-assets.js` (which Bswup verifies with Subresource Integrity), the -> service-worker script itself cannot be integrity-pinned: browsers do not support an +> `service-worker-assets.js` (which Bswup can verify with Subresource Integrity when the +> opt-in `enableIntegrityCheck` setting is enabled), the service-worker script itself cannot +> be integrity-pinned: browsers do not support an > `integrity` option on `navigator.serviceWorker.register()`, and `importScripts()` has no > SRI mechanism either. This is not Bswup-specific - Workbox and every other SW library share > the limitation - but it matters because a service worker can intercept every request, so a @@ -244,9 +245,9 @@ 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": "/" }`. 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. If your host answers the shell URL with a redirect (for example `/` → `/index.html`, common on Cloudflare Pages, Netlify, and some reverse proxies), Bswup rebuilds that response before serving it to a navigation so the browser does not reject the followed redirect with *"a redirected response was used for a request whose redirect mode is not follow"* - offline deep-link navigation keeps working regardless. +- `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-6-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. If your host answers the shell URL with a redirect (for example `/` → `/index.html`, common on Cloudflare Pages, Netlify, and some reverse proxies), Bswup rebuilds that response before serving it to a navigation so the browser does not reject the followed redirect with *"a redirected response was used for a request whose redirect mode is not follow"* - offline deep-link navigation keeps working regardless. - `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. +- `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-6-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. - `serverHandledUrls`: The list of URLs that do not enter the service-worker offline process and will be handled only by server (regex supported). such as `/api`, `/swagger`, ... - `serverRenderedUrls`: The list of URLs that should be rendered by the server and not client while navigating (regex supported). such as `/about.html`, `/privacy`, ... @@ -303,7 +304,7 @@ Instead of writing the step-5 handler yourself, you can use the built-in splash/ Component parameters (each maps to a `data-bit-bswup-*` attribute the script reads at load - the component emits no inline ` - + @* The component renders first so its data-bit-bswup-* configuration is already in the + document when the script initializes. Rendered after the script it still works - the + script's MutationObserver picks up a late element - but that is the fallback path. *@ + diff --git a/src/Bswup/Tests/bit-bswup-js/service-worker.test.js b/src/Bswup/Tests/bit-bswup-js/service-worker.test.js index 5f651683de..6ab9049299 100644 --- a/src/Bswup/Tests/bit-bswup-js/service-worker.test.js +++ b/src/Bswup/Tests/bit-bswup-js/service-worker.test.js @@ -70,6 +70,9 @@ describe('errorTolerance', () => { }); expect(sw.self.errorTolerance).toBe('lax'); await expect(sw.fire('install')).resolves.not.toThrow(); + // The per-asset error dispatch is not awaited by the install promise (sendError goes + // through clients.matchAll()), so drain it rather than leave it floating past the test. + await sw.settle(); }); it('reports a non-fatal error under lax so the UI does not show install-failed', async () => {