Files
bitvid/js/relayManager.js
T
thePR0M3TH3ANandClaude Opus 4.8 47f2a9a865 [nostr] Fix spurious "failed" errors on unconfirmed list publishes across the board
Same class of bug as the subscription fix, applied everywhere an idempotent,
replaceable list is published with an optimistic UI: when no relay ACKs within the
10s window (slow/flaky relays), the event was still sent and almost always
persists (it shows up after a refresh), but assertAnyRelayAccepted threw -> the UI
showed a scary error / reverted, even though it worked.

- nostrPublish.js: add shared helpers relayPublishFailuresAreAllTimeouts() and
  assertAnyRelayAcceptedOrUnconfirmed() — the latter returns a summary flagged
  { unconfirmed: true } on all-timeout instead of throwing; explicit relay
  rejections (and pre-publish signer/encryption errors) still throw.
- Applied to the replaceable-list publishes: subscriptions (kind 30000, refactored
  to the shared helper), user mute/block list (10000), relay list (10002), DM
  relay hints (10050), hashtag preferences (30015).

Left as-is for now (higher-stakes, separate follow-up): the generic content
publish path (video/edit/delete/revert/rebroadcast) in publishHelpers/client.js —
same latent issue but the video outcome-describer needs careful handling.

Tests: tests/subscription-publish-timeout.test.mjs (8) now cover both the
classifier and assertAnyRelayAcceptedOrUnconfirmed (confirmed->summary,
all-timeout->soft, rejection->throws). hashtag-preferences suite green (14).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:20:58 -04:00

860 lines
24 KiB
JavaScript

