diff --git a/config/instance-config.js b/config/instance-config.js index dfa246ec..f2c341d5 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -23,6 +23,15 @@ */ export const IS_DEV_MODE = true; +/** + * Whether development builds should surface extra verbose diagnostics. + * + * Set this to `true` to keep high-volume development warnings (like COUNT + * request failures) visible in the console. Flip it to `false` when you want + * a quieter dev mode without disabling other development helpers entirely. + */ +export const IS_VERBOSE_DEV_MODE = false; + /** * Whether the current deployment should operate in lockdown mode. * diff --git a/js/config.js b/js/config.js index 7a07c770..2864a36d 100644 --- a/js/config.js +++ b/js/config.js @@ -3,6 +3,7 @@ import { userLogger } from "./utils/logger.js"; import { IS_DEV_MODE, + IS_VERBOSE_DEV_MODE, IS_LOCKDOWN_MODE, ADMIN_SUPER_NPUB, ADMIN_DM_IMAGE_URL, @@ -43,11 +44,15 @@ import { export const isDevMode = Boolean(IS_DEV_MODE); export { IS_DEV_MODE }; +export const isVerboseDevMode = Boolean(IS_VERBOSE_DEV_MODE); +export { IS_VERBOSE_DEV_MODE }; + export const isLockdownMode = Boolean(IS_LOCKDOWN_MODE); export { IS_LOCKDOWN_MODE }; if (typeof window !== "undefined") { window.__BITVID_DEV_MODE__ = isDevMode; + window.__BITVID_VERBOSE_DEV_MODE__ = isVerboseDevMode; window.__BITVID_LOCKDOWN_MODE__ = isLockdownMode; } diff --git a/js/nostr/client.js b/js/nostr/client.js index 27249fc3..7483262f 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -58,6 +58,11 @@ import { listVideoComments as listVideoCommentsForClient, subscribeVideoComments as subscribeVideoCommentsForClient, } from "./commentEvents.js"; +import { + logCountTimeoutCleanupFailure, + logRelayCountFailure, +} from "./countDiagnostics.js"; +import "./maxListenerDiagnostics.js"; import { publishEventToRelay, publishEventToRelays, @@ -345,7 +350,7 @@ function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request ti try { onTimeout(); } catch (cleanupError) { - devLogger.warn("[nostr] COUNT timeout cleanup failed:", cleanupError); + logCountTimeoutCleanupFailure(cleanupError); } } reject(new Error(message)); @@ -5739,18 +5744,15 @@ 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, - ); - } - return { url, ok: false, error, unsupported: isUnsupported }; + } catch (error) { + const isUnsupported = error?.code === "count-unsupported"; + if (isUnsupported) { + this.countUnsupportedRelays.add(url); + } else { + logRelayCountFailure(url, error); } + return { url, ok: false, error, unsupported: isUnsupported }; + } }) ); diff --git a/js/nostr/countDiagnostics.js b/js/nostr/countDiagnostics.js new file mode 100644 index 00000000..7f63c6b0 --- /dev/null +++ b/js/nostr/countDiagnostics.js @@ -0,0 +1,65 @@ +// js/nostr/countDiagnostics.js +// Centralized helpers for gating high-volume COUNT diagnostics (and other +// verbose nostr warnings) behind verbose dev mode while deduplicating repeated +// messages so the console stays readable during normal development sessions. + +import { devLogger } from "../utils/logger.js"; +import { isVerboseDevMode } from "../config.js"; + +const seenWarningKeys = new Set(); + +export function isVerboseDiagnosticsEnabled() { + if (typeof window !== "undefined" && window) { + const runtimeFlag = window.__BITVID_VERBOSE_DEV_MODE__; + if (typeof runtimeFlag === "boolean") { + return runtimeFlag; + } + } + return Boolean(isVerboseDevMode); +} + +function logCountWarning(message, args = [], { key, throttle = true } = {}) { + if (!isVerboseDiagnosticsEnabled()) { + return; + } + + if (throttle && key) { + const normalizedKey = String(key); + if (seenWarningKeys.has(normalizedKey)) { + return; + } + seenWarningKeys.add(normalizedKey); + } + + devLogger.warn(message, ...args); +} + +export function logCountTimeoutCleanupFailure(error) { + logCountWarning("[nostr] COUNT timeout cleanup failed:", [error], { + key: "count-timeout-cleanup", + }); +} + +export function logRelayCountFailure(url, error) { + const normalizedUrl = + typeof url === "string" && url.trim() ? url.trim() : "(unknown relay)"; + logCountWarning(`[nostr] COUNT request failed on ${normalizedUrl}:`, [error], { + key: `relay:${normalizedUrl}`, + }); +} + +export function logRebroadcastCountFailure(error) { + logCountWarning("[nostr] COUNT request for rebroadcast failed:", [error], { + key: "rebroadcast-count-failure", + }); +} + +export function logViewCountFailure(error) { + logCountWarning("[nostr] COUNT view request failed:", [error], { + key: "view-count-failure", + }); +} + +export function resetCountWarningHistory() { + seenWarningKeys.clear(); +} diff --git a/js/nostr/maxListenerDiagnostics.js b/js/nostr/maxListenerDiagnostics.js new file mode 100644 index 00000000..cc11d494 --- /dev/null +++ b/js/nostr/maxListenerDiagnostics.js @@ -0,0 +1,101 @@ +// js/nostr/maxListenerDiagnostics.js +// Suppresses noisy MaxListenersExceededWarning diagnostics unless verbose +// dev mode is enabled by shimming `process.emitWarning`. The EventEmitter +// polyfill used by nostr-tools surfaces listener leaks through +// `process.emitWarning`, so we intercept that hook instead of importing the +// Node `events` module (which breaks browser bundlers) or rewriting +// console.warn globally. + +import { isVerboseDiagnosticsEnabled } from "./countDiagnostics.js"; + +const MAX_LISTENER_SNIPPETS = [ + "MaxListenersExceededWarning", + "Possible EventEmitter memory leak detected", +]; + +const MAX_LISTENER_CODES = new Set(["MaxListenersExceededWarning"]); + +function collectCandidateStrings(value) { + const candidates = []; + + if (!value) { + return candidates; + } + + if (typeof value === "string") { + candidates.push(value); + return candidates; + } + + if (typeof value === "object") { + const fields = ["name", "message", "code", "type"]; + for (const field of fields) { + const fieldValue = value[field]; + if (typeof fieldValue === "string") { + candidates.push(fieldValue); + } + } + } + + return candidates; +} + +function shouldSuppressWarning(...args) { + if (isVerboseDiagnosticsEnabled()) { + return false; + } + + const candidates = []; + for (const arg of args) { + candidates.push(...collectCandidateStrings(arg)); + } + + for (const candidate of candidates) { + if (MAX_LISTENER_CODES.has(candidate)) { + return true; + } + if (MAX_LISTENER_SNIPPETS.some((snippet) => candidate.includes(snippet))) { + return true; + } + } + + return false; +} + +function patchProcessEmitWarning() { + const processRef = + typeof globalThis !== "undefined" && globalThis + ? globalThis.process + : typeof process !== "undefined" + ? process + : undefined; + + if (!processRef || typeof processRef.emitWarning !== "function") { + return; + } + + const originalEmitWarning = processRef.emitWarning.bind(processRef); + + if (processRef.emitWarning.__BITVID_MAX_LISTENER_PATCHED__) { + return; + } + + function patchedEmitWarning(warning, ...rest) { + if (shouldSuppressWarning(warning, ...rest)) { + return; + } + + return originalEmitWarning(warning, ...rest); + } + + patchedEmitWarning.__BITVID_MAX_LISTENER_PATCHED__ = true; + processRef.emitWarning = patchedEmitWarning; +} + +patchProcessEmitWarning(); + +export function refreshMaxListenerPreference() { + // The shim dynamically checks the verbose flag on each invocation, so there + // is nothing else to refresh right now. This placeholder remains for API + // parity if runtime toggling ever requires additional work. +} diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index 077dde60..79936110 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -1,6 +1,4 @@ -import { - ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, -} from "../config.js"; +import { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } from "../config.js"; import { publishEventToRelays, assertAnyRelayAccepted, @@ -16,6 +14,7 @@ import { } from "./toolkit.js"; import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { logRebroadcastCountFailure } from "./countDiagnostics.js"; const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; const rebroadcastAttemptMemory = new Map(); @@ -1023,7 +1022,7 @@ export async function rebroadcastEvent({ client, eventId, options = {} }) { }) : null; } catch (error) { - devLogger.warn("[nostr] COUNT request for rebroadcast failed:", error); + logRebroadcastCountFailure(error); } if (countResult?.total && Number(countResult.total) > 0) { diff --git a/js/nostr/viewEvents.js b/js/nostr/viewEvents.js index 723b93b0..1d3bf2c0 100644 --- a/js/nostr/viewEvents.js +++ b/js/nostr/viewEvents.js @@ -12,6 +12,7 @@ import { publishEventToRelay } from "../nostrPublish.js"; import { RELAY_URLS } from "./toolkit.js"; import { normalizePointerInput } from "./watchHistory.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { logViewCountFailure } from "./countDiagnostics.js"; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; @@ -590,7 +591,7 @@ export async function countVideoViewEvents(client, pointer, options = {}) { } } catch (error) { if (error?.code !== "count-unsupported") { - devLogger.warn("[nostr] COUNT view request failed:", error); + logViewCountFailure(error); } }