diff options
| author | Adam Soltys <[email protected]> | 2026-10-01 22:48:11 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-01 22:48:11 -0700 |
| commit | 23f18ff13e0a8c612d04727de2ccfadf2d13ff7c (patch) | |
| tree | 578915726c8b4285d6e0dcaedb6d24049996d773 /feed | |
| parent | c6b279abd5e502ce05c1418797714782356b7707 (diff) | |
| download | coinosv3-23f18ff13e0a8c612d04727de2ccfadf2d13ff7c.tar.gz coinosv3-23f18ff13e0a8c612d04727de2ccfadf2d13ff7c.zip | |
Shared note links open on the post, with names and faces
The server renders /nevent1 and /note1 pages with the thread inlined
(post, root, replies, kind 0s) and Open Graph tags; the client seeds its
thread cache from it and opens the thread on the first paint. Without
it, a pending frame holds the screen instead of the front door. Profile
lookups ask our relays and the profile indexes at once, streamed,
instead of one after the other behind five-second waits. A thread
opened straight from a link has no Back button.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'feed')
| -rw-r--r-- | feed/note-page.js | 180 | ||||
| -rw-r--r-- | feed/server.js | 33 |
2 files changed, 210 insertions, 3 deletions
diff --git a/feed/note-page.js b/feed/note-page.js new file mode 100644 index 0000000..f4dc08e --- /dev/null +++ b/feed/note-page.js @@ -0,0 +1,180 @@ +// /nevent1… and /note1… rendered with the thread already in the page. +// +// A shared post opened in a fresh browser used to show the front door, then +// the post under a punk and an npub, and the author's real name seconds +// later — every piece waited on relays the browser had only just dialled. +// This server dials them once, keeps the answers, and hands the browser the +// app's own index.html with the thread inlined as JSON (the client puts it +// in its thread cache and opens from there) plus Open Graph tags, so a link +// pasted into a chat previews as the post rather than as the wallet. +// +// It never makes the page slower than the plain one by much: a thread that +// isn't ready inside BUDGET_MS goes out without it (the client fetches as +// before) while the fetch carries on into the cache for the next visitor. +import { decode } from 'nostr-tools/nip19'; + +export const NOTE_PATH = /^\/((?:note|nevent)1[a-z0-9]+)\/?$/i; +export const NOTE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.coinos.io', + 'wss://relay.ditto.pub', 'wss://nostr.mom']; +// where kind 0s live when the conversation relays lack them +const PROFILE_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es']; +const BUDGET_MS = 500; +const FRESH_MS = 60_000; // a thread younger than this is served as is +const KEEP_MS = 6 * 3600_000; // older, it's still served while a refresh runs +const MAX_THREADS = 500; +const MAX_REPLIES = 60; +const MAX_PROFILES = 40; +const MAX_EVENT = 32_000; + +const okEvent = (e) => e && typeof e.id === 'string' && JSON.stringify(e).length <= MAX_EVENT; +function rootIdOf(ev) { + if (ev.kind === 1111) return (ev.tags.find((t) => (t[0] === 'E' || t[0] === 'e') && t[1]) || [])[1] || ev.id; + const es = ev.tags.filter((t) => t[0] === 'e' && t[1]); + return ((es.find((t) => t[3] === 'root') || es[0]) || [])[1] || ev.id; +} + +export function parseNoteRef(path) { + const m = NOTE_PATH.exec(path); + if (!m) return null; + try { + const d = decode(m[1].toLowerCase()); + if (d.type === 'note') return { id: d.data, relays: [], author: null }; + if (d.type === 'nevent') { + const relays = (d.data.relays || []).filter((u) => /^wss:\/\/[^\s]{1,200}$/.test(u)).slice(0, 3); + return { id: d.data.id, relays, author: d.data.author || null }; + } + } catch {} + return null; +} + +// Everything the thread page shows first: the post, the root it hangs off, +// the replies to that root, and the faces of whoever wrote them. +export async function buildThread(ref, query) { + const relays = [...new Set([...ref.relays, ...NOTE_RELAYS])]; + const [hit, direct] = await Promise.all([ + query(relays, { ids: [ref.id] }, 2500), + query(relays, { kinds: [1, 1111], '#e': [ref.id], limit: MAX_REPLIES }, 2500), + ]); + const focus = hit.find((e) => e.id === ref.id); + if (!focus || !okEvent(focus)) return null; + const rootId = rootIdOf(focus); + let root = focus, replies = direct; + if (rootId !== focus.id) { + const [roots, more] = await Promise.all([ + query(relays, { ids: [rootId] }, 2500), + query(relays, { kinds: [1, 1111], '#e': [rootId], limit: MAX_REPLIES }, 2500), + ]); + root = roots.find((e) => e.id === rootId) || null; + replies = [...more, ...direct, focus]; + } + const seen = new Set(root ? [root.id] : []); + replies = replies.filter((e) => okEvent(e) && !seen.has(e.id) && seen.add(e.id)) + .sort((a, b) => a.created_at - b.created_at).slice(0, MAX_REPLIES); + // no root found: left to the client, which climbs reply chains to find one + if (!root) return null; + const people = [...new Set([root.pubkey, focus.pubkey, ...replies.map((e) => e.pubkey)])].slice(0, MAX_PROFILES); + return { v: 1, root, replies, profiles: await profilesFor(people, query) }; +} + +async function profilesFor(pks, query) { + const evs = await query([...NOTE_RELAYS, ...PROFILE_RELAYS], { kinds: [0], authors: pks }, 2000); + const newest = new Map(); + for (const e of evs) { + if (e.kind !== 0 || !pks.includes(e.pubkey) || !okEvent(e)) continue; + if (!newest.has(e.pubkey) || newest.get(e.pubkey).created_at < e.created_at) newest.set(e.pubkey, e); + } + return [...newest.values()]; +} + +// Single flight per thread, stale-while-refresh, oldest evicted first. +export function createThreadCache({ build, now = Date.now }) { + const entries = new Map(); // id -> { data, at, pending } + function refresh(ref) { + let e = entries.get(ref.id); + if (!e) { + e = { data: null, at: 0, pending: null }; + entries.set(ref.id, e); + while (entries.size > MAX_THREADS) entries.delete(entries.keys().next().value); + } + if (!e.pending) { + e.pending = Promise.resolve().then(() => build(ref)).then((data) => { + if (data) { e.data = data; e.at = now(); } + return e.data; + }).catch(() => e.data).finally(() => { e.pending = null; }); + } + return e.pending; + } + async function get(ref, budget = BUDGET_MS) { + const e = entries.get(ref.id); + if (e && e.data && now() - e.at < KEEP_MS) { + if (now() - e.at >= FRESH_MS) void refresh(ref); + return e.data; + } + let timer; + const late = new Promise((r) => { timer = setTimeout(() => r(null), budget); }); + try { return await Promise.race([refresh(ref), late]); } finally { clearTimeout(timer); } + } + return { get, refresh }; +} + +const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +// JSON inside a script tag: nothing in it may close the tag or open a comment +const scriptJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); + +function nameOf(data, pk) { + const p = (data.profiles || []).find((e) => e.pubkey === pk); + try { const m = JSON.parse(p.content); return { name: m.display_name || m.name || null, picture: m.picture || null }; } catch { return {}; } +} + +// The app's index.html with the thread and its preview tags in the head. +export function renderNotePage(html, data, ref, origin) { + const focus = [data.root, ...data.replies].find((e) => e.id === ref.id) || data.root; + const who = nameOf(data, focus.pubkey); + const text = String(focus.content || '').replace(/nostr:[a-z0-9]+/gi, '').replace(/\s+/g, ' ').trim(); + const image = (String(focus.content || '').match(/https?:\/\/\S+\.(?:jpe?g|png|gif|webp)(?:\?\S*)?/i) || [])[0] || who.picture; + const title = (who.name || 'A post') + ' on Coinos'; + const desc = text.length > 200 ? text.slice(0, 199) + '…' : text; + const head = [ + `<meta property="og:type" content="article">`, + `<meta property="og:site_name" content="Coinos">`, + `<meta property="og:title" content="${esc(title)}">`, + desc ? `<meta property="og:description" content="${esc(desc)}">` : '', + desc ? `<meta name="description" content="${esc(desc)}">` : '', + origin ? `<meta property="og:url" content="${esc(origin + '/' + (ref.path || ''))}">` : '', + image ? `<meta property="og:image" content="${esc(image)}">` : '', + `<meta name="twitter:card" content="${image && image !== who.picture ? 'summary_large_image' : 'summary'}">`, + // the author's face starts downloading with the page, not after the app boots + who.picture && /^https:\/\//.test(who.picture) ? `<link rel="preload" as="image" href="${esc(who.picture)}">` : '', + `<script type="application/json" id="boot-thread">${scriptJson(data)}</script>`, + ].filter(Boolean).join('\n'); + let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`); + out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out; + return out; +} + +// The page itself: the app's index.html, refetched every few seconds so a +// deploy is picked up without a restart. +export function createIndexSource(url, { ttl = 5000, now = Date.now } = {}) { + let html = null, at = 0, pending = null; + return async () => { + if (html && now() - at < ttl) return html; + pending ||= fetch(url, { signal: AbortSignal.timeout(1500) }) + .then((r) => (r.ok ? r.text() : Promise.reject(new Error('index ' + r.status)))) + .then((t) => { html = t; at = now(); return t; }) + .finally(() => { pending = null; }); + try { return await pending; } catch (e) { if (html) return html; throw e; } + }; +} + +export async function notePageResponse(req, { index, threads }) { + const u = new URL(req.url); + const ref = parseNoteRef(u.pathname); + const html = await index(); + const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, must-revalidate' }; + if (!ref) return new Response(html, { headers }); + ref.path = u.pathname.slice(1); + const data = await threads.get(ref).catch(() => null); + const proto = req.headers.get('x-forwarded-proto') || 'https'; + const host = req.headers.get('x-forwarded-host') || req.headers.get('host'); + return new Response(data ? renderNotePage(html, data, ref, host ? proto + '://' + host : '') : html, { headers }); +} diff --git a/feed/server.js b/feed/server.js index 2cf26a1..12ff850 100644 --- a/feed/server.js +++ b/feed/server.js @@ -1,12 +1,39 @@ +import { SimplePool } from 'nostr-tools/pool'; import { startFeedCache } from './service.js'; import { snapshotResponse } from './cache.js'; +import { NOTE_PATH, buildThread, createThreadCache, createIndexSource, notePageResponse } from './note-page.js'; const cache = await startFeedCache(); +// SimplePool verifies signatures and matches filters before accepting events. +const pool = new SimplePool(); +// Every relay's answer, or maxWait — but an ask by id is done the moment +// every id has turned up, rather than waiting out the slowest relay. +const query = (relays, filter, maxWait) => new Promise((resolve) => { + const got = new Map(); + let done = false, sub = null; + const finish = () => { if (done) return; done = true; try { sub && sub.close(); } catch {} resolve([...got.values()]); }; + sub = pool.subscribeManyEose(relays, filter, { + maxWait, + onevent(e) { + got.set(e.id, e); + if (filter.ids && filter.ids.every((id) => got.has(id))) finish(); + }, + onclose: finish, + }); + if (done) try { sub.close(); } catch {} +}); +const threads = createThreadCache({ build: (ref) => buildThread(ref, query) }); +const index = createIndexSource(process.env.INDEX_URL || 'http://bitcoin-wallet/index.html'); const server = Bun.serve({ hostname: process.env.HOST || '127.0.0.1', port: Number(process.env.PORT || 8792), - fetch(req) { - if (new URL(req.url).pathname !== '/api/feed') return new Response('Not found', { status: 404 }); - return snapshotResponse(cache, req); + idleTimeout: 30, + async fetch(req) { + const path = new URL(req.url).pathname; + if (path === '/api/feed') return snapshotResponse(cache, req); + if (NOTE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) { + try { return await notePageResponse(req, { index, threads }); } catch { return new Response('Unavailable', { status: 502 }); } + } + return new Response('Not found', { status: 404 }); }, }); console.log(`Public feed cache listening on ${server.url}`); |