import { isDevMode } from "./config.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import {
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import { getActiveSigner } from "./nostr/index.js";
import { buildRelayListEvent } from "./nostrEventSchemas.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
publishEventToRelays,
assertAnyRelayAcceptedOrUnconfirmed,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
const MODE_SEQUENCE = ["both", "read", "write"];
const FAST_RELAY_FETCH_LIMIT = 3;
const FAST_RELAY_TIMEOUT_MS = 2500;
const BACKGROUND_RELAY_TIMEOUT_MS = 6000;
function getTestRelayOverrides() {
if (typeof window === "undefined" || !window.location) {
return null;
}
// 1. Check window globals (injected by Playwright addInitScript)
if (
Array.isArray(window.__bitvidTestRelays__) &&
window.__bitvidTestRelays__.length > 0
) {
return window.__bitvidTestRelays__;
}
// 2. Check URL search params
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get("__testRelays__");
if (raw) {
const candidates = raw.split(",").map((u) => u.trim()).filter(Boolean);
if (candidates.length) {
return candidates;
}
}
} catch (error) {
// Ignore URL parsing errors
}
// 3. Check persisted test relays
try {
const stored = localStorage.getItem("__bitvidTestRelays__");
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
}
} catch (error) {
// Ignore storage/JSON errors
}
return null;
}
function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (!/^[0-9a-f]{64}$/i.test(trimmed)) {
return null;
}
return trimmed.toLowerCase();
}
function normalizeRelayUrl(input) {
if (typeof input !== "string") {
return null;
}
const enforceTls =
typeof window !== "undefined" &&
window?.location?.protocol === "https:";
let candidate = input.trim();
if (!candidate) {
return null;
}
if (!/^[a-z]+:\/\//i.test(candidate)) {
candidate = `wss://${candidate}`;
}
let parsed;
try {
parsed = new URL(candidate);
} catch (error) {
return null;
}
if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") {
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 || "";
if (pathname !== "/") {
pathname = pathname.replace(/\/+$/, "");
}
if (pathname === "/") {
pathname = "";
}
const search = parsed.search || "";
const hash = parsed.hash || "";
return `${parsed.protocol}//${hostname}${port}${pathname}${search}${hash}`;
}
function deriveMode(marker) {
if (typeof marker !== "string") {
return "both";
}
const normalized = marker.trim().toLowerCase();
if (normalized === "read") {
return "read";
}
if (normalized === "write") {
return "write";
}
return "both";
}
function createEntry(url, mode = "both") {
const normalizedMode = deriveMode(mode);
return {
url,
mode: normalizedMode,
read: normalizedMode !== "write",
write: normalizedMode !== "read",
};
}
function cloneEntry(entry) {
return {
url: entry.url,
mode: entry.mode,
read: !!entry.read,
write: !!entry.write,
};
}
function serializeEntries(entries) {
return JSON.stringify(
entries.map((entry) => ({ url: entry.url, mode: entry.mode }))
);
}
function parseRelayTags(tags) {
if (!Array.isArray(tags)) {
return [];
}
const seen = new Map();
const order = [];
tags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return;
}
if (tag[0] !== "r") {
return;
}
const normalizedUrl = normalizeRelayUrl(tag[1]);
if (!normalizedUrl) {
return;
}
if (!seen.has(normalizedUrl)) {
seen.set(normalizedUrl, { read: false, write: false });
order.push(normalizedUrl);
}
const record = seen.get(normalizedUrl);
const mode = deriveMode(tag[2]);
if (mode === "read") {
record.read = true;
} else if (mode === "write") {
record.write = true;
} else {
record.read = true;
record.write = true;
}
});
return order.map((url) => {
const record = seen.get(url) || { read: true, write: true };
if (record.read && record.write) {
return createEntry(url, "both");
}
if (record.read) {
return createEntry(url, "read");
}
if (record.write) {
return createEntry(url, "write");
}
return createEntry(url, "both");
});
}
function resolveEntryInput(entry) {
if (typeof entry === "string") {
return { url: entry, mode: "both" };
}
if (!entry || typeof entry !== "object") {
return null;
}
const url = typeof entry.url === "string" ? entry.url : "";
let mode = entry.mode || entry.marker;
if (!mode) {
if (entry.read === true && entry.write === false) {
mode = "read";
} else if (entry.write === true && entry.read === false) {
mode = "write";
}
}
return { url, mode: deriveMode(mode) };
}
class RelayPreferencesManager {
constructor() {
this.entries = [];
this.entryIndex = new Map();
this.lastEvent = null;
this.loadedPubkey = null;
this.lastLoadSource = "default";
let defaults = DEFAULT_RELAY_URLS;
try {
if (typeof window !== "undefined" && Array.isArray(window.__bitvidTestRelays__)) {
defaults = window.__bitvidTestRelays__;
} else {
const testRelays = localStorage.getItem("__bitvidTestRelays__");
if (testRelays) {
const parsed = JSON.parse(testRelays);
if (Array.isArray(parsed) && parsed.length) {
defaults = parsed;
}
}
}
} catch (e) {
// ignore
}
this.defaultEntries = defaults.map((url) => {
const item = typeof url === "string" ? url : url?.url;
const normalized = normalizeRelayUrl(item) || item;
return createEntry(normalized, "both");
});
const testOverrides = getTestRelayOverrides();
if (testOverrides) {
const entries = testOverrides.map((url) =>
createEntry(normalizeRelayUrl(url) || url, "both")
);
this.setEntries(entries, { allowEmpty: false, updateClient: true });
} else {
// Attempt to load from storage if policy allows
const loaded = this.loadFromStorage();
if (loaded && loaded.length) {
this.setEntries(loaded, { allowEmpty: false, updateClient: true });
} else {
this.setEntries(this.defaultEntries, {
allowEmpty: false,
updateClient: true,
});
}
}
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
const fresh = this.loadFromStorage();
if (fresh && fresh.length) {
this.setEntries(fresh, { allowEmpty: false, updateClient: true });
} else {
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
}
}
});
}
loadFromStorage() {
try {
if (typeof window !== "undefined" && Array.isArray(window.__bitvidTestRelays__)) {
return window.__bitvidTestRelays__.map((item) => {
if (typeof item === "string") return createEntry(item, "both");
return item;
});
}
const testRelays = localStorage.getItem("__bitvidTestRelays__");
if (testRelays) {
const parsed = JSON.parse(testRelays);
if (Array.isArray(parsed) && parsed.length) {
// Normalize plain URLs to entry objects if needed
return parsed.map((item) => {
if (typeof item === "string") return createEntry(item, "both");
return item;
});
}
}
} catch (error) {
// Ignore storage errors in restricted contexts
}
const cached = profileCache.get("relays");
if (Array.isArray(cached)) {
return cached;
}
return null;
}
saveToStorage() {
profileCache.set("relays", this.entries.map((entry) => ({ url: entry.url, mode: entry.mode })));
}
snapshot() {
return this.getEntries();
}
getEntries() {
return this.entries.map((entry) => cloneEntry(entry));
}
getAllRelayUrls() {
return this.entries.map((entry) => entry.url);
}
getReadRelayUrls() {
return this.entries.filter((entry) => entry.read).map((entry) => entry.url);
}
getWriteRelayUrls() {
return this.entries.filter((entry) => entry.write).map((entry) => entry.url);
}
syncClient() {
if (nostrClient && typeof nostrClient.applyRelayPreferences === "function") {
nostrClient.applyRelayPreferences({
all: this.getAllRelayUrls(),
read: this.getReadRelayUrls(),
write: this.getWriteRelayUrls(),
});
} else if (nostrClient) {
nostrClient.relays = this.getAllRelayUrls();
const writeUrls = this.getWriteRelayUrls();
if (Array.isArray(writeUrls) && writeUrls.length) {
nostrClient.writeRelays = [...writeUrls];
} else {
nostrClient.writeRelays = this.getAllRelayUrls();
}
}
}
setEntries(entries, { allowEmpty = false, updateClient = true } = {}) {
const nextEntries = [];
const indexMap = new Map();
if (Array.isArray(entries)) {
entries.forEach((item) => {
const resolved = resolveEntryInput(item);
if (!resolved) {
return;
}
const normalizedUrl = normalizeRelayUrl(resolved.url);
if (!normalizedUrl || indexMap.has(normalizedUrl)) {
return;
}
const normalizedEntry = createEntry(normalizedUrl, resolved.mode);
indexMap.set(normalizedUrl, nextEntries.length);
nextEntries.push(normalizedEntry);
});
}
if (!nextEntries.length && !allowEmpty) {
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
if (updateClient) {
this.syncClient();
}
return this.getEntries();
}
this.entries = nextEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
if (!this.entries.length && !allowEmpty) {
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
}
if (updateClient) {
this.syncClient();
}
this.saveToStorage();
return this.getEntries();
}
addRelay(url, mode = "both") {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid WSS relay URL.");
error.code = "invalid";
throw error;
}
if (this.entryIndex.has(normalizedUrl)) {
return {
changed: false,
reason: "duplicate",
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
const nextEntries = this.getEntries();
nextEntries.push({ url: normalizedUrl, mode });
this.setEntries(nextEntries, { allowEmpty: false });
return {
changed: true,
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
updateRelayMode(url, mode = "both") {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
return { changed: false, reason: "missing" };
}
const normalizedMode = deriveMode(mode);
const index = this.entryIndex.get(normalizedUrl);
const current = this.entries[index];
if (current.mode === normalizedMode) {
return { changed: false, entry: cloneEntry(current) };
}
const nextEntries = this.getEntries();
nextEntries[index].mode = normalizedMode;
this.setEntries(nextEntries, { allowEmpty: false });
return {
changed: true,
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
cycleRelayMode(url) {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
const error = new Error("Relay not found.");
error.code = "missing";
throw error;
}
const index = this.entryIndex.get(normalizedUrl);
const currentMode = this.entries[index].mode || "both";
const currentIdx = MODE_SEQUENCE.indexOf(currentMode);
const nextMode = MODE_SEQUENCE[(currentIdx + 1) % MODE_SEQUENCE.length];
return this.updateRelayMode(normalizedUrl, nextMode);
}
removeRelay(url) {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL to remove.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
return { changed: false, reason: "missing" };
}
if (this.entries.length <= 1) {
const error = new Error("At least one relay is required.");
error.code = "minimum";
throw error;
}
const nextEntries = this.getEntries().filter((entry) => entry.url !== normalizedUrl);
this.setEntries(nextEntries, { allowEmpty: false });
return { changed: true };
}
restoreDefaults() {
const before = serializeEntries(this.entries);
this.setEntries(this.defaultEntries, { allowEmpty: false });
const after = serializeEntries(this.entries);
return { changed: before !== after };
}
reset() {
this.lastEvent = null;
this.loadedPubkey = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
}
getPublishTargets(customTargets = null) {
const targets = new Set();
const list =
Array.isArray(customTargets) && customTargets.length
? customTargets
: this.getAllRelayUrls();
list.forEach((candidate) => {
const normalized = normalizeRelayUrl(candidate);
if (normalized) {
targets.add(normalized);
}
});
DEFAULT_RELAY_URLS.forEach((url) => {
const normalized = normalizeRelayUrl(url) || url;
targets.add(normalized);
});
return Array.from(targets);
}
async loadRelayList(pubkey) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
this.loadedPubkey = normalizedPubkey;
if (!normalizedPubkey) {
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: false, reason: "invalid-pubkey" };
}
if (!nostrClient?.pool) {
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: false, reason: "nostr-uninitialized" };
}
const filter = { kinds: [10002], authors: [normalizedPubkey], limit: 1 };
const targetRelays = this.getPublishTargets();
const applyEvents = (events, { skipIfEmpty = false } = {}) => {
if (!Array.isArray(events) || !events.length) {
if (skipIfEmpty) {
return null;
}
this.lastEvent = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", events: [] };
}
const sorted = events
.filter((event) => event && event.pubkey === normalizedPubkey)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
if (!sorted.length) {
if (skipIfEmpty) {
return null;
}
this.lastEvent = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", events: [] };
}
const newest = sorted[0];
this.lastEvent = newest;
const parsed = parseRelayTags(newest?.tags);
if (parsed.length) {
this.setEntries(parsed, { allowEmpty: false });
this.lastLoadSource = "event";
return { ok: true, source: "event", event: newest };
}
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", event: newest };
};
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
const timeoutError = new Error(
`Timed out fetching relay list from ${relayUrl} after ${timeoutMs}ms`
);
timeoutError.code = "timeout";
timeoutError.relay = relayUrl;
timeoutError.timeoutMs = timeoutMs;
reject(timeoutError);
}, timeoutMs);
Promise.resolve()
.then(() => nostrClient.pool.list([relayUrl], [filter]))
.then((result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const events = Array.isArray(result)
? result.filter((event) => event && event.pubkey === normalizedPubkey)
: [];
if (requireEvent && !events.length) {
const emptyError = new Error(
`No relay list events returned from ${relayUrl}`
);
emptyError.code = "empty";
emptyError.relay = relayUrl;
reject(emptyError);
return;
}
resolve({ relayUrl, events });
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const wrapped =
error instanceof Error ? error : new Error(String(error));
wrapped.relay = relayUrl;
reject(wrapped);
});
});
};
const fastRelays = targetRelays.slice(0, FAST_RELAY_FETCH_LIMIT);
const backgroundRelays = targetRelays.slice(fastRelays.length);
const fastPromises = fastRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, FAST_RELAY_TIMEOUT_MS, true)
);
const backgroundPromises = backgroundRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, BACKGROUND_RELAY_TIMEOUT_MS, false)
);
const background = Promise.allSettled([
...fastPromises,
...backgroundPromises,
])
.then((outcomes) => {
const aggregated = [];
outcomes.forEach((outcome) => {
if (outcome.status === "fulfilled") {
const events = Array.isArray(outcome.value?.events)
? outcome.value.events
: [];
if (events.length) {
aggregated.push(...events);
}
} else {
const reason = outcome.reason;
if (reason?.code === "timeout") {
devLogger.warn(
`[relayManager] Relay ${reason.relay} timed out while loading relay list (${reason.timeoutMs}ms)`
);
} else devLogger.warn(
`[relayManager] Relay ${reason?.relay || "unknown"} failed while loading relay list:`,
reason
);
}
});
if (!aggregated.length) {
return;
}
applyEvents(aggregated, { skipIfEmpty: true });
})
.catch((error) => {
devLogger.warn("[relayManager] Background relay refresh failed", error);
});
let fastResult = null;
if (fastPromises.length) {
try {
fastResult = await Promise.any(fastPromises);
} catch (error) {
if (error instanceof AggregateError) {
error.errors?.forEach((err) => {
if (err?.code === "timeout" && isDevMode) {
userLogger.warn(
`[relayManager] Relay ${err.relay} timed out while loading relay list (${err.timeoutMs}ms)`
);
}
});
} else devLogger.warn("[relayManager] Fast relay fetch failed", error);
}
}
if (fastResult?.events?.length) {
const result = applyEvents(fastResult.events);
background.catch(() => {});
return result;
}
const fallback = applyEvents([], { skipIfEmpty: false });
background.catch(() => {});
return fallback;
}
async publishRelayList(pubkey, options = {}) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
const error = new Error(
"A valid pubkey is required to publish relay preferences."
);
error.code = "invalid-pubkey";
throw error;
}
const signer = getActiveSigner();
if (!signer || typeof signer.signEvent !== "function") {
const error = new Error(
"An active signer with signEvent support is required to publish relay preferences."
);
error.code = "nostr-extension-missing";
throw error;
}
if (signer.type === "extension" || signer.type === "nip07") {
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult.ok) {
userLogger.warn(
"[RelayPreferencesManager] Signer permissions denied while publishing relay preferences.",
permissionResult.error,
);
const error = new Error(
"The active signer must allow signing before publishing relay preferences.",
);
error.code = "extension-permission-denied";
error.cause = permissionResult.error;
throw error;
}
}
if (!nostrClient?.pool) {
const error = new Error(
"Nostr is not connected yet. Please try again once relays are ready."
);
error.code = "nostr-uninitialized";
throw error;
}
const entries = this.getEntries();
if (!entries.length) {
const error = new Error("Add at least one relay before publishing.");
error.code = "empty";
throw error;
}
const event = buildRelayListEvent({
pubkey: normalizedPubkey,
created_at: Math.floor(Date.now() / 1000),
relays: entries,
});
const signedEvent = await signer.signEvent(event);
const targets = this.getPublishTargets(options?.relayUrls);
if (!targets.length) {
const error = new Error("No relay targets are available for publishing.");
error.code = "no-targets";
throw error;
}
const publishResults = await publishEventToRelays(
nostrClient.pool,
targets,
signedEvent
);
// An all-timeout (unconfirmed) publish of the replaceable relay list is a soft
// success — sent + almost always persisted; don't error/revert. Explicit
// rejections still throw.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "relay preferences update",
message: "No relays accepted the update.",
});
if (publishSummary.unconfirmed) {
userLogger.warn(
"[RelayPreferencesManager] Relay list not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).",
);
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
userLogger.warn(
`[RelayPreferencesManager] Relay ${url} did not acknowledge relay list: ${reason}`,
relayError
);
});
}
const accepted = publishSummary.accepted.map(({ url }) => url);
const failed = publishSummary.failed.map(({ url, error: relayError }) => ({
url,
reason: relayError || null,
}));
this.lastEvent = signedEvent;
return { ok: true, event: signedEvent, accepted, failed, targets };
}
getLastLoadSource() {
return this.lastLoadSource;
}
}
export const relayManager = new RelayPreferencesManager();