[playback] Harden service-worker rollout and rollback guardrails

This commit is contained in:
thePR0M3TH3AN committed 2026-02-10 00:56:33 -05:00
1 parent 1cf0e2aacd
commit eeb2db9037
9 files changed
+210 -8

No files matched your search

+11
View File
@@ -356,6 +356,17 @@ This split is required for safe fast rollouts: HTML needs rapid update pickup,
while fingerprinted static assets should remain aggressively cacheable for
performance and cost control.
#### Service worker rollback playbook
If a deploy regresses playback and you suspect `sw.min.js`, use this rollback runbook:
1. **Freeze impact**: redeploy the last known-good commit so clients fetch the previous worker script immediately.
2. **Invalidate active workers**: from DevTools > Application > Service Workers, click **Unregister** for `sw.min.js` and hard reload once to verify playback without stale worker state.
3. **Confirm claim flow**: in the browser console, confirm worker lifecycle messages (`BITVID_SW_LIFECYCLE`) appear and that requests to `/webtorrent/*` carry the `X-Bitvid-SW-Version` response header.
4. **Check compatibility guardrails**: run `npm run lint:sw-compat` before shipping the fix. This fails deploys when service-worker runtime paths/scope changes are not accompanied by `_headers`, `index.html`, and `embed.html` updates in the same commit.
5. **Redeploy + monitor**: publish the fix, then replay the URL-first fallback QA script in `docs/qa.md` and capture logs/screenshots in the PR.
Inline styles are intentionally blocked. `npm run lint:inline-styles` scans HTML
and scripts for `style=` attributes, `element.style`, or `style.cssText` usage
and will fail CI until offending markup is moved into the shared CSS/token
+1
View File
@@ -18,6 +18,7 @@
/sw.min.js
Cache-Control: public, max-age=0, must-revalidate
X-Bitvid-SW-Version: bitvid-sw-2026-02-10
/site.webmanifest
Cache-Control: public, max-age=0, must-revalidate
+1
View File
@@ -9,6 +9,7 @@
<meta name="robots" content="noindex,follow" />
<meta name="theme-color" content="var(--color-page)" />
<meta name="bitvid-sw-version" content="bitvid-sw-2026-02-10" />
<script>
(() => {
try {
+1
View File
@@ -47,6 +47,7 @@
hex values scattered across templates.
-->
<meta name="theme-color" content="var(--color-page)" />
<meta name="bitvid-sw-version" content="bitvid-sw-2026-02-10" />
<script>
(() => {
try {
+69 -5
View File
@@ -25,6 +25,22 @@ import { devLogger, userLogger } from "./utils/logger.js";
import { emit } from "./embedDiagnostics.js";
const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]);
const SERVICE_WORKER_PATH = "/sw.min.js";
const SERVICE_WORKER_SCOPE = "/";
function compareServiceWorkerScripts(a = "", b = "") {
if (a === b) {
return true;
}
try {
const aUrl = new URL(a, window.location.origin);
const bUrl = new URL(b, window.location.origin);
return aUrl.pathname === bUrl.pathname;
} catch {
return false;
}
}
function normalizeTrackerList(trackers) {
const normalized = [];
@@ -161,6 +177,7 @@ export class TorrentClient {
// Timeout for SW operations
this.TIMEOUT_DURATION = 60000;
this.swLifecycleListenerAttached = false;
}
ensureClientForProbe() {
@@ -467,6 +484,46 @@ export class TorrentClient {
});
}
attachServiceWorkerLifecycleListener() {
if (
this.swLifecycleListenerAttached ||
!("serviceWorker" in navigator) ||
!navigator.serviceWorker
) {
return;
}
navigator.serviceWorker.addEventListener("message", (event) => {
const payload = event?.data;
if (payload?.type !== "BITVID_SW_LIFECYCLE") {
return;
}
this.log("[WebTorrent] Service worker lifecycle:", payload);
});
this.swLifecycleListenerAttached = true;
}
async activateWaitingWorker(registration) {
if (!registration?.waiting) {
return false;
}
const waitingWorker = registration.waiting;
const activeScript = registration.active?.scriptURL || "";
const waitingScript = waitingWorker.scriptURL || "";
if (compareServiceWorkerScripts(activeScript, waitingScript)) {
this.log("Activating waiting service worker", {
activeScript,
waitingScript,
});
}
waitingWorker.postMessage({ type: "SKIP_WAITING" });
await this.waitForActiveController(registration);
return true;
}
/**
* Ensure a live service worker is actively controlling the page before we
* start WebTorrent streaming.
@@ -589,6 +646,8 @@ export class TorrentClient {
throw new Error("Service Worker not supported or disabled");
}
this.attachServiceWorkerLifecycleListener();
// Brave-specific logic: Brave Shields has a long-standing bug where
// stale service worker registrations linger even after we ship fixes.
// When that happens, the outdated worker keeps intercepting requests
@@ -617,11 +676,11 @@ export class TorrentClient {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
this.log("Registering service worker at /sw.min.js...");
this.log(`Registering service worker at ${SERVICE_WORKER_PATH}...`);
const registration = await navigator.serviceWorker.register(
"/sw.min.js",
SERVICE_WORKER_PATH,
{
scope: "/",
scope: SERVICE_WORKER_SCOPE,
updateViaCache: "none",
}
);
@@ -648,6 +707,9 @@ export class TorrentClient {
});
}
await registration.update();
await this.activateWaitingWorker(registration);
await this.waitForServiceWorkerActivation(registration);
this.log("Service worker activated");
@@ -675,8 +737,10 @@ export class TorrentClient {
// newly installed worker claims the page before WebTorrent spins up.
await this.waitForActiveController(registration);
// Force the SW to check for updates
registration.update();
// Force the SW to check for updates and immediately claim if a waiting
// worker appeared after activation.
await registration.update();
await this.activateWaitingWorker(registration);
this.log("Service worker ready");
return registration;
+3 -2
View File
@@ -16,7 +16,7 @@
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
"audit": "node scripts/daily-design-system-audit.mjs",
"lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml && npm run lint:assets",
"lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml && npm run lint:assets && npm run lint:sw-compat",
"lint:file-size": "node scripts/check-file-size.mjs",
"lint:innerhtml": "node scripts/check-innerhtml.mjs",
"lint:css": "stylelint css/tailwind.source.css css/tokens.css css/docs.css",
@@ -38,7 +38,8 @@
"test:unit:shard2": "cross-env UNIT_TEST_SHARD=2/3 node scripts/run-unit-tests.mjs",
"test:unit:shard3": "cross-env UNIT_TEST_SHARD=3/3 node scripts/run-unit-tests.mjs",
"telemetry:aggregate": "node scripts/agent/telemetry-aggregator.mjs",
"lint:assets": "node scripts/check-asset-references.mjs"
"lint:assets": "node scripts/check-asset-references.mjs",
"lint:sw-compat": "node scripts/check-sw-compat.mjs"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
+8
View File
@@ -155,6 +155,14 @@ function rewriteEntryHtmlAssetPaths(manifest) {
}
function main() {
console.log('Validating service worker compatibility guard...');
try {
execSync('npm run lint:sw-compat', { stdio: 'inherit' });
} catch (error) {
console.error('Failed service worker compatibility guard.');
process.exit(1);
}
console.log('Cleaning dist...');
cleanDist();
+90
View File
@@ -0,0 +1,90 @@
import { execSync } from 'node:child_process';
const ENTRY_HTML_FILES = ['index.html', 'embed.html'];
const REQUIRED_COMPANION_FILES = ['_headers', ...ENTRY_HTML_FILES];
const CRITICAL_FILES = ['sw.min.js', 'js/webtorrent.js'];
const CRITICAL_PATH_PATTERNS = [
'/webtorrent/',
'/sw.min.js',
'scope: "/"',
'scope:"/"',
'SERVICE_WORKER_PATH',
'SERVICE_WORKER_SCOPE'
];
function getChangedFilesAgainstHeadParent() {
const hasHeadParent = execSync('git rev-parse --verify --quiet HEAD^', {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim();
if (!hasHeadParent) {
return [];
}
const output = execSync('git diff-tree --no-commit-id --name-only -r HEAD', {
encoding: 'utf8'
}).trim();
if (!output) {
return [];
}
return output.split('\n').map((value) => value.trim()).filter(Boolean);
}
function getCriticalPathDiff() {
const output = execSync(
`git diff --unified=0 HEAD^ HEAD -- ${CRITICAL_FILES.join(' ')}`,
{ encoding: 'utf8' }
);
return output
.split('\n')
.map((line) => line.trim())
.filter((line) => (line.startsWith('+') || line.startsWith('-')) && !line.startsWith('+++') && !line.startsWith('---'));
}
function hasCriticalPathChange(diffLines) {
return diffLines.some((line) =>
CRITICAL_PATH_PATTERNS.some((pattern) => line.includes(pattern))
);
}
function main() {
const changedFiles = getChangedFilesAgainstHeadParent();
if (!changedFiles.length) {
console.log('[sw-compat] No commit delta found; skipping compatibility check.');
return;
}
const touchedCriticalFile = changedFiles.some((file) => CRITICAL_FILES.includes(file));
if (!touchedCriticalFile) {
console.log('[sw-compat] No service worker files changed; compatibility check passed.');
return;
}
const diffLines = getCriticalPathDiff();
const criticalPathChanged = hasCriticalPathChange(diffLines);
if (!criticalPathChanged) {
console.log('[sw-compat] Service worker changes did not alter critical runtime paths.');
return;
}
const missingCompanionFiles = REQUIRED_COMPANION_FILES.filter(
(file) => !changedFiles.includes(file)
);
if (missingCompanionFiles.length > 0) {
const messageLines = [
'[sw-compat] Detected service worker scope/runtime path changes.',
'[sw-compat] Update deployment compatibility files in the same commit:',
...missingCompanionFiles.map((file) => ` - ${file}`),
'[sw-compat] Required companion files: _headers, index.html, embed.html'
];
throw new Error(messageLines.join('\n'));
}
console.log('[sw-compat] Critical service worker path updates include required companion files.');
}
main();
Vendored
+26 -1
View File
@@ -1,8 +1,29 @@
(() => {
"use strict";
const SW_VERSION = "bitvid-sw-2026-02-10";
const SW_METADATA = Object.freeze({
version: SW_VERSION,
runtimePathPrefix: "/webtorrent/",
});
let cancelled = false;
function broadcastLifecycle(eventType, details = {}) {
return clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((windowClients) => {
windowClients.forEach((client) => {
client.postMessage({
type: "BITVID_SW_LIFECYCLE",
eventType,
metadata: SW_METADATA,
...details,
});
});
});
}
// Handle messages from clients
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
@@ -22,7 +43,7 @@
// Immediately install and skip waiting
self.addEventListener("install", (event) => {
self.skipWaiting();
event.waitUntil(Promise.all([self.skipWaiting(), broadcastLifecycle("install")]));
});
// Claim clients on activation and clear caches
@@ -31,6 +52,9 @@
Promise.all([
clients.claim(),
self.skipWaiting(),
broadcastLifecycle("activate", {
scope: self.registration?.scope || null,
}),
caches
.keys()
.then((cacheNames) =>
@@ -115,6 +139,7 @@
);
responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0");
responseHeaders.set("X-Bitvid-SW-Version", SW_VERSION);
// 3e) If the response is not a streaming request, return it directly
if (clientResponse.body !== "STREAM") {