Files
bitvid/js/nostr/managers/ConnectionManager.js
T
thePR0M3TH3ANandClaude Opus 4.8 874897b933 perf(nostr): verify event signatures off the main thread
Profiling the live app showed the single biggest CPU cost was the relay pool
verifying every incoming event's schnorr signature synchronously on the main
thread: ~1.2s of secp256k1 work ([email protected] 855ms) during feed load and tab
switches, dropping to ~16ms when skipped. The pool was constructed as
`new SimplePool()` with no options, so nostr-tools used its default verifying
verifyEvent; the `verifyEvent: () => true` that looked like it disabled this
only existed on a mock tools object and was never wired to the real pool.

Move verification off the main thread instead of dropping it:
- Construct the pool with `verifyEvent: () => true` so it never blocks.
- Add signatureVerifyWorker (+ client) that verifies event batches in a Web
  Worker and returns the valid ids; falls back to main-thread verification only
  when Workers are unavailable (never silently trusts).
- VideoEventBuffer verifies each batch via the worker before committing, and
  drops events that fail (fails closed). The verifier is injectable so tests can
  virtualize the worker; subscribeVideos passes it through and returns the flush
  promise from EOSE so commits are awaitable.

Tests: new signatureVerifyWorkerClient.test.mjs (worker protocol + drop-invalid
+ empty/no-id handling); videoEventBuffer.test.mjs and client.test.mjs adapted
to the async, verified commit path with a passthrough verifier.

Note: tests/nostr-delete-flow.test.mjs has a PRE-EXISTING failure on
`should not trigger subscription callbacks` (fails on clean unstable, and is a
standalone script not run by the unit-test runner); its edits here only adapt it
to the now-async flush.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 11:16:25 -04:00

972 lines
28 KiB
JavaScript

