From bc085ad3beacd12ff5acdc6df4ad7f854c41fc86 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 25 Sep 2025 20:31:25 -0400 Subject: [PATCH] Add WebTorrent probe for grid health badges --- js/gridHealth.js | 382 +++++++++++++++++++++++++++++++++++++---------- js/webtorrent.js | 289 +++++++++++++++++++++++++++++++++++ 2 files changed, 590 insertions(+), 81 deletions(-) diff --git a/js/gridHealth.js b/js/gridHealth.js index be1f976d..11815a6d 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -1,13 +1,230 @@ import { infoHashFromMagnet } from "./magnets.js"; -import { - getDefaultHealth, - getHealthCached, - queueHealthCheck, -} from "./healthService.js"; +import { TorrentClient, torrentClient } from "./webtorrent.js"; const containerState = new WeakMap(); const ROOT_MARGIN = "200px 0px"; +function now() { + return Date.now(); +} + +function getCacheEntry(key) { + if (!key) { + return null; + } + const entry = probeCache.get(key); + if (!entry) { + return null; + } + if (now() - entry.ts > PROBE_CACHE_TTL_MS) { + probeCache.delete(key); + return null; + } + return entry.value; +} + +function setCacheEntry(key, value) { + if (!key) { + return; + } + probeCache.set(key, { ts: now(), value }); +} + +function formatTime(ts) { + if (!Number.isFinite(ts)) { + return ""; + } + try { + return new Date(ts).toLocaleTimeString([], { hour12: false }); + } catch (err) { + return new Date(ts).toLocaleTimeString(); + } +} + +function buildTooltip({ peers = 0, checkedAt, reason } = {}) { + const parts = []; + if (Number.isFinite(peers)) { + parts.push(`Peers: ${Math.max(0, peers)}`); + } + if (Number.isFinite(checkedAt)) { + const formatted = formatTime(checkedAt); + if (formatted) { + parts.push(`Checked ${formatted}`); + } + } + if (typeof reason === "string" && reason && reason !== "peer") { + let normalizedReason; + if (reason === "timeout") { + normalizedReason = "Timed out"; + } else if (reason === "no-trackers") { + normalizedReason = "No WSS trackers"; + } else if (reason === "invalid") { + normalizedReason = "Invalid magnet"; + } else { + normalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1); + } + parts.push(normalizedReason); + } + if (!parts.length) { + return "WebTorrent status unknown"; + } + return `WebTorrent • ${parts.join(" • ")}`; +} + +function normalizeResult(result) { + const fallback = { + healthy: false, + peers: 0, + reason: "error", + appendedTrackers: false, + hasProbeTrackers: false, + usedTrackers: Array.isArray(TorrentClient.PROBE_TRACKERS) + ? [...TorrentClient.PROBE_TRACKERS] + : [], + durationMs: 0, + }; + if (!result || typeof result !== "object") { + return { ...fallback, checkedAt: now() }; + } + const peers = Number.isFinite(result.peers) + ? Math.max(0, Number(result.peers)) + : 0; + const healthy = Boolean(result.healthy) && peers > 0; + const reason = typeof result.reason === "string" ? result.reason : "error"; + return { + healthy, + peers: healthy ? Math.max(1, peers) : peers, + reason, + appendedTrackers: Boolean(result.appendedTrackers), + hasProbeTrackers: + typeof result.hasProbeTrackers === "boolean" + ? result.hasProbeTrackers + : healthy || Boolean(result.appendedTrackers), + usedTrackers: Array.isArray(result.usedTrackers) + ? result.usedTrackers.slice() + : fallback.usedTrackers, + durationMs: Number.isFinite(result.durationMs) + ? Math.max(0, Number(result.durationMs)) + : 0, + checkedAt: now(), + }; +} + +function queueProbe(magnet, cacheKey) { + if (!magnet) { + return Promise.resolve(null); + } + + const cached = getCacheEntry(cacheKey); + if (cached) { + return Promise.resolve(cached); + } + + if (probeInflight.has(cacheKey)) { + return probeInflight.get(cacheKey); + } + + const job = probeQueue + .run(() => + torrentClient + .probePeers(magnet, { + timeoutMs: PROBE_TIMEOUT_MS, + maxWebConns: 2, + polls: PROBE_POLL_COUNT, + }) + .catch((err) => ({ + healthy: false, + peers: 0, + reason: "error", + error: err, + appendedTrackers: false, + hasProbeTrackers: false, + })) + ) + .then((result) => { + const normalized = normalizeResult(result); + if (cacheKey) { + setCacheEntry(cacheKey, normalized); + } + return normalized; + }) + .finally(() => { + if (cacheKey) { + probeInflight.delete(cacheKey); + } + }); + + if (cacheKey) { + probeInflight.set(cacheKey, job); + } + + return job; +} + +const PROBE_CACHE_TTL_MS = 5 * 60 * 1000; +const PROBE_TIMEOUT_MS = 8000; +const PROBE_CONCURRENCY = 3; +const PROBE_POLL_COUNT = 3; + +class ProbeQueue { + constructor(max = 2) { + this.max = Math.max(1, Number(max) || 1); + this.running = 0; + this.queue = []; + } + + run(task) { + return new Promise((resolve, reject) => { + this.queue.push({ task, resolve, reject }); + this.drain(); + }); + } + + drain() { + if (this.running >= this.max) { + return; + } + const job = this.queue.shift(); + if (!job) { + return; + } + this.running += 1; + let finished = false; + const finalize = () => { + if (finished) { + return; + } + finished = true; + this.running -= 1; + this.drain(); + }; + + let result; + try { + result = job.task(); + } catch (err) { + job.reject(err); + finalize(); + return; + } + + Promise.resolve(result) + .then((value) => { + job.resolve(value); + }) + .catch((err) => { + job.reject(err); + }) + .finally(() => { + finalize(); + }); + } +} + +const probeQueue = new ProbeQueue(PROBE_CONCURRENCY); +const probeCache = new Map(); +const probeInflight = new Map(); + function ensureState(container) { let state = containerState.get(container); if (state) { @@ -38,23 +255,7 @@ function ensureState(container) { return state; } -function toVisual(health) { - if (!health) { - return "unknown"; - } - if (health.ok && health.seeders > 0) { - return "good"; - } - if (health.responded) { - if (health.seeders > 0) { - return "good"; - } - return "none"; - } - return "noresp"; -} - -function setBadge(card, visual, health) { +function setBadge(card, state, details) { const badge = card.querySelector(".torrent-health-badge"); if (!badge) { return; @@ -80,65 +281,61 @@ function setBadge(card, visual, health) { badge.className = baseClasses.join(" "); const map = { - good: { - icon: "✅", - aria: "WebTorrent fallback ready", + healthy: { + icon: "🟢", + aria: "WebTorrent peers available", classes: ["bg-green-900", "text-green-200"], role: "status", }, - none: { - icon: "⚠️", - aria: "No seeders reported by trackers", - classes: ["bg-amber-900", "text-amber-200"], - role: "status", - }, - noresp: { - icon: "❌", - aria: "No tracker response", + unhealthy: { + icon: "🔴", + aria: "WebTorrent peers unavailable", classes: ["bg-red-900", "text-red-200"], role: "alert", }, checking: { icon: "⏳", - aria: "Checking Torrent availability", + aria: "Checking WebTorrent peers", classes: ["bg-gray-800", "text-gray-300"], role: "status", }, unknown: { - icon: "⚠️", - aria: "Torrent availability unknown", - classes: ["bg-amber-900", "text-amber-200"], + icon: "⚪", + aria: "WebTorrent status unknown", + classes: ["bg-gray-800", "text-gray-300"], role: "status", }, }; - const entry = map[visual] || map.unknown; + const entry = map[state] || map.unknown; entry.classes.forEach((cls) => badge.classList.add(cls)); - const seederCount = - health && Number.isFinite(health.seeders) && health.seeders > 0 - ? health.seeders - : null; - const countText = seederCount ? ` (${seederCount})` : ""; - const ariaCount = seederCount ? ` with ${seederCount} seeders` : ""; + const peers = + details && Number.isFinite(details.peers) + ? Math.max(0, Number(details.peers)) + : 0; + const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : ""; const iconPrefix = entry.icon ? `${entry.icon} ` : ""; - badge.textContent = `${iconPrefix}Torrent${countText}`; - const ariaLabel = `${entry.aria}${ariaCount}`; - badge.setAttribute("aria-label", ariaLabel); - badge.setAttribute("title", ariaLabel); - badge.setAttribute("aria-live", "polite"); + badge.textContent = `${iconPrefix}WebTorrent${peersText}`; + const tooltip = + state === "checking" || state === "unknown" + ? entry.aria + : buildTooltip({ + peers, + checkedAt: details?.checkedAt, + reason: details?.reason, + }); + badge.setAttribute("aria-label", tooltip); + badge.setAttribute("title", tooltip); + badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite"); badge.setAttribute("role", entry.role); - badge.dataset.streamHealthState = visual; -} - -function applyHealth(card, health) { - if (!health) { - setBadge(card, "unknown"); - return; + badge.dataset.streamHealthState = state; + if (Number.isFinite(peers)) { + badge.dataset.streamHealthPeers = String(peers); + } else if (badge.dataset.streamHealthPeers) { + delete badge.dataset.streamHealthPeers; } - const visual = toVisual(health); - setBadge(card, visual, health); } function handleCardVisible({ card, pendingByCard }) { @@ -157,32 +354,55 @@ function handleCardVisible({ card, pendingByCard }) { return; } - const cached = getHealthCached(infoHash); - if (cached) { - applyHealth(card, cached); - return; - } - if (pendingByCard.has(card)) { return; } - setBadge(card, "checking", getDefaultHealth()); - const pending = queueHealthCheck(magnet).then((health) => { - pendingByCard.delete(card); - if (!card.isConnected) { - return; - } - applyHealth(card, health); - }); - pending.catch(() => { - pendingByCard.delete(card); - if (!card.isConnected) { - return; - } - setBadge(card, "noresp"); - }); - pendingByCard.set(card, pending); + const cached = getCacheEntry(infoHash); + if (cached) { + const cachedState = cached.healthy + ? "healthy" + : cached.hasProbeTrackers + ? "unhealthy" + : "unknown"; + setBadge(card, cachedState, cached); + return; + } + + setBadge(card, "checking"); + + const probePromise = queueProbe(magnet, infoHash); + + pendingByCard.set(card, probePromise); + + probePromise + .then((result) => { + pendingByCard.delete(card); + if (!card.isConnected) { + return; + } + if (!result) { + setBadge(card, "unknown"); + return; + } + if (!result.hasProbeTrackers) { + setBadge(card, "unknown", result); + return; + } + if (result.healthy) { + setBadge(card, "healthy", result); + return; + } + setBadge(card, "unhealthy", result); + }) + .catch((err) => { + console.warn("[gridHealth] probe failed", err); + pendingByCard.delete(card); + if (!card.isConnected) { + return; + } + setBadge(card, "unhealthy"); + }); } export function attachHealthBadges(container) { diff --git a/js/webtorrent.js b/js/webtorrent.js index bd724f43..ee8aac8b 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -2,11 +2,145 @@ import WebTorrent from "./webtorrent.min.js"; +const DEFAULT_PROBE_TRACKERS = Object.freeze([ + "wss://tracker.btorrent.xyz", + "wss://tracker.openwebtorrent.com", +]); + +function decodeComponentSafe(value) { + if (typeof value !== "string") { + return ""; + } + try { + return decodeURIComponent(value); + } catch (err) { + return value; + } +} + +function normalizeTrackerList(trackers) { + const normalized = []; + const seen = new Set(); + if (!Array.isArray(trackers)) { + return normalized; + } + trackers.forEach((tracker) => { + if (typeof tracker !== "string") { + return; + } + const trimmed = tracker.trim(); + if (!trimmed || !/^wss:\/\//i.test(trimmed)) { + return; + } + const lower = trimmed.toLowerCase(); + if (seen.has(lower)) { + return; + } + seen.add(lower); + normalized.push(trimmed); + }); + return normalized; +} + +function appendProbeTrackers(magnetURI, trackers) { + if (typeof magnetURI !== "string") { + return { magnet: "", appended: false, hasProbeTrackers: false }; + } + + const trimmedMagnet = magnetURI.trim(); + if (!trimmedMagnet) { + return { magnet: "", appended: false, hasProbeTrackers: false }; + } + + const probeTrackers = normalizeTrackerList(trackers); + if (!probeTrackers.length) { + return { + magnet: trimmedMagnet, + appended: false, + hasProbeTrackers: false, + }; + } + + const trackerSet = new Set(); + const [withoutFragment, fragment = ""] = trimmedMagnet.split("#", 2); + const [, queryPart = ""] = withoutFragment.split("?", 2); + + if (queryPart) { + queryPart + .split("&") + .map((segment) => segment.trim()) + .filter(Boolean) + .forEach((segment) => { + const [rawKey, rawValue = ""] = segment.split("=", 2); + if (!rawKey || rawKey.trim().toLowerCase() !== "tr") { + return; + } + const decoded = decodeComponentSafe(rawValue).trim().toLowerCase(); + if (decoded) { + trackerSet.add(decoded); + } + }); + } + + const normalizedProbe = probeTrackers.map((url) => url.toLowerCase()); + const hadProbeTracker = normalizedProbe.some((url) => trackerSet.has(url)); + + const toAppend = []; + probeTrackers.forEach((tracker, index) => { + const normalizedTracker = normalizedProbe[index]; + if (trackerSet.has(normalizedTracker)) { + return; + } + trackerSet.add(normalizedTracker); + toAppend.push(`tr=${encodeURIComponent(tracker)}`); + }); + + if (!toAppend.length) { + return { + magnet: trimmedMagnet, + appended: false, + hasProbeTrackers: hadProbeTracker, + }; + } + + const separator = queryPart ? "&" : "?"; + const augmented = `${withoutFragment}${separator}${toAppend.join("&")}`; + const finalMagnet = fragment + ? `${augmented}#${fragment}` + : augmented; + + return { + magnet: finalMagnet, + appended: true, + hasProbeTrackers: true, + }; +} + +function normalizeNumber(value, fallback = 0) { + const coerced = Number(value); + if (Number.isFinite(coerced)) { + return coerced; + } + return fallback; +} + +function toError(err) { + if (err instanceof Error) { + return err; + } + try { + return new Error(String(err)); + } catch (stringifyError) { + return new Error("Unknown error"); + } +} + export class TorrentClient { constructor() { // Reusable objects and flags this.client = null; this.currentTorrent = null; + this.probeClient = null; // Service worker registration is cached this.swRegistration = null; @@ -16,6 +150,155 @@ export class TorrentClient { this.TIMEOUT_DURATION = 60000; } + ensureClientForProbe() { + if (!this.probeClient) { + this.probeClient = new WebTorrent(); + } + return this.probeClient; + } + + async probePeers( + magnetURI, + { timeoutMs = 8000, maxWebConns = 2, polls = 3 } = {} + ) { + const magnet = typeof magnetURI === "string" ? magnetURI.trim() : ""; + if (!magnet) { + return { + healthy: false, + peers: 0, + reason: "invalid", + appendedTrackers: false, + hasProbeTrackers: false, + usedTrackers: [...TorrentClient.PROBE_TRACKERS], + durationMs: 0, + }; + } + + const trackers = normalizeTrackerList(TorrentClient.PROBE_TRACKERS); + const { magnet: augmentedMagnet, appended, hasProbeTrackers } = + appendProbeTrackers(magnet, trackers); + + if (!hasProbeTrackers) { + return { + healthy: false, + peers: 0, + reason: "no-trackers", + appendedTrackers: false, + hasProbeTrackers: false, + usedTrackers: trackers, + durationMs: 0, + }; + } + + const client = this.ensureClientForProbe(); + const safeTimeout = Math.max(0, normalizeNumber(timeoutMs, 8000)); + const safePolls = Math.max(1, Math.floor(normalizeNumber(polls, 3))); + const safeMaxWebConns = Math.max(1, Math.floor(normalizeNumber(maxWebConns, 2))); + const pollInterval = Math.max( + 250, + Math.floor(safeTimeout / Math.max(1, safePolls)) + ); + + const startedAt = + typeof performance !== "undefined" && performance?.now + ? performance.now() + : Date.now(); + + return new Promise((resolve) => { + let settled = false; + let torrent = null; + let timeoutId = null; + let pollId = null; + + const finalize = (overrides = {}) => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (pollId) { + clearInterval(pollId); + pollId = null; + } + if (torrent) { + try { + torrent.destroy({ destroyStore: true }); + } catch (err) { + // ignore + } + } + + const endedAt = + typeof performance !== "undefined" && performance?.now + ? performance.now() + : Date.now(); + + resolve({ + healthy: false, + peers: 0, + reason: "timeout", + appendedTrackers: appended, + hasProbeTrackers, + usedTrackers: trackers, + durationMs: Math.max(0, endedAt - startedAt), + ...overrides, + }); + }; + + try { + torrent = client.add(augmentedMagnet, { + announce: trackers, + maxWebConns: safeMaxWebConns, + }); + } catch (err) { + finalize({ + reason: "error", + error: toError(err), + peers: 0, + }); + return; + } + + const settleHealthy = () => { + const peers = Math.max(1, Math.floor(normalizeNumber(torrent?.numPeers, 1))); + finalize({ healthy: true, peers, reason: "peer" }); + }; + + torrent.once("wire", settleHealthy); + + torrent.once("error", (err) => { + finalize({ + healthy: false, + reason: "error", + error: toError(err), + peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))), + }); + }); + + if (safeTimeout > 0) { + timeoutId = setTimeout(() => { + finalize({ + healthy: false, + peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))), + }); + }, safeTimeout); + } + + pollId = setInterval(() => { + if (!torrent || settled) { + return; + } + const peers = Math.max(0, Math.floor(normalizeNumber(torrent.numPeers, 0))); + if (peers > 0) { + finalize({ healthy: true, peers, reason: "peer" }); + } + }, pollInterval); + }); + } + log(msg) { console.log(msg); } @@ -502,6 +785,10 @@ export class TorrentClient { await this.client.destroy(); this.client = null; } + if (this.probeClient) { + await this.probeClient.destroy(); + this.probeClient = null; + } this.currentTorrent = null; this.serverCreated = false; } catch (error) { @@ -510,4 +797,6 @@ export class TorrentClient { } } +TorrentClient.PROBE_TRACKERS = DEFAULT_PROBE_TRACKERS; + export const torrentClient = new TorrentClient();