Files
vega/src/lib/nostr/core.ts
Jure 1f50afded7 Add subscription debug panel (Ctrl+Shift+D)
Hidden dev tool showing NDK uptime, live subscription status,
per-relay connection state, per-tab last-updated timestamps,
and recent feed diagnostics log. Polls every 2s while visible.
Closes with Ctrl+Shift+D, Escape, or X button.
2026-03-23 19:12:29 +01:00

194 lines
5.9 KiB
TypeScript

import NDK, { NDKEvent, NDKFilter, NDKRelay, NDKRelaySet, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
// ─── Fetch timeout helper ───────────────────────────────────────────
/** Race a promise against a timeout. Returns fallback on timeout. */
export function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => {
console.warn(`[Wrystr] Fetch timed out after ${ms}ms`);
resolve(fallback);
}, ms)),
]);
}
export const FEED_TIMEOUT = 8000; // 8s for feed fetches
export const THREAD_TIMEOUT = 10000; // 10s per thread round-trip
export const SINGLE_TIMEOUT = 5000; // 5s for single event lookups
const EMPTY_SET = new Set<NDKEvent>();
/** Fetch events with a timeout — returns empty set if relay hangs. */
export async function fetchWithTimeout(
instance: NDK,
filter: NDKFilter,
timeoutMs: number,
relaySet?: NDKRelaySet,
): Promise<Set<NDKEvent>> {
const opts = {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
groupable: false, // Prevent NDK from batching/reusing subscriptions
};
const promise = relaySet
? instance.fetchEvents(filter, opts, relaySet)
: instance.fetchEvents(filter, opts);
return withTimeout(promise, timeoutMs, EMPTY_SET);
}
export const RELAY_STORAGE_KEY = "wrystr_relays";
export const FALLBACK_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
];
export function getStoredRelayUrls(): string[] {
try {
const stored = localStorage.getItem(RELAY_STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return FALLBACK_RELAYS;
}
export function saveRelayUrls(urls: string[]) {
localStorage.setItem(RELAY_STORAGE_KEY, JSON.stringify(urls));
}
let ndk: NDK | null = null;
let ndkCreatedAt: number | null = null;
export function getNDK(): NDK {
if (!ndk) {
ndk = new NDK({
explicitRelayUrls: getStoredRelayUrls(),
});
ndkCreatedAt = Date.now();
}
return ndk;
}
export function getNDKUptimeMs(): number | null {
return ndkCreatedAt ? Date.now() - ndkCreatedAt : null;
}
/**
* Destroy the current NDK instance and create a fresh one.
* Preserves the signer (login state) but resets all relay connections.
* Use as a last resort when relay connections are unrecoverable.
*/
export async function resetNDK(): Promise<void> {
const oldInstance = ndk;
const oldSigner = oldInstance?.signer ?? null;
// Disconnect all relays on old instance
if (oldInstance?.pool?.relays) {
for (const relay of oldInstance.pool.relays.values()) {
try { relay.disconnect(); } catch { /* ignore */ }
}
}
// Create fresh instance
ndk = new NDK({
explicitRelayUrls: getStoredRelayUrls(),
});
ndkCreatedAt = Date.now();
// Restore signer so user stays logged in
if (oldSigner) {
ndk.signer = oldSigner;
}
// Connect fresh
console.log("[Wrystr] NDK instance reset — connecting fresh relays");
await ndk.connect();
await waitForConnectedRelay(ndk, 5000);
const relays = Array.from(ndk.pool?.relays?.values() ?? []);
const connected = relays.filter((r) => r.connected).length;
console.log(`[Wrystr] Fresh connection: ${connected}/${relays.length} relays connected`);
}
export function addRelay(url: string): void {
const instance = getNDK();
const urls = getStoredRelayUrls();
if (!urls.includes(url)) {
saveRelayUrls([...urls, url]);
}
if (!instance.pool?.relays.has(url)) {
const relay = new NDKRelay(url, undefined, instance);
instance.pool?.addRelay(relay, true);
}
}
export function removeRelay(url: string): void {
const instance = getNDK();
// NDK may store URLs with or without trailing slash — check both
const variants = [url, url.replace(/\/$/, ""), url.replace(/\/?$/, "/")];
for (const v of variants) {
const relay = instance.pool?.relays.get(v);
if (relay) {
relay.disconnect();
instance.pool?.relays.delete(v);
}
}
saveRelayUrls(getStoredRelayUrls().filter((u) => u !== url));
}
function waitForConnectedRelay(instance: NDK, timeoutMs = 10000): Promise<void> {
return new Promise((resolve, _reject) => {
const timer = setTimeout(() => {
// Even on timeout, continue — some relays may connect later
console.warn("Relay connection timeout, continuing anyway");
resolve();
}, timeoutMs);
const check = () => {
const relays = Array.from(instance.pool?.relays?.values() ?? []);
const hasConnected = relays.some((r) => r.connected);
if (hasConnected) {
clearTimeout(timer);
resolve();
} else {
setTimeout(check, 300);
}
};
check();
});
}
export async function connectToRelays(): Promise<void> {
const instance = getNDK();
await instance.connect();
await waitForConnectedRelay(instance);
}
/**
* Ensure at least one relay is connected.
* If relays report connected, trust them and return immediately.
* Only reconnect if zero relays are connected — never force-disconnect working connections.
*/
export async function ensureConnected(): Promise<boolean> {
const instance = getNDK();
const relays = Array.from(instance.pool?.relays?.values() ?? []);
const connectedCount = relays.filter((r) => r.connected).length;
if (connectedCount > 0) {
return true; // Trust relay.connected — don't probe or disconnect
}
console.warn(`[Wrystr] No relays connected (${relays.length} in pool) — attempting reconnect`);
try {
await withTimeout(instance.connect(), 4000, undefined);
await waitForConnectedRelay(instance, 3000);
const after = Array.from(instance.pool?.relays?.values() ?? []);
const nowConnected = after.some((r) => r.connected);
console.log(`[Wrystr] Reconnect ${nowConnected ? "succeeded" : "failed"}`);
return nowConnected;
} catch {
console.error("[Wrystr] Reconnect failed");
return false;
}
}