mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[playback] Harden service-worker rollout and rollback guardrails
This commit is contained in:
1 parent
1cf0e2aacd
commit
eeb2db9037
9 files changed
+210
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user