diff --git a/index.html b/index.html index 8e86ca16..3d2af74c 100644 --- a/index.html +++ b/index.html @@ -198,156 +198,7 @@ - + diff --git a/js/nostrToolsBootstrap.js b/js/nostrToolsBootstrap.js new file mode 100644 index 00000000..6e496f68 --- /dev/null +++ b/js/nostrToolsBootstrap.js @@ -0,0 +1,424 @@ +const REMOTE_IMPORT_TIMEOUT = 4500; +const LOCAL_IMPORT_TIMEOUT = 2500; +const SCRIPT_FALLBACK_TIMEOUT = 6000; +const CDN_BUNDLE_URL = "https://cdn.jsdelivr.net/npm/nostr-tools@2.10.4/lib/nostr.bundle.min.js"; + +let bootstrapPromise = null; + +const getGlobalScope = () => { + if (typeof window !== "undefined") { + return window; + } + if (typeof globalThis !== "undefined") { + return globalThis; + } + return null; +}; + +const hasWorkingNip04 = (candidate) => + !!( + candidate && + typeof candidate === "object" && + typeof candidate.encrypt === "function" && + typeof candidate.decrypt === "function" + ); + +const toSerializableError = (error) => { + if (!error) { + return null; + } + if (error instanceof Error) { + return { + message: error.message, + name: error.name, + }; + } + if (typeof error === "string") { + return { message: error }; + } + try { + return { ...error }; + } catch (serializationError) { + return { message: String(error) }; + } +}; + +const withTimeout = (factory, ms, label) => + new Promise((resolve) => { + const start = Date.now(); + let settled = false; + let timeoutId; + const finalize = (result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeoutId); + resolve({ + label, + duration: Date.now() - start, + ...result, + }); + }; + + const runner = Promise.resolve().then(factory); + + timeoutId = setTimeout(() => { + finalize({ + ok: false, + timedOut: true, + error: new Error( + `[bitvid][nostr-tools] ${label} timed out after ${ms}ms` + ), + }); + runner.catch(() => {}); + }, ms); + + runner.then( + (value) => finalize({ ok: true, value, timedOut: false }), + (error) => finalize({ ok: false, error, timedOut: false }) + ); + }); + +const discoverLocalEntrypoints = (scope) => { + if (!scope || typeof scope !== "object") { + return []; + } + + const entrypoints = []; + const configuredEntrypoints = scope.__BITVID_LOCAL_NOSTR_TOOLS_ENTRY__; + if (typeof configuredEntrypoints === "string") { + entrypoints.push(configuredEntrypoints); + } else if (Array.isArray(configuredEntrypoints)) { + configuredEntrypoints + .filter((candidate) => typeof candidate === "string" && candidate) + .forEach((candidate) => entrypoints.push(candidate)); + } + + if (scope.document) { + const localScripts = scope.document.querySelectorAll( + "script[data-nostr-tools-entry]" + ); + localScripts.forEach((script) => { + const candidate = script.getAttribute("data-nostr-tools-entry"); + if (candidate) { + entrypoints.push(candidate); + } + }); + } + + return Array.from(new Set(entrypoints)); +}; + +const extractToolsFromModule = (moduleLike) => { + if (!moduleLike || typeof moduleLike !== "object") { + return null; + } + const { default: defaultExport, ...named } = moduleLike; + if (defaultExport && typeof defaultExport === "object") { + return { ...defaultExport, ...named }; + } + return { ...named }; +}; + +const ensureGlobalReadyPromise = (scope, promise) => { + if (!scope || typeof scope !== "object") { + return; + } + try { + Object.defineProperty(scope, "nostrToolsReady", { + configurable: true, + enumerable: false, + writable: true, + value: promise, + }); + } catch (error) { + scope.nostrToolsReady = promise; + } +}; + +export function bootstrapNostrTools() { + if (bootstrapPromise) { + return bootstrapPromise; + } + + const scope = getGlobalScope(); + + bootstrapPromise = (async () => { + if (!scope) { + const error = { + ok: false, + reason: "Global scope is unavailable.", + attempts: [], + }; + return error; + } + + const attempts = []; + + const remoteImports = [ + withTimeout( + () => import("https://esm.sh/nostr-tools@1.8.3"), + REMOTE_IMPORT_TIMEOUT, + "esm-main" + ), + withTimeout( + () => + import( + "https://esm.sh/nostr-tools@1.8.3?target=es2022&exports=nip04" + ), + REMOTE_IMPORT_TIMEOUT, + "esm-nip04" + ), + ]; + + const localEntrypoints = discoverLocalEntrypoints(scope); + const localImports = localEntrypoints.map((entry, index) => + withTimeout( + () => import(entry), + LOCAL_IMPORT_TIMEOUT, + `local-${index}` + ) + ); + + const dynamicResults = await Promise.allSettled([ + ...remoteImports, + ...localImports, + ]); + + const resolvedModules = []; + let resolvedNip04 = null; + + dynamicResults.forEach((settledResult) => { + if (settledResult.status !== "fulfilled") { + return; + } + const result = settledResult.value; + attempts.push({ + target: result.label, + ok: result.ok, + timedOut: !!result.timedOut, + duration: result.duration, + error: result.ok ? null : toSerializableError(result.error), + }); + if (!result.ok) { + return; + } + if (result.label === "esm-nip04" && hasWorkingNip04(result.value?.nip04)) { + resolvedNip04 = result.value.nip04; + return; + } + const extracted = extractToolsFromModule(result.value); + if (extracted && Object.keys(extracted).length > 0) { + resolvedModules.push({ source: result.label, tools: extracted }); + if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) { + resolvedNip04 = extracted.nip04; + } + } + }); + + dynamicResults.forEach((settledResult) => { + if (settledResult.status !== "fulfilled") { + const { reason } = settledResult; + attempts.push({ + target: "unknown", + ok: false, + timedOut: false, + duration: 0, + error: toSerializableError(reason), + }); + return; + } + const result = settledResult.value; + if (result && !attempts.find((entry) => entry.target === result.label)) { + attempts.push({ + target: result.label, + ok: result.ok, + timedOut: !!result.timedOut, + duration: result.duration, + error: result.ok ? null : toSerializableError(result.error), + }); + } + }); + + const allDynamicTimedOut = + attempts.length > 0 && + attempts.every( + (attempt) => + attempt && + attempt.target && + attempt.target.startsWith("esm") + ? attempt.timedOut + : true + ); + + const shouldAttemptScriptFallback = + !!scope.document && (resolvedModules.length === 0 || allDynamicTimedOut); + + if (shouldAttemptScriptFallback) { + const scriptLoadResult = await withTimeout( + () => + new Promise((resolve, reject) => { + const script = scope.document.createElement("script"); + script.src = CDN_BUNDLE_URL; + script.async = true; + script.onload = () => resolve(scope?.NostrTools || null); + script.onerror = () => + reject(new Error("nostr-tools CDN bundle failed to load")); + scope.document.head.appendChild(script); + }), + SCRIPT_FALLBACK_TIMEOUT, + "cdn-bundle" + ); + + attempts.push({ + target: scriptLoadResult.label, + ok: scriptLoadResult.ok, + timedOut: !!scriptLoadResult.timedOut, + duration: scriptLoadResult.duration, + error: scriptLoadResult.ok + ? null + : toSerializableError(scriptLoadResult.error), + }); + + if (scriptLoadResult.ok) { + const extracted = extractToolsFromModule(scriptLoadResult.value); + if (extracted && Object.keys(extracted).length > 0) { + resolvedModules.push({ source: "cdn-bundle", tools: extracted }); + if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) { + resolvedNip04 = extracted.nip04; + } + } + } + } + + const existingGlobalTools = scope?.NostrTools; + if (existingGlobalTools && typeof existingGlobalTools === "object") { + resolvedModules.push({ source: "global", tools: existingGlobalTools }); + if (!resolvedNip04 && hasWorkingNip04(existingGlobalTools?.nip04)) { + resolvedNip04 = existingGlobalTools.nip04; + } + } + + if (resolvedModules.length === 0 && !resolvedNip04) { + const failure = { + ok: false, + reason: "Failed to resolve any nostr-tools helpers.", + attempts, + }; + console.warn("[bitvid] nostr-tools bootstrap failed", failure); + return failure; + } + + const toolSources = resolvedModules.map((entry) => entry.tools); + const canonicalTools = Object.assign({}, ...toolSources); + + const normalizedGeneratePrivateKey = + typeof canonicalTools.generatePrivateKey === "function" + ? canonicalTools.generatePrivateKey + : typeof canonicalTools.generateSecretKey === "function" + ? canonicalTools.generateSecretKey + : undefined; + + const normalizedGenerateSecretKey = + typeof canonicalTools.generateSecretKey === "function" + ? canonicalTools.generateSecretKey + : typeof canonicalTools.generatePrivateKey === "function" + ? canonicalTools.generatePrivateKey + : undefined; + + if (normalizedGeneratePrivateKey) { + canonicalTools.generatePrivateKey = normalizedGeneratePrivateKey; + } + if (normalizedGenerateSecretKey) { + canonicalTools.generateSecretKey = normalizedGenerateSecretKey; + } + + if (resolvedNip04) { + canonicalTools.nip04 = resolvedNip04; + console.info("[bitvid] Initialized nostr nip04 helpers."); + } else { + console.warn( + "[bitvid] NIP-04 helpers unavailable after bootstrap attempts." + ); + } + + const frozenCanonical = Object.freeze({ ...canonicalTools }); + + try { + Object.defineProperty(scope, "__BITVID_CANONICAL_NOSTR_TOOLS__", { + configurable: true, + enumerable: false, + writable: false, + value: frozenCanonical, + }); + } catch (error) { + scope.__BITVID_CANONICAL_NOSTR_TOOLS__ = frozenCanonical; + } + + let activeTools = canonicalTools; + const mergeWithCanonical = (candidate) => { + if (!candidate || typeof candidate !== "object") { + return activeTools; + } + const merged = Object.assign({}, frozenCanonical, candidate); + if (!merged.nip04 && frozenCanonical.nip04) { + merged.nip04 = frozenCanonical.nip04; + } + if ( + typeof merged.generatePrivateKey !== "function" && + typeof merged.generateSecretKey === "function" + ) { + merged.generatePrivateKey = merged.generateSecretKey; + } + if ( + typeof merged.generateSecretKey !== "function" && + typeof merged.generatePrivateKey === "function" + ) { + merged.generateSecretKey = merged.generatePrivateKey; + } + return merged; + }; + + const applyActive = (candidate) => { + activeTools = mergeWithCanonical(candidate); + return activeTools; + }; + + try { + Object.defineProperty(scope, "NostrTools", { + configurable: true, + enumerable: true, + get() { + return activeTools; + }, + set(value) { + applyActive(value); + }, + }); + } catch (error) { + console.warn("[bitvid] Failed to install NostrTools guard.", error); + } + + applyActive(existingGlobalTools); + scope.NostrTools = canonicalTools; + + return frozenCanonical; + })() + .catch((error) => ({ + ok: false, + reason: "nostr-tools bootstrap threw unexpectedly.", + error: toSerializableError(error), + })); + + ensureGlobalReadyPromise(scope, bootstrapPromise); + + return bootstrapPromise; +} + +const nostrToolsReady = bootstrapNostrTools(); + +export { nostrToolsReady }; +export default nostrToolsReady; + +await nostrToolsReady;