Merge pull request #574 from PR0M3TH3AN/codex/fix-webtorrent-fallback-for-video-playback

Handle WebTorrent fallback without service worker
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-15 15:12:09 -04:00
commit 15a1986c55
22 files changed
+278 -82

No files matched your search

+9 -5
View File
@@ -8,8 +8,8 @@ import {
isDevMode,
} from "./config.js";
import { nostrClient } from "./nostr.js";
import {
import { devLogger, userLogger } from "./utils/logger.js";
import {
buildAdminListEvent,
ADMIN_LIST_IDENTIFIERS,
} from "./nostrEventSchemas.js";
@@ -151,8 +151,10 @@ function readJsonListFromStorage(key) {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (error) {
devLogger.warn(`[adminListStore] Failed to parse legacy list for ${key:`, error);
}
devLogger.warn(
`[adminListStore] Failed to parse legacy list for ${key}:`,
error,
);
return null;
}
}
@@ -198,8 +200,10 @@ function clearLegacyStorageFor(listKey) {
try {
localStorage.removeItem(key);
} catch (error) {
devLogger.warn(`[adminListStore] Failed to clear legacy storage for ${key:`, error);
}
devLogger.warn(
`[adminListStore] Failed to clear legacy storage for ${key}:`,
error,
);
}
}
}
+19
View File
@@ -81,6 +81,7 @@ import { pointerArrayToKey } from "./utils/pointer.js";
import { resolveVideoPointer } from "./utils/videoPointer.js";
import { isValidMagnetUri } from "./utils/magnetValidators.js";
import { dedupeToNewestByRoot } from "./utils/videoDeduper.js";
import { buildServiceWorkerFallbackStatus } from "./utils/serviceWorkerFallbackMessages.js";
import {
getVideoRootIdentifier,
applyRootTimestampToVideosMap,
@@ -5635,6 +5636,24 @@ class Application {
this.modalVideo,
{ urlList: sanitizedUrlList }
);
if (torrentClient.isServiceWorkerUnavailable()) {
const swError = torrentClient.getServiceWorkerInitError();
const statusMessage = buildServiceWorkerFallbackStatus(swError);
this.log(
"[playViaWebTorrent] Service worker unavailable; streaming directly via WebTorrent.",
swError
);
if (swError) {
userLogger.warn(
"[playViaWebTorrent] Service worker unavailable; direct streaming engaged.",
swError
);
}
if (this.videoModal) {
this.videoModal.updateStatus(statusMessage);
}
}
if (torrentInstance && torrentInstance.ready) {
// Some browsers delay `playing` events for MediaSource-backed torrents.
// Clearing the poster here prevents the historic "GIF stuck over the
+1 -1
View File
@@ -40,8 +40,8 @@ import {
requestInvoice
} from "./payments/lnurl.js";
import { getPlatformLightningAddress } from "./payments/platformAddress.js";
import {
import { userLogger } from "./utils/logger.js";
import {
ensureWallet,
sendPayment as sendWalletPayment
} from "./payments/nwcClient.js";
+1 -1
View File
@@ -1,7 +1,7 @@
// js/config.js
import {
import { userLogger } from "./utils/logger.js";
import {
IS_DEV_MODE,
ADMIN_SUPER_NPUB,
ADMIN_DM_IMAGE_URL,
+1 -1
View File
@@ -13,8 +13,8 @@ import {
initThemeController,
refreshThemeControls,
} from "./themeController.js";
import {
import { devLogger, userLogger } from "./utils/logger.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
+45 -38
View File
@@ -1620,8 +1620,7 @@ function normalizePointerInput(pointer) {
}
}
} catch (err) {
devLogger.warn(`[nostr] Failed to decode pointer ${trimmed:`, err);
}
devLogger.warn(`[nostr] Failed to decode pointer ${trimmed}:`, err);
}
}
@@ -5681,17 +5680,18 @@ export class NostrClient {
this.restoreLocalData();
try {
await this.ensurePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) {
throw new Error("No relays connected");
}
devLogger.log(`Connected to ${successfulRelays.length relay(s)`);
}
try {
await this.ensurePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) {
throw new Error("No relays connected");
}
devLogger.log(
`Connected to ${successfulRelays.length} relay(s)`,
);
} catch (err) {
userLogger.error("Nostr init failed:", err);
throw err;
@@ -6159,12 +6159,11 @@ export class NostrClient {
}
}
if (!signerPubkey && signedEvent && typeof signedEvent.pubkey === "string") {
signerPubkey = signedEvent.pubkey;
}
if (!signerPubkey && signedEvent && typeof signedEvent.pubkey === "string") {
signerPubkey = signedEvent.pubkey;
}
devLogger.log(`Signed ${devLogLabel event:`, signedEvent);
}
devLogger.log(`Signed ${devLogLabel} event:`, signedEvent);
let targetRelays = sanitizeRelayList(
Array.isArray(relaysOverride) && relaysOverride.length
@@ -7373,11 +7372,13 @@ export class NostrClient {
try {
const events = await this.pool.list([url], [filter]);
return Array.isArray(events) ? events : [];
} catch (error) {
devLogger.warn(`[nostr] NIP-71 fetch failed on ${url:`, error);
} catch (error) {
devLogger.warn(
`[nostr] NIP-71 fetch failed on ${url}:`,
error,
);
return [];
}
return [];
}
})
);
@@ -8538,14 +8539,18 @@ export class NostrClient {
});
const count = this.extractCountValue(frame?.[2]);
return { url, ok: true, frame, count };
} catch (error) {
const isUnsupported = error?.code === "count-unsupported";
if (isUnsupported) {
this.countUnsupportedRelays.add(url);
} else devLogger.warn(`[nostr] COUNT request failed on ${url:`, error);
} catch (error) {
const isUnsupported = error?.code === "count-unsupported";
if (isUnsupported) {
this.countUnsupportedRelays.add(url);
} else {
devLogger.warn(
`[nostr] COUNT request failed on ${url}:`,
error,
);
}
return { url, ok: false, error, unsupported: isUnsupported };
}
return { url, ok: false, error, unsupported: isUnsupported };
}
})
);
@@ -8735,16 +8740,18 @@ export class NostrClient {
try {
const perRelay = await Promise.all(
this.relays.map(async (url) => {
try {
const events = await this.pool.list([url], [filter]);
return events || [];
} catch (err) {
devLogger.warn(`[nostr] History fetch failed on ${url:`, err);
this.relays.map(async (url) => {
try {
const events = await this.pool.list([url], [filter]);
return events || [];
} catch (err) {
devLogger.warn(
`[nostr] History fetch failed on ${url}:`,
err,
);
return [];
}
return [];
}
})
})
);
const merged = perRelay.flat();
+4 -3
View File
@@ -1,5 +1,5 @@
import {
import { devLogger } from "./utils/logger.js";
import {
ADMIN_LIST_NAMESPACE,
isDevMode,
WATCH_HISTORY_KIND,
@@ -830,8 +830,9 @@ function resolveAdminNoteType(listKey) {
export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) {
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
if (!schema) {
devLogger.warn(`[nostrEventSchemas] Unknown admin list key: ${listKey`);
}
devLogger.warn(
`[nostrEventSchemas] Unknown admin list key: ${listKey}`,
);
return {
kind: 30000,
pubkey,
+1 -1
View File
@@ -10,8 +10,8 @@ import {
} from "./lnurl.js";
import { ensureWallet, sendPayment } from "./nwcClient.js";
import { getPlatformLightningAddress } from "./platformAddress.js";
import {
import { userLogger } from "../utils/logger.js";
import {
clampPercent,
parsePercentValue,
resolvePlatformFeePercent,
+1 -1
View File
@@ -1,8 +1,8 @@
import { isDevMode } from "./config.js";
import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js";
import { buildRelayListEvent } from "./nostrEventSchemas.js";
import {
import { devLogger, userLogger } from "./utils/logger.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
+1 -1
View File
@@ -6,8 +6,8 @@ import {
getPubkey,
getCurrentUserNpub,
} from "../state/appState.js";
import {
import { userLogger } from "../utils/logger.js";
import {
getSavedProfiles,
getActiveProfilePubkey,
setActiveProfilePubkey,
+1 -1
View File
@@ -4,8 +4,8 @@ import {
} from "../nostr.js";
import { accessControl } from "../accessControl.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
import {
import { userLogger } from "../utils/logger.js";
import {
getVideosMap as getStoredVideosMap,
setVideosMap as setStoredVideosMap,
getVideoSubscription as getStoredVideoSubscription,
+1 -1
View File
@@ -1,7 +1,7 @@
import { isDevMode } from "../config.js";
import { pointerArrayToKey } from "../utils/pointer.js";
import {
import { devLogger, userLogger } from "../utils/logger.js";
import {
ingestLocalViewEvent as defaultIngestLocalViewEvent,
} from "../viewCounter.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import {
import { userLogger } from "../utils/logger.js";
import {
readUrlHealthFromStorage,
removeUrlHealthFromStorage,
writeUrlHealthToStorage,
+1 -1
View File
@@ -2,8 +2,8 @@ import { normalizeDesignSystemContext } from "../../designSystem.js";
import { updateVideoCardSourceVisibility } from "../../utils/cardSourceVisibility.js";
import positionFloatingPanel from "../utils/positionFloatingPanel.js";
import { createFloatingPanelStyles } from "../utils/floatingPanelStyles.js";
import {
import { userLogger } from "../../utils/logger.js";
import {
getPopupOffsetPx,
getPopupViewportPaddingPx,
} from "../../designSystem/metrics.js";
+1 -1
View File
@@ -1,8 +1,8 @@
import { normalizeDesignSystemContext } from "../designSystem.js";
import positionFloatingPanel from "./utils/positionFloatingPanel.js";
import { createFloatingPanelStyles } from "./utils/floatingPanelStyles.js";
import {
import { userLogger } from "../utils/logger.js";
import {
getPopupOffsetPx,
getPopupViewportPaddingPx,
} from "../designSystem/metrics.js";
+8 -4
View File
@@ -350,13 +350,17 @@ export default class WatchHistoryController {
try {
const result = this.watchHistoryService.snapshot(undefined, { reason });
return Promise.resolve(result).catch((error) => {
devLogger.warn(`[${context] Watch history flush failed:`, error);
}
devLogger.warn(
`[${context}] Watch history flush failed:`,
error,
);
throw error;
});
} catch (error) {
devLogger.warn(`[${context] Failed to queue watch history flush:`, error);
}
devLogger.warn(
`[${context}] Failed to queue watch history flush:`,
error,
);
return Promise.reject(error);
}
}
+1 -1
View File
@@ -18,8 +18,8 @@ import {
requestInvoice,
} from "../payments/lnurl.js";
import { getPlatformLightningAddress } from "../payments/platformAddress.js";
import {
import { devLogger, userLogger } from "../utils/logger.js";
import {
ensureWallet as ensureWalletDefault,
sendPayment as sendPaymentDefault,
} from "../payments/nwcClient.js";
+1 -1
View File
@@ -1,8 +1,8 @@
// js/userBlocks.js
import { nostrClient } from "./nostr.js";
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
import {
import { userLogger } from "./utils/logger.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
+58
View File
@@ -0,0 +1,58 @@
// js/utils/serviceWorkerFallbackMessages.js
const BASE_STATUS_MESSAGE = "Streaming via WebTorrent";
function normalizeMessage(error) {
if (!error) {
return "";
}
if (error instanceof Error && typeof error.message === "string") {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return String(error);
} catch (_err) {
return "";
}
}
export function buildServiceWorkerFallbackStatus(error) {
const rawMessage = normalizeMessage(error);
const normalized = rawMessage.toLowerCase();
if (!normalized) {
return `${BASE_STATUS_MESSAGE} (service worker unavailable)`;
}
if (normalized.includes("https or localhost required")) {
return `${BASE_STATUS_MESSAGE} (serve over HTTPS to enable service worker)`;
}
if (normalized.includes("not supported") || normalized.includes("disabled")) {
return `${BASE_STATUS_MESSAGE} (browser disabled service workers)`;
}
if (normalized.includes("brave shield")) {
return `${BASE_STATUS_MESSAGE} (Brave Shields blocked the service worker)`;
}
if (
normalized.includes("failed to register") ||
normalized.includes("failed to fetch") ||
normalized.includes("blocked") ||
normalized.includes("net::err_blocked_by_client")
) {
return `${BASE_STATUS_MESSAGE} (service worker blocked by browser or extension)`;
}
if (normalized.includes("controller claim timeout")) {
return `${BASE_STATUS_MESSAGE} (waiting for service worker control)`;
}
return `${BASE_STATUS_MESSAGE} (service worker unavailable)`;
}
export default buildServiceWorkerFallbackStatus;
+1 -1
View File
@@ -16,8 +16,8 @@ import {
VIEW_COUNT_CACHE_TTL_MS,
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
} from "./config.js";
import {
import { userLogger } from "./utils/logger.js";
import {
countVideoViewEvents,
listVideoViewEvents,
subscribeVideoViewEvents,
+64 -18
View File
@@ -125,7 +125,9 @@ function toError(err) {
}
export class TorrentClient {
constructor() {
constructor({ webTorrentClass } = {}) {
this.WebTorrentClass =
typeof webTorrentClass === "function" ? webTorrentClass : WebTorrent;
// Reusable objects and flags
this.client = null;
this.currentTorrent = null;
@@ -135,13 +137,16 @@ export class TorrentClient {
this.swRegistration = null;
this.serverCreated = false; // Indicates if we've called createServer on this.client
this.serviceWorkerDisabled = false;
this.serviceWorkerError = null;
// Timeout for SW operations
this.TIMEOUT_DURATION = 60000;
}
ensureClientForProbe() {
if (!this.probeClient) {
this.probeClient = new WebTorrent();
this.probeClient = new this.WebTorrentClass();
}
return this.probeClient;
}
@@ -288,8 +293,16 @@ export class TorrentClient {
});
}
log(msg) {
devLogger.log(msg);
log(...args) {
devLogger.log(...args);
}
isServiceWorkerUnavailable() {
return this.serviceWorkerDisabled;
}
getServiceWorkerInitError() {
return this.serviceWorkerError || null;
}
async isBrave() {
@@ -307,21 +320,52 @@ export class TorrentClient {
* Called once from streamVideo.
*/
async init() {
// 1) If the client doesn't exist, create it
if (!this.client) {
this.client = new WebTorrent();
this.client = new this.WebTorrentClass();
}
// 2) If we haven’t registered the service worker yet, do it now
if (!this.swRegistration) {
this.swRegistration = await this.setupServiceWorker();
} else {
// Even with an existing registration we still wait for control so that a
// transient controller drop (e.g. Chrome devtools unregister/reload) does
// not resurrect the grey-screen regression mentioned in
// waitForActiveController().
this.requestClientsClaim(this.swRegistration);
await this.waitForActiveController(this.swRegistration);
if (this.serviceWorkerDisabled) {
return {
serviceWorkerReady: false,
registration: null,
error: this.serviceWorkerError,
};
}
try {
if (!this.swRegistration) {
this.swRegistration = await this.setupServiceWorker();
} else {
// Even with an existing registration we still wait for control so that a
// transient controller drop (e.g. Chrome devtools unregister/reload) does
// not resurrect the grey-screen regression mentioned in
// waitForActiveController().
this.requestClientsClaim(this.swRegistration);
await this.waitForActiveController(this.swRegistration);
}
return {
serviceWorkerReady: !!this.swRegistration,
registration: this.swRegistration,
};
} catch (error) {
const normalizedError = toError(error);
this.log(
"[WebTorrent] Service worker setup failed; continuing without it:",
normalizedError
);
userLogger.warn(
"[WebTorrent] Service worker unavailable; falling back to direct streaming.",
normalizedError
);
this.serviceWorkerDisabled = true;
this.serviceWorkerError = normalizedError;
this.swRegistration = null;
return {
serviceWorkerReady: false,
registration: null,
error: normalizedError,
};
}
}
@@ -777,10 +821,12 @@ export class TorrentClient {
async streamVideo(magnetURI, videoElement, opts = {}) {
try {
// 1) Make sure we have a WebTorrent client and a valid SW registration.
await this.init();
const initResult = await this.init();
const serviceWorkerReady =
!!(initResult?.serviceWorkerReady && this.swRegistration);
// 2) Create the server once if not already created.
if (!this.serverCreated) {
if (serviceWorkerReady && !this.serverCreated) {
this.client.createServer({
controller: this.swRegistration,
pathPrefix: location.origin + "/webtorrent",
@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildServiceWorkerFallbackStatus,
} from "../../js/utils/serviceWorkerFallbackMessages.js";
const BASE = "Streaming via WebTorrent";
test("returns generic message when error missing", () => {
assert.strictEqual(
buildServiceWorkerFallbackStatus(null),
`${BASE} (service worker unavailable)`
);
});
test("identifies HTTPS requirement errors", () => {
const error = new Error("HTTPS or localhost required");
assert.strictEqual(
buildServiceWorkerFallbackStatus(error),
`${BASE} (serve over HTTPS to enable service worker)`
);
});
test("identifies disabled service worker errors", () => {
const error = new Error("Service Worker not supported or disabled");
assert.strictEqual(
buildServiceWorkerFallbackStatus(error),
`${BASE} (browser disabled service workers)`
);
});
test("identifies Brave specific guidance", () => {
const error = new Error("Please enable Service Workers in Brave Shield settings");
assert.strictEqual(
buildServiceWorkerFallbackStatus(error),
`${BASE} (Brave Shields blocked the service worker)`
);
});
test("identifies blocked script errors", () => {
const error = new Error(
"Failed to register ServiceWorker: TypeError: Failed to fetch"
);
assert.strictEqual(
buildServiceWorkerFallbackStatus(error),
`${BASE} (service worker blocked by browser or extension)`
);
});
test("identifies controller claim timeout", () => {
const error = new Error("Service worker controller claim timeout");
assert.strictEqual(
buildServiceWorkerFallbackStatus(error),
`${BASE} (waiting for service worker control)`
);
});