diff --git a/js/channelProfile.js b/js/channelProfile.js index 4f874e34..6e63526c 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -2,9 +2,9 @@ import { nostrClient, - DEFAULT_RELAY_URLS, - convertEventToVideo as sharedConvertEventToVideo + convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; +import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; diff --git a/js/nostr.js b/js/nostr.js index 4a8d0566..6dd444b3 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -44,23 +44,17 @@ import { assertAnyRelayAccepted, summarizePublishResults, } from "./nostrPublish.js"; -import { nostrToolsReady } from "./nostrToolsBootstrap.js"; +import { + DEFAULT_RELAY_URLS, + RELAY_URLS, + ensureNostrTools, + getCachedNostrTools, + nostrToolsBootstrapFailure, + readToolkitFromScope, + resolveSimplePoolConstructor, +} from "./nostr/toolkit.js"; import { devLogger, userLogger } from "./utils/logger.js"; -/** - * The default relay set bitvid bootstraps with before loading a user's - * preferences. - */ -export const DEFAULT_RELAY_URLS = Object.freeze([ - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.snort.social", - "wss://relay.primal.net", - "wss://relay.nostr.band", -]); - -const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS); - let activeSigner = null; const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; @@ -413,215 +407,6 @@ const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind) ? VIEW_EVENT_SCHEMA.kind : 30079; -const globalScope = - typeof window !== "undefined" - ? window - : typeof globalThis !== "undefined" - ? globalThis - : null; - -const nostrToolsReadySource = - globalScope && - globalScope.nostrToolsReady && - typeof globalScope.nostrToolsReady.then === "function" - ? globalScope.nostrToolsReady - : nostrToolsReady; - -function normalizeToolkitCandidate(candidate) { - if ( - candidate && - typeof candidate === "object" && - candidate.ok !== false && - typeof candidate.then !== "function" - ) { - return candidate; - } - return null; -} - -function readToolkitFromScope(scope = globalScope) { - if (!scope || typeof scope !== "object") { - return null; - } - - const candidates = []; - - const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__; - if (canonical) { - candidates.push(canonical); - } - - const direct = scope.NostrTools; - if (direct) { - candidates.push(direct); - } - - const nestedWindow = - scope.window && scope.window !== scope && typeof scope.window === "object" - ? scope.window - : null; - if (nestedWindow) { - if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) { - candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__); - } - if (nestedWindow.NostrTools) { - candidates.push(nestedWindow.NostrTools); - } - } - - for (const candidate of candidates) { - const normalized = normalizeToolkitCandidate(candidate); - if (normalized) { - return normalized; - } - } - - return null; -} - -const __nostrToolsBootstrapResult = await (async () => { - try { - const result = await nostrToolsReadySource; - if (result && typeof result === "object" && result.ok === false) { - return { - toolkit: normalizeToolkitCandidate(readToolkitFromScope()), - failure: result, - }; - } - - const normalized = normalizeToolkitCandidate(result); - if (normalized) { - return { toolkit: normalized, failure: null }; - } - - return { - toolkit: normalizeToolkitCandidate(readToolkitFromScope()), - failure: null, - }; - } catch (error) { - return { - toolkit: normalizeToolkitCandidate(readToolkitFromScope()), - failure: error, - }; - } -})(); - -let cachedNostrTools = __nostrToolsBootstrapResult.toolkit || null; -const nostrToolsBootstrapFailure = __nostrToolsBootstrapResult.failure || null; - -if (!cachedNostrTools && nostrToolsBootstrapFailure && isDevMode) { - userLogger.warn( - "[nostr] nostr-tools helpers unavailable after bootstrap.", - nostrToolsBootstrapFailure - ); -} - -function rememberNostrTools(candidate) { - const normalized = normalizeToolkitCandidate(candidate); - if (normalized) { - cachedNostrTools = normalized; - } -} - -function getCachedNostrTools() { - const fallback = readToolkitFromScope(); - if (cachedNostrTools && fallback && fallback !== cachedNostrTools) { - rememberNostrTools(fallback); - } else if (!cachedNostrTools && fallback) { - rememberNostrTools(fallback); - } - return cachedNostrTools || fallback || null; -} - -async function ensureNostrTools() { - if (cachedNostrTools) { - return cachedNostrTools; - } - - try { - const result = await nostrToolsReadySource; - rememberNostrTools(result); - } catch (error) { - devLogger.warn("[nostr] Failed to resolve nostr-tools helpers.", error); - } - - if (!cachedNostrTools) { - rememberNostrTools(readToolkitFromScope()); - } - - return cachedNostrTools || null; -} - -function isSimplePoolConstructor(candidate) { - if (typeof candidate !== "function") { - return false; - } - - const prototype = candidate.prototype; - if (!prototype || typeof prototype !== "object") { - return false; - } - - return typeof prototype.sub === "function" && typeof prototype.close === "function"; -} - -function unwrapSimplePool(candidate) { - if (!candidate) { - return null; - } - - if (isSimplePoolConstructor(candidate)) { - return candidate; - } - - if (typeof candidate === "object") { - if (isSimplePoolConstructor(candidate.SimplePool)) { - return candidate.SimplePool; - } - if (isSimplePoolConstructor(candidate.default)) { - return candidate.default; - } - } - - return null; -} - -function resolveSimplePoolConstructor(tools, scope = globalScope) { - const candidates = [ - tools?.SimplePool, - tools?.pool?.SimplePool, - tools?.pool, - tools?.SimplePool?.SimplePool, - tools?.SimplePool?.default, - tools?.pool?.default, - tools?.default?.SimplePool, - tools?.default?.pool?.SimplePool, - tools?.default?.pool, - ]; - - if (scope && typeof scope === "object") { - candidates.push(scope?.SimplePool); - candidates.push(scope?.pool?.SimplePool); - candidates.push(scope?.pool); - const scopedTools = - scope?.NostrTools && scope.NostrTools !== tools ? scope.NostrTools : null; - if (scopedTools) { - candidates.push(scopedTools.SimplePool); - candidates.push(scopedTools.pool?.SimplePool); - candidates.push(scopedTools.pool); - } - } - - for (const candidate of candidates) { - const resolved = unwrapSimplePool(candidate); - if (resolved) { - return resolved; - } - } - - return null; -} - let ingestLocalViewEventRef = null; async function loadIngestLocalViewEvent() { @@ -5188,7 +4973,7 @@ function decodeNpubToHex(npub) { trimmed ); - let tools = cachedNostrTools; + let tools = getCachedNostrTools(); if (!tools || typeof tools?.nip19?.decode !== "function") { const fallbackTools = readToolkitFromScope(); if (fallbackTools) { diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js new file mode 100644 index 00000000..30f35015 --- /dev/null +++ b/js/nostr/toolkit.js @@ -0,0 +1,234 @@ +// js/nostr/toolkit.js + +import { isDevMode } from "../config.js"; +import { nostrToolsReady } from "../nostrToolsBootstrap.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +/** + * The default relay set bitvid bootstraps with before loading a user's + * preferences. + */ +export const DEFAULT_RELAY_URLS = Object.freeze([ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://relay.primal.net", + "wss://relay.nostr.band", +]); + +export const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS); + +const globalScope = + typeof window !== "undefined" + ? window + : typeof globalThis !== "undefined" + ? globalThis + : null; + +const nostrToolsReadySource = + globalScope && + globalScope.nostrToolsReady && + typeof globalScope.nostrToolsReady.then === "function" + ? globalScope.nostrToolsReady + : nostrToolsReady; + +export function normalizeToolkitCandidate(candidate) { + if ( + candidate && + typeof candidate === "object" && + candidate.ok !== false && + typeof candidate.then !== "function" + ) { + return candidate; + } + return null; +} + +export function readToolkitFromScope(scope = globalScope) { + if (!scope || typeof scope !== "object") { + return null; + } + + const candidates = []; + + const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__; + if (canonical) { + candidates.push(canonical); + } + + const direct = scope.NostrTools; + if (direct) { + candidates.push(direct); + } + + const nestedWindow = + scope.window && scope.window !== scope && typeof scope.window === "object" + ? scope.window + : null; + if (nestedWindow) { + if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) { + candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__); + } + if (nestedWindow.NostrTools) { + candidates.push(nestedWindow.NostrTools); + } + } + + for (const candidate of candidates) { + const normalized = normalizeToolkitCandidate(candidate); + if (normalized) { + return normalized; + } + } + + return null; +} + +const __nostrToolsBootstrapResult = await (async () => { + try { + const result = await nostrToolsReadySource; + if (result && typeof result === "object" && result.ok === false) { + return { + toolkit: normalizeToolkitCandidate(readToolkitFromScope()), + failure: result, + }; + } + + const normalized = normalizeToolkitCandidate(result); + if (normalized) { + return { toolkit: normalized, failure: null }; + } + + return { + toolkit: normalizeToolkitCandidate(readToolkitFromScope()), + failure: null, + }; + } catch (error) { + return { + toolkit: normalizeToolkitCandidate(readToolkitFromScope()), + failure: error, + }; + } +})(); + +let cachedNostrTools = __nostrToolsBootstrapResult.toolkit || null; +export const nostrToolsBootstrapFailure = + __nostrToolsBootstrapResult.failure || null; + +if (!cachedNostrTools && nostrToolsBootstrapFailure && isDevMode) { + userLogger.warn( + "[nostr] nostr-tools helpers unavailable after bootstrap.", + nostrToolsBootstrapFailure + ); +} + +export function rememberNostrTools(candidate) { + const normalized = normalizeToolkitCandidate(candidate); + if (normalized) { + cachedNostrTools = normalized; + } +} + +export function getCachedNostrTools() { + const fallback = readToolkitFromScope(); + if (cachedNostrTools && fallback && fallback !== cachedNostrTools) { + rememberNostrTools(fallback); + } else if (!cachedNostrTools && fallback) { + rememberNostrTools(fallback); + } + return cachedNostrTools || fallback || null; +} + +export async function ensureNostrTools() { + if (cachedNostrTools) { + return cachedNostrTools; + } + + try { + const result = await nostrToolsReadySource; + rememberNostrTools(result); + } catch (error) { + devLogger.warn("[nostr] Failed to resolve nostr-tools helpers.", error); + } + + if (!cachedNostrTools) { + rememberNostrTools(readToolkitFromScope()); + } + + return cachedNostrTools || null; +} + +function isSimplePoolConstructor(candidate) { + if (typeof candidate !== "function") { + return false; + } + + const prototype = candidate.prototype; + if (!prototype || typeof prototype !== "object") { + return false; + } + + return ( + typeof prototype.sub === "function" && + typeof prototype.close === "function" + ); +} + +function unwrapSimplePool(candidate) { + if (!candidate) { + return null; + } + + if (isSimplePoolConstructor(candidate)) { + return candidate; + } + + if (typeof candidate === "object") { + if (isSimplePoolConstructor(candidate.SimplePool)) { + return candidate.SimplePool; + } + if (isSimplePoolConstructor(candidate.default)) { + return candidate.default; + } + } + + return null; +} + +export function resolveSimplePoolConstructor(tools, scope = globalScope) { + const candidates = [ + tools?.SimplePool, + tools?.pool?.SimplePool, + tools?.pool, + tools?.SimplePool?.SimplePool, + tools?.SimplePool?.default, + tools?.pool?.default, + tools?.default?.SimplePool, + tools?.default?.pool?.SimplePool, + tools?.default?.pool, + ]; + + if (scope && typeof scope === "object") { + candidates.push(scope?.SimplePool); + candidates.push(scope?.pool?.SimplePool); + candidates.push(scope?.pool); + const scopedTools = + scope?.NostrTools && scope.NostrTools !== tools ? scope.NostrTools : null; + if (scopedTools) { + candidates.push(scopedTools.SimplePool); + candidates.push(scopedTools.pool?.SimplePool); + candidates.push(scopedTools.pool); + } + } + + for (const candidate of candidates) { + const resolved = unwrapSimplePool(candidate); + if (resolved) { + return resolved; + } + } + + return null; +} + +export { isSimplePoolConstructor, unwrapSimplePool }; diff --git a/js/relayManager.js b/js/relayManager.js index 114afa1b..85db9a86 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -1,6 +1,6 @@ import { isDevMode } from "./config.js"; +import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { - DEFAULT_RELAY_URLS, getActiveSigner, nostrClient, requestDefaultExtensionPermissions, diff --git a/js/subscriptions.js b/js/subscriptions.js index f4f3ba40..4b04c137 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -4,8 +4,8 @@ import { nostrClient, convertEventToVideo as sharedConvertEventToVideo, requestDefaultExtensionPermissions, - DEFAULT_RELAY_URLS, } from "./nostr.js"; +import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER diff --git a/tests/nostr/toolkit.test.mjs b/tests/nostr/toolkit.test.mjs new file mode 100644 index 00000000..6a700dbf --- /dev/null +++ b/tests/nostr/toolkit.test.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const originalCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; +const originalNostrTools = globalThis.NostrTools; +const originalNostrToolsReady = globalThis.nostrToolsReady; + +class BootstrapSimplePool { + sub() {} + close() {} +} + +const bootstrapToolkit = { + ok: true, + nip19: { + decode: (value) => value, + npubEncode: (value) => value, + }, + SimplePool: BootstrapSimplePool, +}; + +globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = bootstrapToolkit; +globalThis.NostrTools = bootstrapToolkit; +globalThis.nostrToolsReady = Promise.resolve(bootstrapToolkit); + +const toolkitModule = await import("../../js/nostr/toolkit.js"); +const { + rememberNostrTools, + getCachedNostrTools, + ensureNostrTools, + resolveSimplePoolConstructor, +} = toolkitModule; + +const baselineToolkit = getCachedNostrTools() || bootstrapToolkit; + +test.after(() => { + if (originalCanonical === undefined) { + delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + } else { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = originalCanonical; + } + + if (originalNostrTools === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = originalNostrTools; + } + + if (originalNostrToolsReady === undefined) { + delete globalThis.nostrToolsReady; + } else { + globalThis.nostrToolsReady = originalNostrToolsReady; + } + + rememberNostrTools(baselineToolkit); +}); + +test("ensureNostrTools resolves the cached toolkit", async () => { + class CachedSimplePool { + sub() {} + close() {} + } + + const cachedToolkit = { + ok: true, + nip19: { + decode: (value) => value, + npubEncode: (value) => value, + }, + SimplePool: CachedSimplePool, + }; + + const previousCanonicalForTest = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + const previousNostrToolsForTest = globalThis.NostrTools; + + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = cachedToolkit; + globalThis.NostrTools = cachedToolkit; + rememberNostrTools(cachedToolkit); + + try { + const ensured = await ensureNostrTools(); + assert.strictEqual(ensured, cachedToolkit); + } finally { + if (previousCanonicalForTest === undefined) { + delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + } else { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonicalForTest; + } + + if (previousNostrToolsForTest === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = previousNostrToolsForTest; + } + + rememberNostrTools(baselineToolkit); + } +}); + +test("resolveSimplePoolConstructor handles supported permutations", () => { + class MockSimplePool { + sub() {} + close() {} + } + + const variations = [ + { label: "tools.SimplePool", tools: { SimplePool: MockSimplePool } }, + { label: "tools.pool.SimplePool", tools: { pool: { SimplePool: MockSimplePool } } }, + { label: "tools.pool constructor", tools: { pool: MockSimplePool } }, + { + label: "tools.SimplePool.SimplePool", + tools: { SimplePool: { SimplePool: MockSimplePool } }, + }, + { + label: "tools.SimplePool.default", + tools: { SimplePool: { default: MockSimplePool } }, + }, + { label: "tools.pool.default", tools: { pool: { default: MockSimplePool } } }, + { + label: "tools.default.SimplePool", + tools: { default: { SimplePool: MockSimplePool } }, + }, + { + label: "tools.default.pool.SimplePool", + tools: { default: { pool: { SimplePool: MockSimplePool } } }, + }, + { + label: "tools.default.pool constructor", + tools: { default: { pool: MockSimplePool } }, + }, + ]; + + for (const { label, tools } of variations) { + assert.strictEqual( + resolveSimplePoolConstructor(tools), + MockSimplePool, + `expected ${label}` + ); + } + + assert.strictEqual( + resolveSimplePoolConstructor({}, { SimplePool: MockSimplePool }), + MockSimplePool, + "scope.SimplePool fallback" + ); + + assert.strictEqual( + resolveSimplePoolConstructor({}, { pool: { SimplePool: MockSimplePool } }), + MockSimplePool, + "scope.pool.SimplePool fallback" + ); + + assert.strictEqual( + resolveSimplePoolConstructor( + {}, + { NostrTools: { pool: { SimplePool: MockSimplePool } } } + ), + MockSimplePool, + "scope.NostrTools.pool.SimplePool fallback" + ); + + assert.strictEqual( + resolveSimplePoolConstructor({}, {}), + null, + "returns null when no candidates are available" + ); +});