[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 while fingerprinted static assets should remain aggressively cacheable for
performance and cost control. 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 Inline styles are intentionally blocked. `npm run lint:inline-styles` scans HTML
and scripts for `style=` attributes, `element.style`, or `style.cssText` usage 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 and will fail CI until offending markup is moved into the shared CSS/token
+1
View File
@@ -18,6 +18,7 @@
/sw.min.js /sw.min.js
Cache-Control: public, max-age=0, must-revalidate Cache-Control: public, max-age=0, must-revalidate
X-Bitvid-SW-Version: bitvid-sw-2026-02-10
/site.webmanifest /site.webmanifest
Cache-Control: public, max-age=0, must-revalidate Cache-Control: public, max-age=0, must-revalidate
+1
View File
@@ -9,6 +9,7 @@
<meta name="robots" content="noindex,follow" /> <meta name="robots" content="noindex,follow" />
<meta name="theme-color" content="var(--color-page)" /> <meta name="theme-color" content="var(--color-page)" />
<meta name="bitvid-sw-version" content="bitvid-sw-2026-02-10" />
<script> <script>
(() => { (() => {
try { try {
+1
View File
@@ -47,6 +47,7 @@
hex values scattered across templates. hex values scattered across templates.
--> -->
<meta name="theme-color" content="var(--color-page)" /> <meta name="theme-color" content="var(--color-page)" />
<meta name="bitvid-sw-version" content="bitvid-sw-2026-02-10" />
<script> <script>
(() => { (() => {
try { try {
+69 -5
View File
@@ -25,6 +25,22 @@ import { devLogger, userLogger } from "./utils/logger.js";
import { emit } from "./embedDiagnostics.js"; import { emit } from "./embedDiagnostics.js";
const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); 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) { function normalizeTrackerList(trackers) {
const normalized = []; const normalized = [];
@@ -161,6 +177,7 @@ export class TorrentClient {
// Timeout for SW operations // Timeout for SW operations
this.TIMEOUT_DURATION = 60000; this.TIMEOUT_DURATION = 60000;
this.swLifecycleListenerAttached = false;
} }
ensureClientForProbe() { 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 * Ensure a live service worker is actively controlling the page before we
* start WebTorrent streaming. * start WebTorrent streaming.
@@ -589,6 +646,8 @@ export class TorrentClient {
throw new Error("Service Worker not supported or disabled"); throw new Error("Service Worker not supported or disabled");
} }
this.attachServiceWorkerLifecycleListener();
// Brave-specific logic: Brave Shields has a long-standing bug where // Brave-specific logic: Brave Shields has a long-standing bug where
// stale service worker registrations linger even after we ship fixes. // stale service worker registrations linger even after we ship fixes.
// When that happens, the outdated worker keeps intercepting requests // When that happens, the outdated worker keeps intercepting requests
@@ -617,11 +676,11 @@ export class TorrentClient {
await new Promise((resolve) => setTimeout(resolve, 1000)); 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( const registration = await navigator.serviceWorker.register(
"/sw.min.js", SERVICE_WORKER_PATH,
{ {
scope: "/", scope: SERVICE_WORKER_SCOPE,
updateViaCache: "none", updateViaCache: "none",
} }
); );
@@ -648,6 +707,9 @@ export class TorrentClient {
}); });
} }
await registration.update();
await this.activateWaitingWorker(registration);
await this.waitForServiceWorkerActivation(registration); await this.waitForServiceWorkerActivation(registration);
this.log("Service worker activated"); this.log("Service worker activated");
@@ -675,8 +737,10 @@ export class TorrentClient {
// newly installed worker claims the page before WebTorrent spins up. // newly installed worker claims the page before WebTorrent spins up.
await this.waitForActiveController(registration); await this.waitForActiveController(registration);
// Force the SW to check for updates // Force the SW to check for updates and immediately claim if a waiting
registration.update(); // worker appeared after activation.
await registration.update();
await this.activateWaitingWorker(registration);
this.log("Service worker ready"); this.log("Service worker ready");
return registration; return registration;
+3 -2
View File
@@ -16,7 +16,7 @@
"ci:beacon": "npm run build:beacon", "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", "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", "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:file-size": "node scripts/check-file-size.mjs",
"lint:innerhtml": "node scripts/check-innerhtml.mjs", "lint:innerhtml": "node scripts/check-innerhtml.mjs",
"lint:css": "stylelint css/tailwind.source.css css/tokens.css css/docs.css", "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: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", "test:unit:shard3": "cross-env UNIT_TEST_SHARD=3/3 node scripts/run-unit-tests.mjs",
"telemetry:aggregate": "node scripts/agent/telemetry-aggregator.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": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
+8
View File
@@ -155,6 +155,14 @@ function rewriteEntryHtmlAssetPaths(manifest) {
} }
function main() { 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...'); console.log('Cleaning dist...');
cleanDist(); 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"; "use strict";
const SW_VERSION = "bitvid-sw-2026-02-10";
const SW_METADATA = Object.freeze({
version: SW_VERSION,
runtimePathPrefix: "/webtorrent/",
});
let cancelled = false; 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 // Handle messages from clients
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") { if (event.data && event.data.type === "SKIP_WAITING") {
@@ -22,7 +43,7 @@
// Immediately install and skip waiting // Immediately install and skip waiting
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
self.skipWaiting(); event.waitUntil(Promise.all([self.skipWaiting(), broadcastLifecycle("install")]));
}); });
// Claim clients on activation and clear caches // Claim clients on activation and clear caches
@@ -31,6 +52,9 @@
Promise.all([ Promise.all([
clients.claim(), clients.claim(),
self.skipWaiting(), self.skipWaiting(),
broadcastLifecycle("activate", {
scope: self.registration?.scope || null,
}),
caches caches
.keys() .keys()
.then((cacheNames) => .then((cacheNames) =>
@@ -115,6 +139,7 @@
); );
responseHeaders.set("Pragma", "no-cache"); responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0"); responseHeaders.set("Expires", "0");
responseHeaders.set("X-Bitvid-SW-Version", SW_VERSION);
// 3e) If the response is not a streaming request, return it directly // 3e) If the response is not a streaming request, return it directly
if (clientResponse.body !== "STREAM") { if (clientResponse.body !== "STREAM") {