Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
825d8af
apply Bswup improvements #12408
msynk May 31, 2026
c646784
further improvements
msynk Jun 1, 2026
733a05a
resolve review comments
msynk Jun 2, 2026
de9c330
resolve review comments II
msynk Jun 4, 2026
23b29b3
resolve review comments III
msynk Jun 5, 2026
088c6d1
resolve review comments IV
msynk Jun 6, 2026
c161919
Merge branch 'develop' into 12408-bswup-improvements
msynk Jun 6, 2026
8c11506
resovle review comments V
msynk Jun 6, 2026
ea3d330
resolve review comments VI
msynk Jun 6, 2026
a71d04f
remove json serializer
msynk Jun 6, 2026
ddd6829
fix progress component
msynk Jun 6, 2026
9d73fbf
resolve local review findimgs
msynk Jun 14, 2026
6c96a71
Merge branch '12408-bswup-improvements' of https://github.com/msynk/b…
msynk Jun 14, 2026
ce3cd61
Merge branch 'develop' into 12408-bswup-improvements
msynk Jun 16, 2026
5377236
resolve review comments V
msynk Jun 21, 2026
a450c91
Merge branch 'develop' into 12408-bswup-improvements
msynk Jun 21, 2026
d6f1bd1
resolve review comments VI
msynk Jun 22, 2026
bd800d8
resolve review comments VII
msynk Jun 27, 2026
3623e25
Merge branch 'develop' into 12408-bswup-improvements
msynk Jun 27, 2026
754eb63
resolve review comments VIII
msynk Jun 27, 2026
70d385e
fix build issues
msynk Jun 28, 2026
a1ee5c5
resolve review comments IX
msynk Jun 28, 2026
f92cdaa
resolve review comments X
msynk Jun 28, 2026
20addce
resolve review comments XI
msynk Jun 28, 2026
73401e9
fix variable definition issue
msynk Jun 28, 2026
7721833
restructure demo projects
msynk Jun 29, 2026
3e6197a
improve FullDemo
msynk Jun 30, 2026
021176a
Merge branch 'develop' into 12408-bswup-improvements
msynk Jun 30, 2026
571f537
resolve review comments XII
msynk Jul 3, 2026
968fc99
resolve review comments XIII
msynk Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Bswup/Bit.Bswup.Demo/wwwroot/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
6 changes: 6 additions & 0 deletions src/Bswup/Bit.Bswup/BswupProgress.razor
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
</div>
<p id="bit-bswup-percent">0 %</p>
<ul id="bit-bswup-assets" style="display: @(ShowAssets ? "block" : "none");"></ul>
<div id="bit-bswup-error" class="bit-bswup-error" style="display: none;" role="alert">
<p class="bit-bswup-error-title">Update failed to install</p>
<p id="bit-bswup-error-message" class="bit-bswup-error-message"></p>
<pre id="bit-bswup-error-details" class="bit-bswup-error-details"></pre>
<button id="bit-bswup-error-retry" type="button">Retry</button>
Comment thread
msynk marked this conversation as resolved.
</div>
</div>
<button id="bit-bswup-reload">Update ready to install!</button>
}
Expand Down
34 changes: 34 additions & 0 deletions src/Bswup/Bit.Bswup/Scripts/bit-bswup.progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');

Comment thread
msynk marked this conversation as resolved.
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;
}
}
}
Expand Down
209 changes: 199 additions & 10 deletions src/Bswup/Bit.Bswup/Scripts/bit-bswup.sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface Window {
disableHashlessAssetsUpdate: any
forcePrerender: any
enableCacheControl: any
cacheVersion: any

mode: any
}
Expand All @@ -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';
Comment thread
msynk marked this conversation as resolved.
Outdated
const CACHE_NAME_PREFIX = 'bit-bswup';
Comment thread
msynk marked this conversation as resolved.
Comment thread
msynk marked this conversation as resolved.
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}`;
Comment thread
msynk marked this conversation as resolved.

let integrityFailureCount = 0;

switch (self.mode) {
case 'NoPrerender': // like adminpanel
Expand Down Expand Up @@ -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)));
Expand All @@ -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();
}
Comment thread
msynk marked this conversation as resolved.
}

async function handleActivate(e: any) {
Expand Down Expand Up @@ -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' }
});
Comment thread
msynk marked this conversation as resolved.
}

const isServerHandled = SERVER_HANDLED_URLS.some(pattern => pattern.test(req.url));
Expand Down Expand Up @@ -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);
Expand All @@ -222,13 +276,26 @@ function handleMessage(e: MessageEvent<string>) {
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') {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -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,
});
Comment thread
msynk marked this conversation as resolved.
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);
}
Expand Down Expand Up @@ -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]) : [];

Expand All @@ -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) {
Expand Down
Loading
Loading