mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 08:22:24 +00:00
Three CI-enforced lint scripts to prevent common codebase growth problems: - lint:file-size — enforces 1000-line max for new JS files, grandfathers existing oversized files and prevents them from growing further - lint:innerhtml — tracks innerHTML usage per-file with a baseline count, blocks new innerHTML additions (guides agents toward safe DOM APIs) - Relay constants consolidated from duplicate definitions in client.js and ConnectionManager.js into shared js/nostr/relayConstants.js - AGENTS.md Section 13: documents all three rules with examples - docs/scheduled-agent-prompts.md: copy-paste prompts for recurring file decomposition, innerHTML migration, and constant dedup tasks https://claude.ai/code/session_01LzErpH9FNw4hAPsjoYb1v9
965 lines
27 KiB
JavaScript
965 lines
27 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";
|
|
|
|
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(() => {
|
|
const instance = new SimplePool();
|
|
|
|
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, 60000, {
|
|
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 = 60000, { 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, 60000, {
|
|
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, 60000, {
|
|
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, 60000, {
|
|
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,
|
|
};
|
|
}
|
|
}
|