import {
DEFAULT_RELAY_URLS,
RELAY_URLS,
ensureNostrTools,
resolveSimplePoolConstructor,
shimLegacySimplePoolMethods,
nostrToolsBootstrapFailure,
} from "../toolkit.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { sanitizeRelayList } from "../nip46Client.js";
import {
logCountTimeoutCleanupFailure,
logRelayCountFailure,
} from "../countDiagnostics.js";
import {
RELAY_CONNECT_TIMEOUT_MS,
RELAY_RECONNECT_BASE_DELAY_MS,
RELAY_RECONNECT_MAX_DELAY_MS,
RELAY_RECONNECT_MAX_ATTEMPTS,
RELAY_BACKOFF_BASE_DELAY_MS,
RELAY_BACKOFF_MAX_DELAY_MS,
RELAY_CIRCUIT_BREAKER_THRESHOLD,
RELAY_CIRCUIT_BREAKER_COOLDOWN_MS,
RELAY_FAILURE_WINDOW_MS,
RELAY_FAILURE_WINDOW_THRESHOLD,
RELAY_SUMMARY_LOG_INTERVAL_MS,
} from "../relayConstants.js";
import { LONG_TIMEOUT_MS } from "../../constants.js";
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
const resolvedTimeout = Number(timeoutMs);
const effectiveTimeout =
Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
? Math.floor(resolvedTimeout)
: 4000;
let timeoutId = null;
let settled = false;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
if (settled) {
return;
}
settled = true;
if (typeof onTimeout === "function") {
try {
onTimeout();
} catch (cleanupError) {
logCountTimeoutCleanupFailure(cleanupError);
}
}
reject(new Error(message));
}, effectiveTimeout);
Promise.resolve(promise)
.then((value) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
reject(error);
});
});
}
export class ConnectionManager {
constructor() {
this.pool = null;
this.poolPromise = null;
this.relays = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
this.readRelays = Array.from(this.relays);
this.writeRelays = Array.from(this.relays);
this.countUnsupportedRelays = new Set();
this.unreachableRelays = new Set();
this.relayBackoff = new Map();
this.relayFailureCounts = new Map();
this.relayFailureWindows = new Map();
this.relayCircuitBreakers = new Map();
this.relaySummaryLogTimestamps = new Map();
this.relayReconnectTimer = null;
this.relayReconnectAttempt = 0;
this.countRequestCounter = 0;
}
async ensurePool() {
if (this.pool) {
return this.pool;
}
if (this.poolPromise) {
return this.poolPromise;
}
const tools = await ensureNostrTools();
const SimplePool = resolveSimplePoolConstructor(tools);
if (typeof SimplePool !== "function") {
if (tools && typeof tools === "object") {
const availableKeys = Object.keys(tools).join(", ");
devLogger.warn(
"[nostr] NostrTools helpers did not expose SimplePool. Available keys:",
availableKeys
);
} else {
userLogger.warn(
"[nostr] NostrTools helpers were unavailable. Check that nostr-tools bundles can load on this domain."
);
}
if (nostrToolsBootstrapFailure) {
userLogger.warn(
"[nostr] nostr-tools bootstrap failure details:",
nostrToolsBootstrapFailure
);
}
const error = new Error(
"NostrTools SimplePool is unavailable. Verify that nostr-tools resources can load on this domain."
);
error.code = "nostr-simplepool-unavailable";
if (nostrToolsBootstrapFailure) {
error.bootstrapFailure = nostrToolsBootstrapFailure;
}
this.poolPromise = null;
throw error;
}
const creation = Promise.resolve().then(() => {
// PERF (pending security decision — see PR discussion): the default
// SimplePool verifies every incoming event's schnorr signature on the main
// thread, which profiling showed costs ~1.2s of blocking CPU during feed
// load / tab switches (it dropped to ~16ms when skipped). Skipping trusts
// relays not to forge authorship. Alternatives: verify in a Web Worker, or
// only skip for the user's own trustedRelayURLs.
const instance = new SimplePool({ verifyEvent: () => true });
if (typeof instance.ensureRelay === "function") {
const originalEnsureRelay = instance.ensureRelay.bind(instance);
instance.ensureRelay = async (url) => {
const relay = await originalEnsureRelay(url);
if (relay && typeof relay.setMaxListeners === "function") {
try {
relay.setMaxListeners(200);
} catch (error) {
// ignore
}
}
return relay;
};
} else {
devLogger.warn(
"[nostr] SimplePool.ensureRelay missing; max listeners patch skipped."
);
}
shimLegacySimplePoolMethods(instance);
this.pool = instance;
return instance;
});
const shared = creation
.then((instance) => {
this.poolPromise = Promise.resolve(instance);
return instance;
})
.catch((error) => {
this.poolPromise = null;
throw error;
});
this.poolPromise = shared;
return shared;
}
async connectToRelays() {
const relayTargets = this.getHealthyRelays(this.relays);
if (!relayTargets.length) {
return [];
}
const results = await Promise.all(
relayTargets.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, RELAY_CONNECT_TIMEOUT_MS);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
for (const result of results) {
if (result.success) {
this.clearRelayBackoff(result.url);
} else {
this.markRelayUnreachable(result.url, LONG_TIMEOUT_MS, {
reason: "connect-timeout",
});
if (isDevMode) {
devLogger.warn(`[nostr] Marked relay as unreachable: ${result.url}`);
}
}
}
return results;
}
resolveRelayReconnectDelayMs(attempt) {
const safeAttempt = Number.isFinite(attempt) ? Math.max(0, attempt) : 0;
const computed = RELAY_RECONNECT_BASE_DELAY_MS * Math.pow(2, safeAttempt);
return Math.min(RELAY_RECONNECT_MAX_DELAY_MS, computed);
}
resetRelayReconnectState() {
this.relayReconnectAttempt = 0;
if (this.relayReconnectTimer) {
clearTimeout(this.relayReconnectTimer);
this.relayReconnectTimer = null;
}
}
scheduleRelayReconnect({ reason = "retry" } = {}) {
if (this.relayReconnectTimer) {
return;
}
if (this.relayReconnectAttempt >= RELAY_RECONNECT_MAX_ATTEMPTS) {
if (isDevMode) {
devLogger.debug(
"[nostr] Relay reconnect attempts exhausted.",
{
attempts: this.relayReconnectAttempt,
reason,
},
);
}
return;
}
const delayMs = this.resolveRelayReconnectDelayMs(this.relayReconnectAttempt);
const attemptNumber = this.relayReconnectAttempt + 1;
this.relayReconnectAttempt = attemptNumber;
if (isDevMode) {
devLogger.debug("[nostr] Scheduling relay reconnect attempt.", {
attempt: attemptNumber,
delayMs,
reason,
});
}
this.relayReconnectTimer = setTimeout(async () => {
this.relayReconnectTimer = null;
try {
const results = await this.connectToRelays();
const successful = results.some((result) => result.success);
if (successful) {
this.resetRelayReconnectState();
return;
}
} catch (error) {
devLogger.warn("[nostr] Relay reconnect attempt failed:", error);
}
this.scheduleRelayReconnect({ reason: "reconnect-failed" });
}, delayMs);
}
logRelaySummary({
key,
level = "warn",
message,
payload,
} = {}) {
if (!key || !message) {
return;
}
const now = Date.now();
const lastLogged = this.relaySummaryLogTimestamps.get(key) || 0;
if (now - lastLogged < RELAY_SUMMARY_LOG_INTERVAL_MS) {
return;
}
this.relaySummaryLogTimestamps.set(key, now);
const channel = isDevMode ? devLogger : userLogger;
const logFn =
typeof channel[level] === "function" ? channel[level] : channel.warn;
if (payload) {
logFn(`[nostr] ${message}`, payload);
} else {
logFn(`[nostr] ${message}`);
}
}
resolveRelayBackoffMs(failureCount, ttlOverride) {
const baseMs = RELAY_BACKOFF_BASE_DELAY_MS;
const maxMs = RELAY_BACKOFF_MAX_DELAY_MS;
const computed = Math.min(
maxMs,
baseMs * Math.pow(2, Math.max(0, failureCount - 1))
);
const override = Number(ttlOverride);
if (Number.isFinite(override) && override > 0) {
return Math.min(computed, Math.floor(override));
}
return computed;
}
clearRelayBackoff(url) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
const hadBackoffState =
this.relayBackoff.has(normalized) ||
this.unreachableRelays.has(normalized) ||
this.relayFailureCounts.has(normalized) ||
this.relayCircuitBreakers.has(normalized);
this.relayBackoff.delete(normalized);
this.relayFailureCounts.delete(normalized);
this.relayFailureWindows.delete(normalized);
this.unreachableRelays.delete(normalized);
this.relayCircuitBreakers.delete(normalized);
if (hadBackoffState) {
this.logRelaySummary({
key: `relay-recovered:${normalized}`,
level: "info",
message: "Relay recovered; backoff cleared.",
payload: { relay: normalized },
});
}
}
recordRelayFailureWindow(url) {
if (!url || typeof url !== "string") {
return 0;
}
const normalized = url.trim();
if (!normalized) {
return 0;
}
const now = Date.now();
const cutoff = now - RELAY_FAILURE_WINDOW_MS;
const history = this.relayFailureWindows.get(normalized) || [];
const filtered = history.filter(
(timestamp) => Number.isFinite(timestamp) && timestamp >= cutoff,
);
filtered.push(now);
this.relayFailureWindows.set(normalized, filtered);
return filtered.length;
}
markRelayUnreachable(url, ttlMs = LONG_TIMEOUT_MS, { reason = null } = {}) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
const windowFailureCount = this.recordRelayFailureWindow(normalized);
const nextFailureCount = (this.relayFailureCounts.get(normalized) || 0) + 1;
this.relayFailureCounts.set(normalized, nextFailureCount);
const backoffMs = this.resolveRelayBackoffMs(nextFailureCount, ttlMs);
const retryAt = Date.now() + backoffMs;
this.relayBackoff.set(normalized, {
retryAt,
backoffMs,
failureCount: nextFailureCount,
reason,
});
this.unreachableRelays.add(normalized);
const shouldOpenCircuit =
nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD ||
windowFailureCount >= RELAY_FAILURE_WINDOW_THRESHOLD;
if (shouldOpenCircuit) {
const now = Date.now();
const openUntil = now + RELAY_CIRCUIT_BREAKER_COOLDOWN_MS;
const existing = this.relayCircuitBreakers.get(normalized);
const nextOpenUntil =
existing && Number.isFinite(existing.openUntil)
? Math.max(existing.openUntil, openUntil)
: openUntil;
const circuitReason =
reason ||
(nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD
? "consecutive-failures"
: "windowed-failures");
this.relayCircuitBreakers.set(normalized, {
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
});
this.logRelaySummary({
key: `relay-circuit:${normalized}`,
level: "warn",
message: "Circuit breaker opened for relay.",
payload: {
relay: normalized,
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
},
});
}
this.logRelaySummary({
key: `relay-backoff:${normalized}`,
level: "warn",
message: "Relay backoff applied.",
payload: {
relay: normalized,
backoffMs,
failureCount: nextFailureCount,
retryAt,
reason,
},
});
}
getHealthyRelays(candidates) {
const source = Array.isArray(candidates) ? candidates : this.relays;
if (!this.unreachableRelays.size) {
return source;
}
const now = Date.now();
return source.filter((url) => {
const circuit = this.relayCircuitBreakers.get(url);
if (circuit && Number.isFinite(circuit.openUntil)) {
if (circuit.openUntil > now) {
return false;
}
this.relayCircuitBreakers.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.relayBackoff.delete(url);
this.logRelaySummary({
key: `relay-circuit-reset:${url}`,
level: "info",
message: "Circuit breaker reset for relay.",
payload: { relay: url },
});
}
if (!this.unreachableRelays.has(url)) {
return true;
}
const entry = this.relayBackoff.get(url);
if (!entry) {
return false;
}
if (Number.isFinite(entry.retryAt) && entry.retryAt > now) {
return false;
}
this.relayBackoff.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.logRelaySummary({
key: `relay-backoff-expired:${url}`,
level: "info",
message: "Relay backoff expired.",
payload: { relay: url },
});
return true;
});
}
applyRelayPreferences(preferences = {}) {
const normalizedPrefs =
preferences && typeof preferences === "object" ? preferences : {};
const sanitizedAll = sanitizeRelayList(
Array.isArray(normalizedPrefs.all)
? normalizedPrefs.all
: this.relays
);
const effectiveAll =
sanitizedAll.length > 0
? sanitizedAll
: sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
const sanitizedRead = sanitizeRelayList(
Array.isArray(normalizedPrefs.read)
? normalizedPrefs.read
: effectiveAll
);
const sanitizedWrite = sanitizeRelayList(
Array.isArray(normalizedPrefs.write)
? normalizedPrefs.write
: effectiveAll
);
this.relays = effectiveAll.length ? effectiveAll : Array.from(RELAY_URLS);
this.readRelays = sanitizedRead.length ? sanitizedRead : Array.from(this.relays);
this.writeRelays = sanitizedWrite.length ? sanitizedWrite : Array.from(this.relays);
}
makeCountUnsupportedError(relayUrl) {
const normalizedUrl =
typeof relayUrl === "string" && relayUrl.trim()
? relayUrl.trim()
: "";
const error = new Error(
`[nostr] Relay ${normalizedUrl} does not support COUNT frames.`
);
error.code = "count-unsupported";
error.relay = normalizedUrl;
error.unsupported = true;
return error;
}
getRequestTimeoutMs(timeoutMs) {
const candidate = Number(timeoutMs);
if (Number.isFinite(candidate) && candidate > 0) {
return Math.floor(candidate);
}
const poolTimeout = Number(this.pool?.getTimeout);
if (Number.isFinite(poolTimeout) && poolTimeout > 0) {
return Math.floor(poolTimeout);
}
return 3400;
}
normalizeCountFilter(filter) {
if (!filter || typeof filter !== "object") {
return null;
}
const normalized = {};
const toStringArray = (value) => {
if (value === undefined || value === null) {
return [];
}
const source = Array.isArray(value) ? value : [value];
const collected = [];
for (const item of source) {
if (typeof item !== "string") {
continue;
}
const trimmed = item.trim();
if (!trimmed || collected.includes(trimmed)) {
continue;
}
collected.push(trimmed);
}
return collected;
};
if (filter.kinds !== undefined) {
const kindsSource = Array.isArray(filter.kinds)
? filter.kinds
: [filter.kinds];
const normalizedKinds = [];
const seenKinds = new Set();
for (const candidate of kindsSource) {
const parsed = Number(candidate);
if (!Number.isFinite(parsed)) {
continue;
}
const normalizedValue = Math.floor(parsed);
if (seenKinds.has(normalizedValue)) {
continue;
}
seenKinds.add(normalizedValue);
normalizedKinds.push(normalizedValue);
}
if (normalizedKinds.length) {
normalized.kinds = normalizedKinds;
}
}
const ids = toStringArray(filter.ids);
if (ids.length) {
normalized.ids = ids;
}
const authors = toStringArray(filter.authors);
if (authors.length) {
normalized.authors = authors;
}
for (const [key, value] of Object.entries(filter)) {
if (!key.startsWith("#")) {
continue;
}
const tagValues = toStringArray(value);
if (tagValues.length) {
normalized[key] = tagValues;
}
}
if (filter.since !== undefined) {
const parsedSince = Number(filter.since);
if (Number.isFinite(parsedSince)) {
normalized.since = Math.floor(parsedSince);
}
}
if (filter.until !== undefined) {
const parsedUntil = Number(filter.until);
if (Number.isFinite(parsedUntil)) {
normalized.until = Math.floor(parsedUntil);
}
}
if (filter.limit !== undefined) {
const parsedLimit = Number(filter.limit);
if (Number.isFinite(parsedLimit) && parsedLimit >= 0) {
normalized.limit = Math.floor(parsedLimit);
}
}
return Object.keys(normalized).length ? normalized : null;
}
normalizeCountFilters(filters) {
if (!filters) {
return [];
}
const list = Array.isArray(filters) ? filters : [filters];
const normalized = [];
for (const candidate of list) {
const normalizedFilter = this.normalizeCountFilter(candidate);
if (normalizedFilter) {
normalized.push(normalizedFilter);
}
}
return normalized;
}
generateCountRequestId(prefix = "count") {
this.countRequestCounter += 1;
if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) {
this.countRequestCounter = 1;
}
const normalizedPrefix =
typeof prefix === "string" && prefix.trim() ? prefix.trim() : "count";
const timestamp = Date.now().toString(36);
const counter = this.countRequestCounter.toString(36);
const random = Math.random().toString(36).slice(2, 8);
return `${normalizedPrefix}:${timestamp}:${counter}${random}`;
}
extractCountValue(payload) {
if (typeof payload === "number") {
const value = Math.floor(payload);
return value >= 0 ? value : 0;
}
if (payload && typeof payload === "object") {
const candidate =
typeof payload.count === "number"
? payload.count
: Number(payload.count);
if (Number.isFinite(candidate)) {
const value = Math.floor(candidate);
return value >= 0 ? value : 0;
}
}
const parsed = Number(payload);
if (Number.isFinite(parsed)) {
const value = Math.floor(parsed);
return value >= 0 ? value : 0;
}
return 0;
}
async sendRawCountFrame(relayUrl, filters, options = {}) {
if (!this.pool) {
throw new Error(
"Nostr pool not initialized. Call ensurePool() before requesting counts."
);
}
const normalizedUrl =
typeof relayUrl === "string" ? relayUrl.trim() : "";
if (!normalizedUrl) {
throw new Error("Invalid relay URL for COUNT request.");
}
if (this.countUnsupportedRelays.has(normalizedUrl)) {
throw this.makeCountUnsupportedError(normalizedUrl);
}
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
throw new Error("At least one filter is required for a COUNT request.");
}
const requestId =
typeof options.subId === "string" && options.subId.trim()
? options.subId.trim()
: this.generateCountRequestId();
let relay;
try {
relay = await this.pool.ensureRelay(normalizedUrl);
} catch (error) {
const connectError = new Error(
`Failed to connect to relay ${normalizedUrl}`
);
connectError.code = "relay-connect-failed";
this.markRelayUnreachable(normalizedUrl, LONG_TIMEOUT_MS, {
reason: "connect-failed",
});
throw connectError;
}
if (!relay) {
const relayError = new Error(
`Relay ${normalizedUrl} is unavailable for COUNT requests.`
);
relayError.code = "relay-unavailable";
this.markRelayUnreachable(normalizedUrl, LONG_TIMEOUT_MS, {
reason: "relay-unavailable",
});
throw relayError;
}
const frame = ["COUNT", requestId, ...normalizedFilters];
let countPromise;
if (
relay.openCountRequests instanceof Map &&
typeof relay.send === "function"
) {
countPromise = new Promise((resolve, reject) => {
const cleanup = () => {
if (relay.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
};
relay.openCountRequests.set(requestId, {
resolve: (value) => {
cleanup();
resolve(value);
},
reject: (error) => {
cleanup();
reject(error);
},
});
let sendResult;
try {
sendResult = relay.send(JSON.stringify(frame));
} catch (error) {
cleanup();
reject(error);
return;
}
if (sendResult && typeof sendResult.catch === "function") {
sendResult.catch((error) => {
cleanup();
reject(error);
});
}
});
} else if (typeof relay.count === "function") {
countPromise = relay.count(normalizedFilters, { id: requestId });
} else {
this.countUnsupportedRelays.add(normalizedUrl);
throw this.makeCountUnsupportedError(normalizedUrl);
}
const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs);
let rawResult;
try {
rawResult = await withRequestTimeout(
countPromise,
timeoutMs,
() => {
if (relay?.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
},
`COUNT request timed out after ${timeoutMs}ms`
);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("COUNT request timed out")
) {
error.code = "count-timeout";
}
throw error;
}
const countValue = this.extractCountValue(rawResult);
return ["COUNT", requestId, { count: countValue }];
}
async countEventsAcrossRelays(filters, options = {}) {
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
return { total: 0, best: null, perRelay: [], partial: false };
}
const relayList =
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS;
const normalizedRelayList = relayList
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
const eligibleRelays = this.getHealthyRelays(normalizedRelayList);
const eligibleRelaySet = new Set(eligibleRelays);
const activeRelays = [];
const precomputedEntries = [];
for (const url of normalizedRelayList) {
if (this.countUnsupportedRelays.has(url)) {
const error = this.makeCountUnsupportedError(url);
precomputedEntries.push({
url,
ok: false,
error,
unsupported: true,
});
continue;
}
if (!eligibleRelaySet.has(url)) {
precomputedEntries.push({
url,
ok: false,
skipped: true,
reason: "backoff",
});
continue;
}
activeRelays.push(url);
}
const activeResults = await Promise.all(
activeRelays.map(async (url) => {
try {
const frame = await this.sendRawCountFrame(url, normalizedFilters, {
timeoutMs: options.timeoutMs,
});
const count = this.extractCountValue(frame?.[2]);
this.clearRelayBackoff(url);
return { url, ok: true, frame, count };
} catch (error) {
const isUnsupported = error?.code === "count-unsupported";
const isTimeout =
error?.code === "count-timeout" || error?.code === "timeout";
if (isUnsupported) {
this.countUnsupportedRelays.add(url);
} else {
logRelayCountFailure(url, error);
// If the count request timed out, we don't want to kill the entire relay connection
// because it might still be good for subscriptions.
// We only trip the circuit breaker for hard errors.
if (!isTimeout) {
this.markRelayUnreachable(url, LONG_TIMEOUT_MS, {
reason: "count-error",
});
} else if (isDevMode) {
devLogger.warn(
`[nostr] Relay ${url} count timed out (ignored for circuit breaker).`
);
}
}
return {
url,
ok: false,
error,
unsupported: isUnsupported,
timedOut: isTimeout,
errorCode: error?.code || null,
};
}
})
);
const resultsByUrl = new Map();
for (const entry of [...precomputedEntries, ...activeResults]) {
if (entry && typeof entry.url === "string") {
resultsByUrl.set(entry.url, entry);
}
}
const perRelayResults = normalizedRelayList.map((url) => {
if (resultsByUrl.has(url)) {
return resultsByUrl.get(url);
}
return { url, ok: false };
});
let bestEstimate = null;
const perRelay = perRelayResults.map((entry) => {
if (!entry || !entry.ok) {
return entry;
}
const numericValue = Number(entry.count);
const normalizedCount =
Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
const normalizedEntry = {
...entry,
count: normalizedCount,
};
if (!Number.isFinite(numericValue) || numericValue < 0) {
normalizedEntry.rawCount = entry.count;
}
if (
!bestEstimate ||
normalizedCount > bestEstimate.count ||
(bestEstimate && normalizedCount === bestEstimate.count && !bestEstimate.frame)
) {
bestEstimate = {
relay: normalizedEntry.url,
count: normalizedCount,
frame: normalizedEntry.frame,
};
}
return normalizedEntry;
});
const total = bestEstimate ? bestEstimate.count : 0;
const partial = perRelayResults.some(
(entry) => entry?.timedOut || entry?.skipped
);
return {
total,
best: bestEstimate,
perRelay,
partial,
};
}
}