feat: resolve live Nostr references in blog posts and add embeds
Support naddr/nevent/note slugs for unindexed posts, cache naddr lookups, render Nostr embeds in markdown, and add a consistency check for events mirrors. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+192
-57
@@ -1,5 +1,14 @@
|
||||
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { readCache, writeCache } from "./browserCache";
|
||||
|
||||
// Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs:
|
||||
// these survive reloads, where the whole point is to avoid re-querying relays.
|
||||
// Profiles and immutable events change rarely; addressable (replaceable)
|
||||
// long-form events use a shorter window so edits are picked up reasonably soon.
|
||||
const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable)
|
||||
const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable)
|
||||
|
||||
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
|
||||
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
|
||||
@@ -87,6 +96,72 @@ const FALLBACK_RELAYS = [
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
// Only secure WebSocket (wss://) relays may be dialed: the site is served over
|
||||
// HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed
|
||||
// content and must never be connected to. Relay hints reach us from untrusted
|
||||
// sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter
|
||||
// at every connection point.
|
||||
export function isSecureRelay(url: unknown): url is string {
|
||||
return typeof url === "string" && /^wss:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
export function filterSecureRelays(urls: Iterable<string>): string[] {
|
||||
const out: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (isSecureRelay(url)) out.push(url.trim());
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
// How long a single relay query may run before we give up on it. Relays go
|
||||
// dead or stall mid-subscription without ever sending EOSE, which is what makes
|
||||
// blog loads occasionally hang; bounding every query keeps the page responsive.
|
||||
const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait`
|
||||
const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup
|
||||
|
||||
// A single shared read pool for the whole app. Reusing one pool keeps relay
|
||||
// websockets warm and—crucially—avoids opening several simultaneous
|
||||
// connections to the same relays (author profile + article body + NIP-65 all at
|
||||
// once on a blog open), which relays throttle/drop and which caused the
|
||||
// intermittent "couldn't fetch" failure on first load. Never closed: the pool
|
||||
// manages and reuses its own connections for the SPA's lifetime.
|
||||
let _readPool: any = null;
|
||||
async function getReadPool(): Promise<any> {
|
||||
if (!_readPool) {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
_readPool = new SimplePool();
|
||||
}
|
||||
return _readPool;
|
||||
}
|
||||
|
||||
// Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a
|
||||
// hard ceiling around relay queries so a connection that never opens (or never
|
||||
// closes) can't block rendering.
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(fallback), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
() => { clearTimeout(timer); resolve(fallback); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// A single bounded query across `relays` (which are dialed in parallel by the
|
||||
// pool). Returns null on timeout, empty relay set, or error.
|
||||
async function getEventBounded(
|
||||
pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise<any> },
|
||||
relays: string[],
|
||||
filter: any,
|
||||
): Promise<any | null> {
|
||||
if (relays.length === 0) return null;
|
||||
return withTimeout(
|
||||
pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }),
|
||||
RELAY_HARD_TIMEOUT,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
|
||||
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -98,9 +173,10 @@ export async function getSiteRelays(): Promise<string[]> {
|
||||
const res = await fetch("/api/relays/public");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.relays) && data.relays.length > 0) {
|
||||
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
|
||||
return data.relays;
|
||||
const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : [];
|
||||
if (secure.length > 0) {
|
||||
_siteRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
||||
return secure;
|
||||
}
|
||||
} catch {}
|
||||
return FALLBACK_RELAYS;
|
||||
@@ -119,12 +195,11 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
||||
const cached = _nip65Cache.get(pubkey);
|
||||
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
|
||||
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const siteRelays = await getSiteRelays();
|
||||
const pool = new SimplePool();
|
||||
const pool = await getReadPool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(siteRelays, {
|
||||
const event = await getEventBounded(pool, siteRelays, {
|
||||
kinds: [10002],
|
||||
authors: [pubkey],
|
||||
});
|
||||
@@ -138,6 +213,7 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== "r" || !tag[1]) continue;
|
||||
const url = tag[1];
|
||||
if (!isSecureRelay(url)) continue;
|
||||
const marker = tag[2];
|
||||
result.all.push(url);
|
||||
if (marker === "write") {
|
||||
@@ -154,8 +230,6 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
||||
return result;
|
||||
} catch {
|
||||
return { write: [], read: [], all: [] };
|
||||
} finally {
|
||||
pool.close(siteRelays);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +249,9 @@ export async function getUserStoredRelays(): Promise<{ url: string; read: boolea
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
|
||||
return data;
|
||||
const secure = data.filter((r) => isSecureRelay(r?.url));
|
||||
_userRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
||||
return secure;
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
@@ -217,7 +292,7 @@ export async function publishEvent(signedEvent: any): Promise<void> {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const relayUrls = [...allRelays];
|
||||
const relayUrls = filterSecureRelays(allRelays);
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
||||
@@ -233,30 +308,40 @@ export async function fetchNostrProfile(
|
||||
const hex = toHexPubkey(pubkey);
|
||||
if (!hex) return {};
|
||||
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
|
||||
PROFILE_METADATA_RELAYS.forEach((url) => allRelays.add(url));
|
||||
const cached = readProfileCache(hex, Date.now());
|
||||
if (cached) return cached;
|
||||
|
||||
const filter = { kinds: [0], authors: [hex] };
|
||||
|
||||
// Overlap the NIP-65 lookup with the first query rather than waiting on it.
|
||||
const nip65Promise = fetchNip65RelayList(hex).catch(
|
||||
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
||||
);
|
||||
|
||||
const pool = await getReadPool();
|
||||
const tried = new Set<string>();
|
||||
try {
|
||||
const nip65 = await fetchNip65RelayList(hex);
|
||||
nip65.write.forEach((url) => allRelays.add(url));
|
||||
} catch {}
|
||||
// Phase 1: provided relays (or site relays) + profile aggregators.
|
||||
const phase1 = filterSecureRelays([
|
||||
...(relayUrls || await getSiteRelays()),
|
||||
...PROFILE_METADATA_RELAYS,
|
||||
]);
|
||||
phase1.forEach((u) => tried.add(u));
|
||||
let event = await getEventBounded(pool, phase1, filter);
|
||||
|
||||
const urls = [...allRelays];
|
||||
const pool = new SimplePool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(urls, {
|
||||
kinds: [0],
|
||||
authors: [hex],
|
||||
});
|
||||
// Phase 2: author's NIP-65 write relays if not found yet.
|
||||
if (!event?.content) {
|
||||
const nip65 = await nip65Promise;
|
||||
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
||||
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
||||
}
|
||||
|
||||
if (!event?.content) return {};
|
||||
return parseProfileContent(event.content);
|
||||
const profile = parseProfileContent(event.content);
|
||||
writeProfileCache(hex, profile, Date.now());
|
||||
return profile;
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
pool.close(urls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,13 +366,25 @@ function isProfileEmpty(p: NostrProfile): boolean {
|
||||
|
||||
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
|
||||
const cached = _profileCache.get(pubkey);
|
||||
if (!cached) return null;
|
||||
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
|
||||
return now - cached.fetchedAt < ttl ? cached.profile : null;
|
||||
if (cached) {
|
||||
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
|
||||
if (now - cached.fetchedAt < ttl) return cached.profile;
|
||||
}
|
||||
// Fall back to the persistent browser cache (survives reloads/navigation).
|
||||
// Only resolved profiles are persisted, so a hit here is always non-empty.
|
||||
const persisted = readCache<NostrProfile>(`profile:${pubkey}`, PROFILE_PERSIST_TTL);
|
||||
if (persisted) {
|
||||
_profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false });
|
||||
return persisted;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
|
||||
_profileCache.set(pubkey, { profile, fetchedAt: now, empty: isProfileEmpty(profile) });
|
||||
const empty = isProfileEmpty(profile);
|
||||
_profileCache.set(pubkey, { profile, fetchedAt: now, empty });
|
||||
// Persist resolved profiles only; skip empties so a reload can retry the miss.
|
||||
if (!empty) writeCache(`profile:${pubkey}`, profile);
|
||||
}
|
||||
|
||||
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
|
||||
@@ -321,10 +418,9 @@ export async function fetchNostrProfiles(
|
||||
|
||||
if (hexByKey.size === 0) return result;
|
||||
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
|
||||
const urls = [...new Set([...base, ...PROFILE_METADATA_RELAYS])];
|
||||
const pool = new SimplePool();
|
||||
const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]);
|
||||
const pool = await getReadPool();
|
||||
|
||||
try {
|
||||
const authors = [...new Set(hexByKey.values())];
|
||||
@@ -363,8 +459,6 @@ export async function fetchNostrProfiles(
|
||||
if (!(key in result)) result[key] = {};
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
pool.close(urls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,22 +511,27 @@ export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
|
||||
}
|
||||
|
||||
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const cacheKey = `event:${eventId}`;
|
||||
const cached = readCache<any>(cacheKey, EVENT_PERSIST_TTL);
|
||||
if (cached) return cached;
|
||||
|
||||
const siteRelays = await getSiteRelays();
|
||||
const pool = new SimplePool();
|
||||
const pool = await getReadPool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(siteRelays, { ids: [eventId] });
|
||||
const event = await getEventBounded(pool, siteRelays, { ids: [eventId] });
|
||||
if (event) writeCache(cacheKey, event);
|
||||
return event || null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
pool.close(siteRelays);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const cacheKey = `longform:${naddrStr}`;
|
||||
const cached = readCache<any>(cacheKey, LONGFORM_PERSIST_TTL);
|
||||
if (cached) return cached;
|
||||
|
||||
const { nip19 } = await import("nostr-tools");
|
||||
|
||||
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
|
||||
@@ -445,29 +544,65 @@ export async function fetchLongformFromRelays(naddrStr: string): Promise<any | n
|
||||
}
|
||||
|
||||
const siteRelays = await getSiteRelays();
|
||||
const naddrRelays = decoded.relays || [];
|
||||
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
|
||||
|
||||
try {
|
||||
const nip65 = await fetchNip65RelayList(decoded.pubkey);
|
||||
nip65.write.forEach((url) => allRelays.add(url));
|
||||
} catch {}
|
||||
|
||||
const relayUrls = [...allRelays];
|
||||
const naddrRelays = filterSecureRelays(decoded.relays || []);
|
||||
const filter = {
|
||||
kinds: [decoded.kind],
|
||||
authors: [decoded.pubkey],
|
||||
"#d": [decoded.identifier],
|
||||
};
|
||||
|
||||
const pool = new SimplePool();
|
||||
// Kick off the author's NIP-65 lookup immediately so it overlaps the first
|
||||
// query instead of adding a serial round-trip before it.
|
||||
const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch(
|
||||
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
||||
);
|
||||
|
||||
const pool = await getReadPool();
|
||||
const tried = new Set<string>();
|
||||
// Phase 1: naddr relay hints + site relays, all dialed in parallel. The
|
||||
// hints usually point at the author's own relay, so this is the fast path.
|
||||
const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]);
|
||||
phase1.forEach((u) => tried.add(u));
|
||||
let event = await getEventBounded(pool, phase1, filter);
|
||||
|
||||
// Phase 2: fall back to the author's NIP-65 write relays only if needed.
|
||||
if (!event) {
|
||||
const nip65 = await nip65Promise;
|
||||
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
||||
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
||||
}
|
||||
|
||||
if (event) writeCache(cacheKey, event);
|
||||
return event || null;
|
||||
}
|
||||
|
||||
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
|
||||
// event id to its underlying Nostr event by querying relays. Lets the blog page
|
||||
// render a long-form note straight from a shared link even when it was never
|
||||
// indexed by the backend. Returns null if it can't be decoded or found.
|
||||
export async function resolveEventFromRelays(identifier: string): Promise<any | null> {
|
||||
const trimmed = identifier.trim();
|
||||
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return fetchEventFromRelays(trimmed.toLowerCase());
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
const event = await pool.get(relayUrls, filter);
|
||||
return event || null;
|
||||
decoded = nip19.decode(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
pool.close(relayUrls);
|
||||
}
|
||||
|
||||
switch (decoded.type) {
|
||||
case "naddr":
|
||||
return fetchLongformFromRelays(trimmed);
|
||||
case "nevent":
|
||||
return fetchEventFromRelays((decoded.data as { id: string }).id);
|
||||
case "note":
|
||||
return fetchEventFromRelays(decoded.data as string);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user