Improve relay hygiene and count status

This commit is contained in:
thePR0M3TH3AN committed 2026-01-29 09:58:35 -05:00
1 parent 6ae4b7ab4f
commit c3e986c9d3
12 files changed
+286 -59

No files matched your search

+6 -2
View File
@@ -1488,7 +1488,7 @@ class Application {
this.videoModal.setViewCountPointer(pointerKey);
}
try {
const token = subscribeToVideoViewCount(pointer, ({ total, status }) => {
const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => {
const latestViewEl = this.videoModal?.getViewCountElement() || null;
if (!latestViewEl) {
return;
@@ -1497,10 +1497,12 @@ class Application {
if (Number.isFinite(total)) {
const numeric = Number(total);
if (this.videoModal) {
const label = this.formatViewCountLabel(numeric);
this.videoModal.updateViewCountLabel(
this.formatViewCountLabel(numeric)
partial ? `${label} (partial)` : label
);
}
latestViewEl.dataset.viewCountState = partial ? "partial" : "ready";
return;
}
@@ -1508,10 +1510,12 @@ class Application {
if (this.videoModal) {
this.videoModal.updateViewCountLabel("Loading views…");
}
latestViewEl.dataset.viewCountState = "hydrating";
} else {
if (this.videoModal) {
this.videoModal.updateViewCountLabel("– views");
}
latestViewEl.dataset.viewCountState = status;
}
});
+135 -26
View File
@@ -1261,6 +1261,8 @@ export class NostrClient {
this.sessionActorCipherClosuresPrivateKey = null;
this.countUnsupportedRelays = new Set();
this.unreachableRelays = new Set();
this.relayBackoff = new Map();
this.relayFailureCounts = new Map();
this.isInitialized = false;
this.nip46Client = null;
this.pendingHandshakeCancel = null;
@@ -4303,8 +4305,9 @@ export class NostrClient {
// `unsub` to avoid leaking subscriptions. Note: any future change must still
// provide a lightweight readiness check with similar timeout semantics.
async connectToRelays() {
const relayTargets = this.getHealthyRelays(this.relays);
const results = await Promise.all(
this.relays.map(
relayTargets.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
@@ -4326,9 +4329,11 @@ export class NostrClient {
for (const result of results) {
if (result.success) {
this.unreachableRelays.delete(result.url);
this.clearRelayBackoff(result.url);
} else {
this.unreachableRelays.add(result.url);
this.markRelayUnreachable(result.url, 60000, {
reason: "connect-timeout",
});
if (isDevMode) {
devLogger.warn(`[nostr] Marked relay as unreachable: ${result.url}`);
}
@@ -4338,7 +4343,21 @@ export class NostrClient {
return results;
}
markRelayUnreachable(url, ttlMs = 60000) {
resolveRelayBackoffMs(failureCount, ttlOverride) {
const baseMs = 15000;
const maxMs = 5 * 60 * 1000;
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.max(computed, Math.floor(override));
}
return computed;
}
clearRelayBackoff(url) {
if (!url || typeof url !== "string") {
return;
}
@@ -4346,14 +4365,34 @@ export class NostrClient {
if (!normalized) {
return;
}
this.relayBackoff.delete(normalized);
this.relayFailureCounts.delete(normalized);
this.unreachableRelays.delete(normalized);
}
markRelayUnreachable(url, ttlMs = 60000, { reason = null } = {}) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
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);
if (ttlMs > 0) {
setTimeout(() => {
this.unreachableRelays.delete(normalized);
if (isDevMode) {
devLogger.debug(`[nostr] Cleared temporary failure mark for ${normalized}`);
}
}, ttlMs);
if (isDevMode) {
devLogger.debug(
`[nostr] Relay backoff set for ${normalized} (${backoffMs}ms).`,
);
}
}
@@ -4362,7 +4401,26 @@ export class NostrClient {
if (!this.unreachableRelays.size) {
return source;
}
return source.filter((url) => !this.unreachableRelays.has(url));
const now = Date.now();
return source.filter((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.unreachableRelays.delete(url);
if (isDevMode) {
devLogger.debug(`[nostr] Relay backoff expired for ${url}.`);
}
return true;
});
}
/**
@@ -7764,13 +7822,25 @@ export class NostrClient {
try {
relay = await this.pool.ensureRelay(normalizedUrl);
} catch (error) {
throw new Error(`Failed to connect to relay ${normalizedUrl}`);
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) {
throw new Error(
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];
@@ -7822,16 +7892,27 @@ export class NostrClient {
}
const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs);
const rawResult = await withRequestTimeout(
countPromise,
timeoutMs,
() => {
if (relay?.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
},
`COUNT request timed out after ${timeoutMs}ms`
);
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 }];
@@ -7840,7 +7921,7 @@ export class NostrClient {
async countEventsAcrossRelays(filters, options = {}) {
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
return { total: 0, best: null, perRelay: [] };
return { total: 0, best: null, perRelay: [], partial: false };
}
const relayList =
@@ -7854,6 +7935,8 @@ export class NostrClient {
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
const eligibleRelays = this.getHealthyRelays(normalizedRelayList);
const eligibleRelaySet = new Set(eligibleRelays);
const activeRelays = [];
const precomputedEntries = [];
@@ -7868,6 +7951,15 @@ export class NostrClient {
});
continue;
}
if (!eligibleRelaySet.has(url)) {
precomputedEntries.push({
url,
ok: false,
skipped: true,
reason: "backoff",
});
continue;
}
activeRelays.push(url);
}
@@ -7878,15 +7970,28 @@ export class NostrClient {
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);
this.markRelayUnreachable(url, 60000, {
reason: isTimeout ? "count-timeout" : "count-error",
});
}
return { url, ok: false, error, unsupported: isUnsupported };
return {
url,
ok: false,
error,
unsupported: isUnsupported,
timedOut: isTimeout,
errorCode: error?.code || null,
};
}
})
);
@@ -7940,11 +8045,15 @@ export class NostrClient {
});
const total = bestEstimate ? bestEstimate.count : 0;
const partial = perRelayResults.some(
(entry) => entry?.timedOut || entry?.skipped
);
return {
total,
best: bestEstimate,
perRelay,
partial,
};
}
+8 -5
View File
@@ -13,6 +13,7 @@ import { CACHE_POLICIES } from "./cachePolicies.js";
import { isSessionActor } from "./sessionActor.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT];
@@ -32,13 +33,15 @@ function getAllowedCommentKinds() {
return ALLOWED_COMMENT_KINDS.slice();
}
function sanitizeRelayList(primary, fallback) {
if (Array.isArray(primary) && primary.length) {
return primary;
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
}
if (Array.isArray(fallback) && fallback.length) {
return fallback;
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
}
return RELAY_URLS;
return sanitizeRelayUrls(RELAY_URLS);
}
function normalizeRelay(candidate) {
+8 -5
View File
@@ -9,17 +9,20 @@ import { devLogger, userLogger } from "../utils/logger.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
import { isSessionActor } from "./sessionActor.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const DEFAULT_TYPING_EXPIRY_SECONDS = 15;
function sanitizeRelayList(primary, fallback) {
if (Array.isArray(primary) && primary.length) {
return primary;
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
}
if (Array.isArray(fallback) && fallback.length) {
return fallback;
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
}
return RELAY_URLS;
return sanitizeRelayUrls(RELAY_URLS);
}
function normalizeString(value) {
+37 -1
View File
@@ -43,6 +43,28 @@ export const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([
"nip04",
]);
function isSecureRelayContext() {
if (typeof window === "undefined") {
return false;
}
const protocol = window?.location?.protocol;
return protocol === "https:";
}
function resolveRelayProtocol(protocol, { enforceTls = false } = {}) {
if (!protocol) {
return null;
}
const normalized = protocol.toLowerCase();
if (normalized === "wss:") {
return "wss:";
}
if (normalized === "ws:") {
return enforceTls ? "wss:" : "ws:";
}
return null;
}
/**
* resolves the appropriate storage mechanism (localStorage or polyfill) for NIP-46 sessions.
* @returns {Storage|null} The storage interface or null if unavailable.
@@ -72,6 +94,7 @@ export function sanitizeRelayList(list) {
if (!Array.isArray(list)) {
return sanitized;
}
const enforceTls = isSecureRelayContext();
list.forEach((value) => {
if (typeof value !== "string") {
@@ -90,16 +113,29 @@ export function sanitizeRelayList(list) {
let normalized = trimmed.replace(/\/+$/, "");
try {
const parsed = new URL(trimmed);
const parsed = new URL(normalized);
if (!parsed.hostname) {
return;
}
const resolvedProtocol = resolveRelayProtocol(parsed.protocol, {
enforceTls,
});
if (!resolvedProtocol) {
return;
}
if (parsed.protocol !== resolvedProtocol) {
parsed.protocol = resolvedProtocol;
}
const pathname = parsed.pathname.replace(/\/+$/, "");
normalized = `${parsed.protocol}//${parsed.host}${pathname}${parsed.search || ""}`;
} catch (error) {
// Ignore URL parsing failures and fall back to the trimmed string.
}
if (enforceTls && normalized.startsWith("ws://")) {
return;
}
if (seen.has(normalized)) {
return;
}
+8 -5
View File
@@ -7,18 +7,21 @@ import { LRUCache } from "../utils/lruCache.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import { NOTE_TYPES } from "../nostrEventSchemas.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_REACTION];
const reactionCache = new LRUCache({ maxSize: 100 });
function sanitizeRelayList(primary, fallback) {
if (Array.isArray(primary) && primary.length) {
return primary;
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
}
if (Array.isArray(fallback) && fallback.length) {
return fallback;
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
}
return RELAY_URLS;
return sanitizeRelayUrls(RELAY_URLS);
}
function normalizeString(value) {
+18 -6
View File
@@ -14,6 +14,7 @@ import { normalizePointerInput } from "./watchHistory.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { logViewCountFailure } from "./countDiagnostics.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
@@ -340,13 +341,15 @@ function flattenListResults(input) {
}
function sanitizeRelayList(primary, fallback) {
if (Array.isArray(primary) && primary.length) {
return primary;
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
}
if (Array.isArray(fallback) && fallback.length) {
return fallback;
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
}
return RELAY_URLS;
return sanitizeRelayUrls(RELAY_URLS);
}
export async function listVideoViewEvents(client, pointer, options = {}) {
@@ -563,6 +566,7 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
perRelay: [],
best: null,
fallback: true,
partial: false,
};
}
@@ -588,19 +592,26 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
throw normalizeAbortError();
}
let countAttemptPartial = false;
try {
const result = await client.countEventsAcrossRelays([pointerFilter], {
relays: sanitizeRelayList(relayList, client.relays),
timeoutMs: options?.timeoutMs,
});
countAttemptPartial = Boolean(result?.partial);
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
return { ...result, fallback: false };
return { ...result, fallback: false, partial: countAttemptPartial };
}
} catch (error) {
if (error?.code !== "count-unsupported") {
logViewCountFailure(error);
}
if (error?.code === "count-timeout" || error?.code === "timeout") {
countAttemptPartial = true;
}
}
if (signal?.aborted) {
@@ -647,6 +658,7 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
perRelay: [],
best: null,
fallback: true,
partial: countAttemptPartial,
};
}
+16
View File
@@ -39,6 +39,10 @@ function normalizeRelayUrl(input) {
return null;
}
const enforceTls =
typeof window !== "undefined" &&
window?.location?.protocol === "https:";
let candidate = input.trim();
if (!candidate) {
return null;
@@ -59,6 +63,18 @@ function normalizeRelayUrl(input) {
return null;
}
if (enforceTls && parsed.protocol === "ws:") {
try {
parsed = new URL(candidate.replace(/^ws:/i, "wss:"));
} catch (error) {
return null;
}
}
if (enforceTls && parsed.protocol !== "wss:") {
return null;
}
const hostname = parsed.hostname.toLowerCase();
const port = parsed.port ? `:${parsed.port}` : "";
let pathname = parsed.pathname || "";
+11 -4
View File
@@ -4,6 +4,8 @@ import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js";
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
const COUNT_PARTIAL_TITLE =
"Some relays timed out. Comment counts may be partial.";
function toPositiveInteger(value, fallback) {
const numeric = Number(value);
@@ -99,10 +101,11 @@ export default class DiscussionCountService {
}
const total = Number(result?.total);
const isPartial = Boolean(result?.partial);
const normalized =
Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
this.videoDiscussionCountCache.set(video.id, normalized);
this.updateDiscussionCountElement(container, normalized);
this.updateDiscussionCountElement(container, normalized, { partial: isPartial });
return result;
})
.catch((error) => {
@@ -204,7 +207,7 @@ export default class DiscussionCountService {
element.removeAttribute("title");
}
updateDiscussionCountElement(element, count) {
updateDiscussionCountElement(element, count, { partial = false } = {}) {
if (!element) {
return;
}
@@ -217,9 +220,13 @@ export default class DiscussionCountService {
const numeric = Number(count);
const safeValue =
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
element.dataset.countState = "ready";
element.dataset.countState = partial ? "partial" : "ready";
valueEl.textContent = safeValue.toLocaleString();
element.removeAttribute("title");
if (partial) {
element.title = COUNT_PARTIAL_TITLE;
} else {
element.removeAttribute("title");
}
}
markDiscussionCountError(element, { unsupported = false } = {}) {
+10 -4
View File
@@ -6201,11 +6201,16 @@ export class VideoModal {
try {
const token = subscribeToVideoViewCount(
pointerInfo.pointer,
({ total, status }) => {
({ total, status, partial }) => {
if (!viewEl || !viewEl.isConnected) {
return;
}
viewEl.textContent = this.getViewCountLabel(total, status);
viewEl.textContent = this.getViewCountLabel(total, status, partial);
if (partial) {
viewEl.dataset.viewCountState = "partial";
} else {
viewEl.dataset.viewCountState = status;
}
}
);
return { pointer: pointerInfo.pointer, token };
@@ -6216,9 +6221,10 @@ export class VideoModal {
}
}
getViewCountLabel(total, status) {
getViewCountLabel(total, status, partial) {
if (Number.isFinite(total)) {
return this.formatViewCountLabel(Number(total));
const label = this.formatViewCountLabel(Number(total));
return partial ? `${label} (partial)` : label;
}
if (status === "hydrating") {
return "Loading…";
+14 -1
View File
@@ -1134,16 +1134,20 @@ export class VideoListView {
elements: new Set(),
lastTotal: null,
lastStatus: "idle",
lastPartial: false,
lastText: "– views",
};
try {
const token = subscribeToVideoViewCount(pointerInfo.pointer, ({ total, status }) => {
const token = subscribeToVideoViewCount(pointerInfo.pointer, ({ total, status, partial }) => {
let text;
if (Number.isFinite(total)) {
const numeric = Number(total);
entry.lastTotal = numeric;
text = this.formatters.formatViewCountLabel(numeric);
if (partial) {
text = `${text} (partial)`;
}
} else if (status === "hydrating") {
text = "Loading views…";
} else {
@@ -1151,6 +1155,7 @@ export class VideoListView {
}
entry.lastStatus = status;
entry.lastPartial = Boolean(partial);
entry.lastText = text;
for (const el of Array.from(entry.elements)) {
@@ -1159,6 +1164,11 @@ export class VideoListView {
continue;
}
el.textContent = text;
if (entry.lastPartial) {
el.dataset.viewCountState = "partial";
} else {
el.dataset.viewCountState = status;
}
}
});
entry.token = token;
@@ -1193,6 +1203,9 @@ export class VideoListView {
if (typeof pointerInfo.key === "string") {
viewCountEl.dataset.viewPointer = pointerInfo.key;
}
viewCountEl.dataset.viewCountState = entry.lastPartial
? "partial"
: entry.lastStatus;
viewCountEl.textContent = entry.lastText;
}
+15
View File
@@ -50,6 +50,7 @@ restoreCacheSnapshot();
* @property {Map<string, number>} dedupeBuckets
* @property {number} lastSyncedAt
* @property {"idle"|"hydrating"|"live"|"stale"} status
* @property {boolean} partial
*/
/**
@@ -278,6 +279,7 @@ function restoreCacheSnapshot() {
dedupeBuckets,
lastSyncedAt,
status: status === "hydrating" || status === "live" ? "idle" : status,
partial: Boolean(state.partial),
});
}
} catch (error) {
@@ -299,6 +301,7 @@ function persistCacheSnapshot() {
dedupeBuckets: Array.from(state.dedupeBuckets.entries()),
lastSyncedAt: state.lastSyncedAt,
status: state.status,
partial: Boolean(state.partial),
},
]);
}
@@ -321,6 +324,7 @@ function ensurePointerState(key) {
dedupeBuckets: new Map(),
lastSyncedAt: 0,
status: "idle",
partial: false,
};
pointerStates.set(key, state);
} else if (!(state.dedupeBuckets instanceof Map)) {
@@ -373,6 +377,7 @@ function notifyHandlers(key) {
total: state.total,
lastSyncedAt: state.lastSyncedAt,
status: state.status,
partial: Boolean(state.partial),
};
for (const handler of listeners.handlers.values()) {
try {
@@ -494,6 +499,15 @@ async function hydratePointer(key, listeners) {
? Number(countResult.total)
: null;
if (countResult && typeof countResult === "object") {
const state = ensurePointerState(key);
const nextPartial = Boolean(countResult.partial);
if (state.partial !== nextPartial) {
state.partial = nextPartial;
mutated = true;
}
}
if (bestCount !== null) {
const state = ensurePointerState(key);
const shouldUpdate = bestCount > state.total || state.total === 0;
@@ -597,6 +611,7 @@ export function subscribeToVideoViewCount(pointerInput, handler, options = {}) {
total: state.total,
lastSyncedAt: state.lastSyncedAt,
status: state.status,
partial: Boolean(state.partial),
});
} catch (error) {
userLogger.warn("[viewCounter] Initial handler invocation threw:", error);