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:
bbe
2026-06-29 03:57:32 +02:00
co-authored by Cursor
parent 6023991f5c
commit 2ef68222bf
23 changed files with 1066 additions and 264 deletions
+52
View File
@@ -0,0 +1,52 @@
// Lightweight TTL cache backed by localStorage so resolved Nostr data (author
// profiles, long-form events) survives page reloads and navigation instead of
// being re-queried from relays every visit. No-ops on the server and degrades
// silently on quota/parse errors so it can never break rendering.
const PREFIX = "bbe:nostr:";
interface Entry<T> {
v: T;
t: number; // stored-at epoch ms
}
export function readCache<T>(key: string, ttlMs: number): T | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null;
const entry = JSON.parse(raw) as Entry<T>;
if (Date.now() - entry.t > ttlMs) {
window.localStorage.removeItem(PREFIX + key);
return null;
}
return entry.v;
} catch {
return null;
}
}
export function writeCache<T>(key: string, value: T): void {
if (typeof window === "undefined") return;
const payload = JSON.stringify({ v: value, t: Date.now() } satisfies Entry<T>);
try {
window.localStorage.setItem(PREFIX + key, payload);
} catch {
// Quota exceeded (or similar): evict our namespace and retry once.
try {
pruneNamespace();
window.localStorage.setItem(PREFIX + key, payload);
} catch {
// Give up silently — caching is best-effort.
}
}
}
// Removes every entry written by this cache to recover space when localStorage
// is full. Only touches our own namespace.
function pruneNamespace(): void {
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const k = window.localStorage.key(i);
if (k && k.startsWith(PREFIX)) window.localStorage.removeItem(k);
}
}
+14 -36
View File
@@ -7,14 +7,17 @@
* reading these needs the actual content, not repeated chrome.
*/
import { apiUrl } from "./api-base";
import { formatMeetupCivilDateLong, getMeetupStartUtc } from "./meetupEventTime";
import { formatMeetupCivilDateLong } from "./meetupEventTime";
import { fetchMeetupsLive, partitionMeetups, countUpcoming } from "./meetupsData";
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
// Live (uncached) so the mirrors always reflect the same backend state as the
// rendered pages — no ISR drift between /events, /events.md, and /llms.txt.
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(apiUrl(path), { next: { revalidate: 300 } });
const res = await fetch(apiUrl(path), { cache: "no-store" });
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
@@ -85,14 +88,10 @@ function configuredChannels(settings: Record<string, string>) {
export async function buildLlmsTxt(): Promise<string> {
const [settings, meetups] = await Promise.all([
fetchJson<Record<string, string>>("/settings/public", {}),
fetchJson<any[]>("/meetups", []),
fetchMeetupsLive(),
]);
const now = new Date();
const upcomingCount = (Array.isArray(meetups) ? meetups : []).filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start >= now;
}).length;
const upcomingCount = countUpcoming(meetups);
const channels = configuredChannels(settings);
const channelNames = channels.map((c) => c.name).join(", ");
@@ -101,12 +100,14 @@ export async function buildLlmsTxt(): Promise<string> {
? `How to connect on ${channelNames}`
: "How to connect with the community";
// Always state the count (including zero) so it stays machine-parseable and
// verifiably consistent with /events and /events.md.
const upcomingLine =
upcomingCount > 0
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
upcomingCount === 1 ? "" : "s"
} scheduled.`
: "Meetups run monthly; check the events page for the next date.";
: "There are currently 0 upcoming meetups scheduled; the community meets monthly.";
return `# Belgian Bitcoin Embassy
@@ -135,19 +136,8 @@ Belgian Bitcoin Embassy is a volunteer-run network, not a business. Content here
// ---------------------------------------------------------------------------
export async function buildHomeMarkdown(): Promise<string> {
const meetups = await fetchJson<any[]>("/meetups", []);
const now = new Date();
const next = (Array.isArray(meetups) ? meetups : [])
.filter((m) => {
if (m.status && m.status !== "PUBLISHED") return false;
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start >= now;
})
.sort(
(a, b) =>
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
getMeetupStartUtc(b.date, b.time || "00:00").getTime(),
)[0];
const meetups = await fetchMeetupsLive();
const next = partitionMeetups(meetups).upcoming[0];
let nextSection = "";
if (next) {
@@ -182,20 +172,8 @@ We help people in Belgium understand and adopt Bitcoin through education, meetup
}
export async function buildEventsMarkdown(): Promise<string> {
const meetups = await fetchJson<any[]>("/meetups", []);
const list = Array.isArray(meetups) ? meetups : [];
const now = new Date();
const upcoming = list.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start >= now;
});
const past = list
.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start < now;
})
.reverse();
const meetups = await fetchMeetupsLive();
const { upcoming, past } = partitionMeetups(meetups);
const renderMeetup = (m: any): string => {
const when = formatMeetupCivilDateLong(m.date);
+71
View File
@@ -0,0 +1,71 @@
/**
* Single source of truth for meetup data used by the public /events page, the
* /events.md mirror, and the /llms.txt summary line. Centralizing the fetch +
* upcoming/past partition guarantees those three can never disagree on the count.
*
* Fetched with `no-store` so every render reflects the live backend — this is
* what keeps crawlers (which don't run JS) and llms.txt consumers from seeing a
* stale or build-time-empty list.
*/
import { apiUrl } from "./api-base";
import { getMeetupStartUtc } from "./meetupEventTime";
export interface Meetup {
id: string;
title: string;
date: string;
time?: string;
location?: string;
description?: string;
status?: string;
organizer?: { name?: string; slug?: string } | null;
[key: string]: unknown;
}
/** Fetch all publicly-visible meetups from the backend, live (uncached). */
export async function fetchMeetupsLive(): Promise<Meetup[]> {
try {
const res = await fetch(apiUrl("/meetups"), { cache: "no-store" });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as Meetup[]) : [];
} catch {
return [];
}
}
export interface PartitionedMeetups {
upcoming: Meetup[];
past: Meetup[];
}
/**
* Split meetups into upcoming (start >= now, soonest first) and past (start <
* now, most recent first). Meetups with an unparseable date are dropped.
*/
export function partitionMeetups(
meetups: Meetup[],
now: Date = new Date(),
): PartitionedMeetups {
const upcoming: Meetup[] = [];
const past: Meetup[] = [];
for (const m of meetups) {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) continue;
if (start >= now) upcoming.push(m);
else past.push(m);
}
const startMs = (m: Meetup) =>
getMeetupStartUtc(m.date, m.time || "00:00").getTime();
upcoming.sort((a, b) => startMs(a) - startMs(b));
past.sort((a, b) => startMs(b) - startMs(a));
return { upcoming, past };
}
/** Number of upcoming meetups — the single value llms.txt and events.md share. */
export function countUpcoming(meetups: Meetup[], now: Date = new Date()): number {
return partitionMeetups(meetups, now).upcoming.length;
}
+192 -57
View File
@@ -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;
}
}