Merge pull request #957 from PR0M3TH3AN/codex/fix-nostr-relay-connection-issues

Fix Nostr bootstrap guard for SimplePool compatibility
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-26 21:42:34 -04:00
commit 4bbb33beb1
4 files changed
+431 -17

No files matched your search

+2
View File
@@ -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;
});
+315 -4
View File
@@ -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 &&
@@ -210,10 +269,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) {
@@ -274,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;
}
+27 -13
View File
@@ -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);
+87
View File
@@ -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"]
);
});