mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
- Updated `shimLegacySimplePoolMethods` in `js/nostr/toolkit.js` to correctly handle `nostr-tools` v2 subscription closers (which are functions) in `unsub`. This prevents subscription leaks that were causing relays to reject new requests, leading to empty subscription lists. - Updated `bootstrapInterface` in `js/index.js` to await `applicationReadyPromise` before attempting to hydrate the sidebar navigation. This fixes a race condition where the sidebar "Subscriptions" link would remain hidden because the application auth state wasn't fully initialized when the sidebar was rendered.
604 lines
15 KiB
JavaScript
604 lines
15 KiB
JavaScript
// 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 SIMPLE_POOL_SHIM_KEY = Symbol.for("__bitvidSimplePoolShimApplied__");
|
|
|
|
const noop = () => {};
|
|
|
|
function normalizeRelayList(relays) {
|
|
if (!Array.isArray(relays)) {
|
|
if (typeof relays === "string" && relays.trim()) {
|
|
return [relays.trim()];
|
|
}
|
|
return [];
|
|
}
|
|
return relays
|
|
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
|
.filter((relay) => relay);
|
|
}
|
|
|
|
function normalizeFilterList(filters) {
|
|
if (!filters) {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(filters)) {
|
|
if (typeof filters === "object" && filters !== null) {
|
|
return [filters];
|
|
}
|
|
return [];
|
|
}
|
|
return filters.filter((candidate) => candidate && typeof candidate === "object");
|
|
}
|
|
|
|
function safeInvoke(handler, args, contextLabel) {
|
|
if (typeof handler !== "function") {
|
|
return;
|
|
}
|
|
try {
|
|
handler(...args);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
`[nostr] Legacy SimplePool ${contextLabel} handler threw:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
function createLegacySubscriptionContext(relays, filters) {
|
|
const relayList = normalizeRelayList(relays);
|
|
const filterList = normalizeFilterList(filters);
|
|
|
|
const requests = [];
|
|
if (relayList.length && filterList.length) {
|
|
relayList.forEach((url) => {
|
|
filterList.forEach((filter) => {
|
|
requests.push({ url, filter });
|
|
});
|
|
});
|
|
}
|
|
|
|
return { relayList, filterList, requests };
|
|
}
|
|
|
|
const nostrToolsReadySource =
|
|
globalScope &&
|
|
globalScope.nostrToolsReady &&
|
|
typeof globalScope.nostrToolsReady.then === "function"
|
|
? globalScope.nostrToolsReady
|
|
: nostrToolsReady;
|
|
|
|
function ensureCanonicalCrypto(candidate, scope = globalScope) {
|
|
if (!candidate || typeof candidate !== "object") {
|
|
return candidate;
|
|
}
|
|
|
|
const canonical =
|
|
scope && typeof scope === "object"
|
|
? scope.__BITVID_CANONICAL_NOSTR_TOOLS__ || null
|
|
: null;
|
|
|
|
if (!canonical || typeof canonical !== "object") {
|
|
return candidate;
|
|
}
|
|
|
|
const needsNip04 = !candidate.nip04 && canonical.nip04;
|
|
const needsNip44 = !candidate.nip44 && canonical.nip44;
|
|
|
|
if (!needsNip04 && !needsNip44) {
|
|
return candidate;
|
|
}
|
|
|
|
if (Object.isFrozen(candidate)) {
|
|
const augmented = { ...candidate };
|
|
if (needsNip04) {
|
|
augmented.nip04 = canonical.nip04;
|
|
}
|
|
if (needsNip44) {
|
|
augmented.nip44 = canonical.nip44;
|
|
}
|
|
return Object.freeze(augmented);
|
|
}
|
|
|
|
if (needsNip04) {
|
|
candidate.nip04 = canonical.nip04;
|
|
}
|
|
if (needsNip44) {
|
|
candidate.nip44 = canonical.nip44;
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
export function normalizeToolkitCandidate(candidate) {
|
|
if (
|
|
candidate &&
|
|
typeof candidate === "object" &&
|
|
candidate.ok !== false &&
|
|
typeof candidate.then !== "function"
|
|
) {
|
|
return ensureCanonicalCrypto(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;
|
|
}
|
|
|
|
const hasSubscribeMethod =
|
|
typeof prototype.sub === "function" ||
|
|
typeof prototype.subscribe === "function" ||
|
|
typeof prototype.subscribeMany === "function";
|
|
|
|
const hasCloseMethod = typeof prototype.close === "function";
|
|
|
|
return hasSubscribeMethod && hasCloseMethod;
|
|
}
|
|
|
|
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 };
|
|
|
|
export function shimLegacySimplePoolMethods(pool) {
|
|
if (!pool || typeof pool !== "object" || pool[SIMPLE_POOL_SHIM_KEY]) {
|
|
return pool || null;
|
|
}
|
|
|
|
const subscribeMap =
|
|
typeof pool.subscribeMap === "function" ? pool.subscribeMap.bind(pool) : null;
|
|
const subscribeMany =
|
|
typeof pool.subscribeMany === "function" ? pool.subscribeMany.bind(pool) : null;
|
|
const subscribeSingle =
|
|
typeof pool.subscribe === "function" ? pool.subscribe.bind(pool) : null;
|
|
|
|
const canSubscribe = subscribeMap || subscribeMany || subscribeSingle;
|
|
|
|
if (canSubscribe && typeof pool.sub !== "function") {
|
|
pool.sub = function legacySub(relays, filters, opts = {}) {
|
|
const { relayList, filterList, requests } =
|
|
createLegacySubscriptionContext(relays, filters);
|
|
|
|
const listeners = {
|
|
event: new Set(),
|
|
eose: new Set(),
|
|
close: new Set(),
|
|
};
|
|
|
|
if (!requests.length) {
|
|
const stub = {
|
|
sub() {
|
|
return stub;
|
|
},
|
|
on() {
|
|
return stub;
|
|
},
|
|
off() {
|
|
return stub;
|
|
},
|
|
unsub: noop,
|
|
};
|
|
|
|
queueMicrotask(() => {
|
|
listeners.eose.forEach((listener) => safeInvoke(listener, [], "eose listener"));
|
|
listeners.close.forEach((listener) =>
|
|
safeInvoke(listener, [["no-filters"]], "close listener")
|
|
);
|
|
});
|
|
|
|
return stub;
|
|
}
|
|
|
|
const seenIds = new Set();
|
|
|
|
const subscribeParams = { ...opts };
|
|
const originalOnevent = subscribeParams.onevent;
|
|
const originalOneose = subscribeParams.oneose;
|
|
const originalOnclose = subscribeParams.onclose;
|
|
const originalAlreadyHaveEvent = subscribeParams.alreadyHaveEvent;
|
|
delete subscribeParams.onevent;
|
|
delete subscribeParams.oneose;
|
|
delete subscribeParams.onclose;
|
|
delete subscribeParams.alreadyHaveEvent;
|
|
|
|
subscribeParams.alreadyHaveEvent = (id) => {
|
|
if (!id) {
|
|
return false;
|
|
}
|
|
if (seenIds.has(id)) {
|
|
return true;
|
|
}
|
|
let skip = false;
|
|
if (typeof originalAlreadyHaveEvent === "function") {
|
|
try {
|
|
skip = originalAlreadyHaveEvent(id) === true;
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostr] Legacy SimplePool alreadyHaveEvent hook threw:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
if (skip) {
|
|
return true;
|
|
}
|
|
seenIds.add(id);
|
|
return false;
|
|
};
|
|
|
|
let closed = false;
|
|
|
|
const invokeEventListeners = (event) => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
if (event && typeof event === "object" && event.id) {
|
|
seenIds.add(event.id);
|
|
}
|
|
safeInvoke(originalOnevent, [event], "options.onevent");
|
|
listeners.event.forEach((listener) =>
|
|
safeInvoke(listener, [event], "event listener")
|
|
);
|
|
};
|
|
|
|
const invokeEoseListeners = () => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
safeInvoke(originalOneose, [], "options.oneose");
|
|
listeners.eose.forEach((listener) => safeInvoke(listener, [], "eose listener"));
|
|
};
|
|
|
|
const invokeCloseListeners = (reasons) => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
safeInvoke(originalOnclose, [reasons], "options.onclose");
|
|
listeners.close.forEach((listener) =>
|
|
safeInvoke(listener, [reasons], "close listener")
|
|
);
|
|
};
|
|
|
|
subscribeParams.onevent = (event) => {
|
|
invokeEventListeners(event);
|
|
};
|
|
|
|
subscribeParams.oneose = () => {
|
|
invokeEoseListeners();
|
|
};
|
|
|
|
subscribeParams.onclose = (reasons) => {
|
|
invokeCloseListeners(reasons);
|
|
};
|
|
|
|
let closer;
|
|
|
|
try {
|
|
if (subscribeMap) {
|
|
closer = subscribeMap(requests, subscribeParams);
|
|
} else if (subscribeMany) {
|
|
closer = subscribeMany(relayList, filterList, subscribeParams);
|
|
} else if (subscribeSingle && filterList.length) {
|
|
closer = subscribeSingle(relayList, filterList[0], subscribeParams);
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to open SimplePool subscription.", error);
|
|
throw error;
|
|
}
|
|
|
|
const legacySub = {
|
|
sub() {
|
|
devLogger.warn("[nostr] legacy sub.sub() is unsupported with the new SimplePool API.");
|
|
return legacySub;
|
|
},
|
|
on(type, handler) {
|
|
if (typeof handler !== "function") {
|
|
return legacySub;
|
|
}
|
|
if (type === "event") {
|
|
listeners.event.add(handler);
|
|
} else if (type === "eose") {
|
|
listeners.eose.add(handler);
|
|
} else if (type === "close" || type === "closed") {
|
|
listeners.close.add(handler);
|
|
}
|
|
return legacySub;
|
|
},
|
|
off(type, handler) {
|
|
if (typeof handler !== "function") {
|
|
return legacySub;
|
|
}
|
|
if (type === "event") {
|
|
listeners.event.delete(handler);
|
|
} else if (type === "eose") {
|
|
listeners.eose.delete(handler);
|
|
} else if (type === "close" || type === "closed") {
|
|
listeners.close.delete(handler);
|
|
}
|
|
return legacySub;
|
|
},
|
|
unsub() {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
try {
|
|
if (typeof closer === "function") {
|
|
closer();
|
|
} else if (closer && typeof closer.close === "function") {
|
|
closer.close("closed by caller");
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to close SimplePool subscription.", error);
|
|
}
|
|
},
|
|
};
|
|
|
|
return legacySub;
|
|
};
|
|
}
|
|
|
|
if (typeof pool.list !== "function") {
|
|
pool.list = async function legacyList(relays, filters, opts = {}) {
|
|
const events = [];
|
|
let subscription;
|
|
try {
|
|
subscription = this.sub(relays, filters, opts);
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let timer = null;
|
|
|
|
const finish = () => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
resolve(events);
|
|
};
|
|
|
|
if (!subscription || typeof subscription.on !== "function") {
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
subscription.on("event", (event) => {
|
|
events.push(event);
|
|
});
|
|
subscription.on("eose", () => {
|
|
try {
|
|
subscription.unsub?.();
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to unsubscribe after list EOSE.", error);
|
|
}
|
|
finish();
|
|
});
|
|
subscription.on("close", () => {
|
|
finish();
|
|
});
|
|
|
|
// Prevent hanging indefinitely if relays fail to EOSE
|
|
const timeoutMs =
|
|
Number.isFinite(opts.timeout) && opts.timeout > 0
|
|
? opts.timeout
|
|
: 7000;
|
|
|
|
timer = setTimeout(() => {
|
|
devLogger.warn(`[toolkit] pool.list timed out after ${timeoutMs}ms.`);
|
|
timer = null;
|
|
try {
|
|
subscription.unsub?.();
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
finish();
|
|
}, timeoutMs);
|
|
});
|
|
};
|
|
}
|
|
|
|
pool[SIMPLE_POOL_SHIM_KEY] = true;
|
|
return pool;
|
|
}
|