From 325a6bd2bf4119a24c8fcde5d0eafc24bf225b8e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:30:14 -0400 Subject: [PATCH 1/2] Fix nostr SimplePool bootstrap --- js/nostr/toolkit.js | 12 ++++++++---- js/nostrToolsBootstrap.js | 40 ++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js index 2cb42138..d6ed7329 100644 --- a/js/nostr/toolkit.js +++ b/js/nostr/toolkit.js @@ -210,10 +210,14 @@ function isSimplePoolConstructor(candidate) { return false; } - return ( - typeof prototype.sub === "function" && - typeof prototype.close === "function" - ); + 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) { diff --git a/js/nostrToolsBootstrap.js b/js/nostrToolsBootstrap.js index c951585c..813af171 100644 --- a/js/nostrToolsBootstrap.js +++ b/js/nostrToolsBootstrap.js @@ -584,19 +584,33 @@ export function bootstrapNostrTools() { return activeTools; }; - try { - Object.defineProperty(scope, "NostrTools", { - configurable: true, - enumerable: true, - get() { - return activeTools; - }, - set(value) { - applyActive(value); - }, - }); - } catch (error) { - userLogger.warn("[bitvid] Failed to install NostrTools guard.", error); + const existingDescriptor = + scope && typeof scope === "object" + ? Object.getOwnPropertyDescriptor(scope, "NostrTools") + : null; + + const canInstallGuard = + !existingDescriptor || existingDescriptor.configurable === true; + + if (canInstallGuard) { + try { + Object.defineProperty(scope, "NostrTools", { + configurable: true, + enumerable: true, + get() { + return activeTools; + }, + set(value) { + applyActive(value); + }, + }); + } catch (error) { + userLogger.warn("[bitvid] Failed to install NostrTools guard.", error); + } + } else { + devLogger.info( + "[bitvid] Skipping NostrTools guard; existing property is non-configurable." + ); } applyActive(existingGlobalTools); From 4183a521d717c8ab05aef2bdf33a471917d3e517 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:41:33 -0400 Subject: [PATCH 2/2] Shim SimplePool for legacy API expectations --- js/nostr/client.js | 2 + js/nostr/toolkit.js | 307 +++++++++++++++++++++++++++++++++++ tests/nostr/toolkit.test.mjs | 87 ++++++++++ 3 files changed, 396 insertions(+) diff --git a/js/nostr/client.js b/js/nostr/client.js index 60f0198c..27249fc3 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -79,6 +79,7 @@ import { nostrToolsBootstrapFailure, readToolkitFromScope, resolveSimplePoolConstructor, + shimLegacySimplePoolMethods, } from "./toolkit.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { @@ -3123,6 +3124,7 @@ export class NostrClient { const creation = Promise.resolve().then(() => { const instance = new SimplePool(); + shimLegacySimplePoolMethods(instance); this.pool = instance; return instance; }); diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js index d6ed7329..ab43e00c 100644 --- a/js/nostr/toolkit.js +++ b/js/nostr/toolkit.js @@ -25,6 +25,65 @@ const globalScope = ? 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 && @@ -278,3 +337,251 @@ export function resolveSimplePoolConstructor(tools, scope = globalScope) { } 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) { + // subscribeMany only accepts a single filter, so reuse subscribeMap-style batching + // by issuing one subscription per filter. + const subs = filterList.map((filter) => + subscribeMany(relayList, filter, subscribeParams) + ); + closer = { + close: (reason) => { + subs.forEach((sub) => { + try { + sub?.close?.(reason); + } catch (error) { + devLogger.warn("[nostr] Failed to close SimplePool subscription.", error); + } + }); + }, + }; + } 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 { + 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) => { + if (!subscription || typeof subscription.on !== "function") { + resolve(events); + 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); + } + resolve(events); + }); + subscription.on("close", () => { + resolve(events); + }); + }); + }; + } + + pool[SIMPLE_POOL_SHIM_KEY] = true; + return pool; +} diff --git a/tests/nostr/toolkit.test.mjs b/tests/nostr/toolkit.test.mjs index 6a700dbf..8205089b 100644 --- a/tests/nostr/toolkit.test.mjs +++ b/tests/nostr/toolkit.test.mjs @@ -29,6 +29,7 @@ const { getCachedNostrTools, ensureNostrTools, resolveSimplePoolConstructor, + shimLegacySimplePoolMethods, } = toolkitModule; const baselineToolkit = getCachedNostrTools() || bootstrapToolkit; @@ -165,3 +166,89 @@ test("resolveSimplePoolConstructor handles supported permutations", () => { "returns null when no candidates are available" ); }); + +test("shimLegacySimplePoolMethods adds legacy sub/list wrappers", async () => { + let capturedRequests = null; + let capturedParams = null; + const closeReasons = []; + + const mockPool = { + subscribeMap(requests, params) { + capturedRequests = requests; + capturedParams = params; + return { + close(reason) { + closeReasons.push(reason); + }, + }; + }, + }; + + shimLegacySimplePoolMethods(mockPool); + + assert.strictEqual(typeof mockPool.sub, "function"); + assert.strictEqual(typeof mockPool.list, "function"); + + const relays = ["wss://relay.one", "wss://relay.two"]; + const filters = [{ kinds: [1] }, { authors: ["abc"] }]; + + const sub = mockPool.sub(relays, filters, { + alreadyHaveEvent(id) { + return id === "skip"; + }, + }); + + assert.ok(Array.isArray(capturedRequests)); + assert.strictEqual(capturedRequests.length, relays.length * filters.length); + assert.ok(capturedRequests.every((entry) => entry && entry.url && entry.filter)); + assert.ok(capturedParams); + assert.strictEqual(typeof capturedParams.onevent, "function"); + assert.strictEqual(typeof capturedParams.oneose, "function"); + assert.strictEqual(typeof capturedParams.onclose, "function"); + assert.strictEqual(typeof capturedParams.alreadyHaveEvent, "function"); + + const receivedEvents = []; + const eosePromise = new Promise((resolve) => sub.on("eose", resolve)); + const closePromise = new Promise((resolve) => sub.on("close", resolve)); + sub.on("event", (event) => { + receivedEvents.push(event.id); + }); + + assert.strictEqual(capturedParams.alreadyHaveEvent("skip"), true); + assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), false); + assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), true); + + capturedParams.onevent({ id: "id-1" }); + capturedParams.onevent({ id: "id-2" }); + capturedParams.oneose(); + capturedParams.onclose(["done"]); + + await eosePromise; + const closePayload = await closePromise; + + assert.deepStrictEqual(receivedEvents, ["id-1", "id-2"]); + assert.deepStrictEqual(closePayload, ["done"]); + + sub.unsub(); + + closeReasons.length = 0; + const immediateUnsub = mockPool.sub(["wss://relay.extra"], [{ kinds: [2] }]); + immediateUnsub.unsub(); + assert.ok(closeReasons.includes("closed by caller")); + + capturedParams = null; + capturedRequests = null; + closeReasons.length = 0; + + const listPromise = mockPool.list(["wss://relay.example"], [{ kinds: [1] }]); + assert.ok(capturedParams); + capturedParams.onevent({ id: "evt-1" }); + capturedParams.onevent({ id: "evt-2" }); + capturedParams.oneose(); + + const listed = await listPromise; + assert.deepStrictEqual( + listed.map((event) => event.id), + ["evt-1", "evt-2"] + ); +});