diff options
| -rw-r--r-- | build.js | 3 | ||||
| -rw-r--r-- | dev.js | 6 | ||||
| -rw-r--r-- | feed/README.md | 66 | ||||
| -rw-r--r-- | feed/cache.js | 59 | ||||
| -rw-r--r-- | feed/server.js | 12 | ||||
| -rw-r--r-- | feed/service.js | 29 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/features/messages.js | 59 | ||||
| -rw-r--r-- | src/nostr.js | 2 | ||||
| -rw-r--r-- | src/popular-feed.js | 16 | ||||
| -rw-r--r-- | tools/public-feed-browser-test.js | 75 | ||||
| -rw-r--r-- | tools/public-feed-cache-test.js | 50 |
12 files changed, 367 insertions, 11 deletions
@@ -84,6 +84,9 @@ self.addEventListener('fetch', (e) => { // cached "unconfirmed"). Straight to network, always. if (/^\\/(esplora|sp|lnurlp|electrum)\\//.test(u.pathname) || u.pathname.startsWith('/.well-known/')) return; + // Feed snapshots have their own short HTTP lifetime; never freeze one in Cache Storage. + if (u.pathname === '/api/feed') return; + if (req.mode === 'navigate') { e.respondWith( fetch(req) @@ -6,6 +6,11 @@ // localhost or from a phone over the LAN/Tailscale — no per-device config, no // CORS. HTTP backends by path prefix; WebSocket backends by exact path. +import { startFeedCache } from './feed/service.js'; +import { snapshotResponse } from './feed/cache.js'; + +const publicFeedCache = await startFeedCache(); + import { buildHtml, buildJsQr, buildNip46, buildVerifyWorker } from './build.js'; const port = Number(process.env.PORT || 5173); @@ -23,6 +28,7 @@ Bun.serve({ try { const url = new URL(req.url); const path = url.pathname; + if (path === '/api/feed') return snapshotResponse(publicFeedCache, req); // WebSocket proxies (Fulcrum electrum, SP-indexer push) — upgrade + relay. if (WS_PROXY[path]) { if (server.upgrade(req, { data: { target: WS_PROXY[path] } })) return; diff --git a/feed/README.md b/feed/README.md new file mode 100644 index 0000000..550445e --- /dev/null +++ b/feed/README.md @@ -0,0 +1,66 @@ +# Shared public feed cache + +The default Popular feed fetches `/api/feed` before querying Nostr relays. +The service collects the same popular-post candidates as the browser, with +signed author profiles. Language, replies, spam and mute filtering remain in +the client. Custom relay selections and pagination use the existing relay path. +The client verifies signatures from the HTTP snapshot too. + +Run from the repository with dependencies installed: + +```sh +bun run feed:serve +``` + +The service binds to `127.0.0.1:8792` by default (`HOST` and `PORT` override). +Run it under your existing process supervisor, then add a same-origin proxy +alongside the static site's routes. For nginx: + +```nginx +location = /api/feed { + proxy_pass http://127.0.0.1:8792; +} +``` + +Rebuild and deploy the frontend with `bun run build`. `bun run dev` includes +the cache endpoint automatically. Static or standalone installations without +the endpoint fall back to relays; no separate cache host is hardcoded. + +One background job queries a fixed set of four public relays, with a one-minute +snapshot lifetime. Visitors receive the previous snapshot during refreshes. +Concurrent requests cannot launch duplicate jobs. Failed/empty refreshes retain +the previous result and retry after at least 15 seconds. A snapshot older than +24 hours is not served. Cold starts without a saved snapshot return 503 promptly +so browsers can fall back while the service warms. + +The latest snapshot is atomically saved to `db/public-feed.json`; set +`FEED_CACHE_FILE` to change the path. Give the service user write access to its +parent directory. The snapshot holds only public posts/profiles, at most 240 of +each, with individual events capped at 32 KB. It is shared across languages and +users; the server accepts no user-selected relays or personalized query inputs. +Browsers cache HTTP responses for 15 seconds; the service worker bypasses this +endpoint so it cannot freeze an old feed indefinitely. + +This removes repeated initial reaction scans and profile discovery. Images, +link previews, quoted posts, live updates and older pages can still require +client requests. A language with no usable cached posts falls back to relays. + +Validation: `bun tools/public-feed-cache-test.js`. + +## Current production deployment + +`v3.coinos.io` is served by the `bitcoin-wallet` nginx container on SSH host +`cs`. The `public-feed` container runs the bundled `feed/server.js` on the +existing Docker `net` network, with `unless-stopped` restart policy. No host +port is published. Its bundle is `/home/adam/public-feed/server.js`, and its +persistent snapshot is `/home/adam/public-feed/data/public-feed.json`. +The nginx config is `/home/adam/bitcoin-wallet-nginx.conf`; `/api/feed` proxies +to `public-feed:8792` using Docker DNS with periodic resolution. + +To update the service, bundle with +`bun build feed/server.js --target=bun --minify --outfile=/tmp/coinos-public-feed-server.js`, +copy that file over the existing remote bundle, then `ssh cs 'docker restart public-feed'`. +The frontend is built locally and copied to `cs:bitcoin-wallet/`, retaining +older hashed assets for existing browser sessions. Publish assets before +`index.html` and `sw.js`. Pre-cache-deployment nginx, HTML and service-worker +backups are in `/home/adam/public-feed/backups/` on `cs`. diff --git a/feed/cache.js b/feed/cache.js new file mode 100644 index 0000000..4edc017 --- /dev/null +++ b/feed/cache.js @@ -0,0 +1,59 @@ +import { PUBLIC_FEED_RELAYS, popularCandidates } from '../src/popular-feed.js'; +import { isMachinePost } from '../src/lang-guess.js'; + +// One snapshot for all languages; language/mute filtering stays in the browser. +export async function buildSnapshot(query, now = Date.now) { + const notes = new Map(); + let until; + for (let round = 0; round < 3; round++) { + const reactions = await query({ kinds: [6, 7, 9735], limit: 600, ...(until ? { until } : {}) }); + if (!reactions.length) break; + until = Math.min(...reactions.map((e) => e.created_at)) - 1; + const candidates = popularCandidates(reactions); + const batches = []; + for (let i = 0; i < candidates.ids.length; i += 40) batches.push(query({ ids: candidates.ids.slice(i, i + 40) })); + for (const e of (await Promise.all(batches)).flat()) { + if (candidates.qualifies(e) && !isMachinePost(e.content) && JSON.stringify(e).length <= 32_000) notes.set(e.id, e); + } + } + if (!notes.size) throw new Error('No public posts returned; retaining previous snapshot'); + const posts = [...notes.values()].sort((a, b) => b.created_at - a.created_at); + const authors = [...new Set(posts.map((e) => e.pubkey))]; + const profiles = new Map(); + for (let i = 0; i < authors.length; i += 100) { + for (const e of await query({ kinds: [0], authors: authors.slice(i, i + 100) })) { + if (e.kind === 0 && authors.includes(e.pubkey) && JSON.stringify(e).length <= 32_000 + && (!profiles.has(e.pubkey) || profiles.get(e.pubkey).created_at < e.created_at)) profiles.set(e.pubkey, e); + } + } + return { version: 1, generatedAt: now(), notes: posts, profiles: [...profiles.values()], popUntil: until + 1 }; +} + +// Single flight, stale-while-refresh, and a retry delay when relays are down. +export function createSnapshotCache({ build, initial = null, save = async () => {}, now = Date.now, ttl = 60_000, maxAge = 86400_000, onError = console.error }) { + let snapshot = initial, pending = null, retryAt = 0; + function refresh() { + if (pending) return pending; + if (now() < retryAt) return Promise.resolve(); + pending = Promise.resolve().then(build).then(async (next) => { + snapshot = next; + await save(next); + }).catch(onError).finally(() => { pending = null; retryAt = now() + 15_000; }); + return pending; + } + function read() { + if (!snapshot || now() - snapshot.generatedAt >= ttl) void refresh(); + return snapshot && now() - snapshot.generatedAt <= maxAge ? snapshot : null; + } + return { read, refresh }; +} + +export function snapshotResponse(cache, req) { + if (!['GET', 'HEAD'].includes(req.method)) return new Response(null, { status: 405, headers: { Allow: 'GET, HEAD' } }); + const snapshot = cache.read(); + if (!snapshot) return new Response(null, { status: 503, headers: { 'Retry-After': '15', 'Cache-Control': 'no-store' } }); + return new Response(req.method === 'HEAD' ? null : JSON.stringify(snapshot), { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=15, stale-while-revalidate=60' }, + }); +} +export { PUBLIC_FEED_RELAYS }; diff --git a/feed/server.js b/feed/server.js new file mode 100644 index 0000000..2cf26a1 --- /dev/null +++ b/feed/server.js @@ -0,0 +1,12 @@ +import { startFeedCache } from './service.js'; +import { snapshotResponse } from './cache.js'; +const cache = await startFeedCache(); +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); + }, +}); +console.log(`Public feed cache listening on ${server.url}`); diff --git a/feed/service.js b/feed/service.js new file mode 100644 index 0000000..304b40f --- /dev/null +++ b/feed/service.js @@ -0,0 +1,29 @@ +import { SimplePool } from 'nostr-tools/pool'; +import { readFile, mkdir, writeFile, rename } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { buildSnapshot, createSnapshotCache, PUBLIC_FEED_RELAYS } from './cache.js'; + +export async function startFeedCache() { + const file = process.env.FEED_CACHE_FILE || new URL('../db/public-feed.json', import.meta.url).pathname; + let initial = null; + try { + const data = JSON.parse(await readFile(file, 'utf8')); + if (data.version === 1 && Number.isFinite(data.generatedAt) && Array.isArray(data.notes) && data.notes.length && Array.isArray(data.profiles)) initial = data; + } catch {} + // SimplePool verifies signatures and matches filters before accepting events. + const pool = new SimplePool(); + const cache = createSnapshotCache({ + initial, + build: () => buildSnapshot((filter) => pool.querySync(PUBLIC_FEED_RELAYS, filter, { maxWait: 4500 })), + save: async (data) => { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file + '.tmp', JSON.stringify(data)); + await rename(file + '.tmp', file); + }, + onError: (e) => console.warn('[public feed]', e.message), + }); + cache.read(); // warm before the first visitor, reusing the persisted snapshot + const timer = setInterval(() => cache.read(), 15_000); + timer.unref(); + return cache; +} diff --git a/package.json b/package.json index 1d12b14..f857ca6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "description": "coinos v3 — offline-capable, single-file self-custody Bitcoin wallet for the browser", "scripts": { "dev": "bun run dev.js", + "feed:serve": "bun run feed/server.js", "build": "bun run build.js", "build:minimal": "HAL_FEATURES=none bun run build.js" }, diff --git a/src/features/messages.js b/src/features/messages.js index b3f40f2..b696267 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,7 +15,7 @@ import { subscribeOn, publishOn, queryOn, fetchInboxRelays, relayAlive, liveRelayList, resetRelay, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, - PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, + PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, } from '../nostr.js'; import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, @@ -32,6 +32,7 @@ import { saveInbox } from '../dm-inbox.js'; import { mergeFeedWindow } from '../feed-window.js'; import { createThreadStore } from '../thread-cache.js'; import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js'; +import { PUBLIC_FEED_RELAYS, popularCandidates } from '../popular-feed.js'; import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl } from '../recipient-search.js'; import { getNetwork } from '../api.js'; import { decodeBolt11 } from '../ark/lightning.js'; @@ -4050,7 +4051,7 @@ export function messagesFeature(ctx) { const RELAY_PICKS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social', 'wss://nostr.mom', 'wss://nostr.bitcoiner.social', 'wss://eden.nostr.land', 'wss://nostr.land', 'wss://nostr.oxtr.dev', 'wss://relay.nostrplebs.com', 'wss://offchain.pub', 'wss://nostr21.com', 'wss://relay.nostr.net', 'wss://nostr-pub.wellorder.net']; - const FIREHOSE_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net']; + const FIREHOSE_RELAYS = PUBLIC_FEED_RELAYS; const normRelay = (u) => { let x = String(u || '').trim(); if (!x) return null; @@ -4068,10 +4069,38 @@ export function messagesFeature(ctx) { // latest reactions, reposts and zaps, counted per post by distinct // people, then the posts enough people touched. Older pages walk the // reactions back in time. The prose and language tests run at the door. - const POPULAR_MIN = 2; const POPULAR_ASK = 600; // reactions per round const curateFeed = (evs) => { const lang = getLang(); return evs.filter((e) => !isMachinePost(e.content) && inLanguage(e.content, lang)); }; async function popularPass(extra, merge, c, def) { + // Only the default public feed shares a snapshot. Custom relays and older + // pages keep their own queries. A static/offline install falls back quickly. + if (extra.until == null && !feedRelays(def)) { + try { + const response = await fetch('/api/feed', { signal: AbortSignal.timeout(1500) }); + if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) throw new Error('No feed cache'); + const data = await response.json(); + if (data.version !== 1 || !Number.isFinite(data.generatedAt) || Date.now() - data.generatedAt > 86400_000 + || !Array.isArray(data.notes) || data.notes.length > 240 || !Array.isArray(data.profiles) || data.profiles.length > 240 + || !Number.isFinite(data.popUntil)) throw new Error('Invalid feed cache'); + const events = [...data.notes, ...data.profiles]; + // HTTP is another untrusted source: retain the relay signature checks. + const valid = await verifyEventsAsync(events); + if (c.stopped) return false; + const posts = data.notes.filter((e, i) => valid[i] && e.kind === 1); + const usable = curateFeed(posts).filter((e) => !isReply(e) && !hidden(e)); + if (usable.length) { + warmProfiles(); + for (let i = 0; i < data.profiles.length; i++) { + const e = data.profiles[i]; + if (valid[data.notes.length + i] && e.kind === 0) applyProfile(e.pubkey, e); + } + c.popUntil = c.popUntil || data.popUntil; + const got = await mergeFeed(posts, { ...merge, immediate: true }, c); + if (got) scheduleRepaint(); + return got; // already-known posts also count as a successful cache hit + } + } catch {} // relay path also covers snapshots with no posts in this language + } const relays = feedRelaysOr(def, FIREHOSE_RELAYS); const wide = [...new Set([...relays, ...NOTE_RELAYS])]; let until = extra.until != null && c.popUntil ? c.popUntil - 1 : undefined; @@ -4079,19 +4108,15 @@ export function messagesFeature(ctx) { for (let round = 0; round < 3 && !c.stopped; round++) { const re = await queryOn(wide, { kinds: [6, 7, 9735], limit: POPULAR_ASK, ...(until ? { until } : {}) }, 4500).catch(() => []); if (!re.length) break; - const counts = new Map(); - for (const r of re) { - const who = r.kind === 9735 ? ((r.tags.find((x) => x[0] === 'P') || [])[1] || r.pubkey) : r.pubkey; - for (const x of r.tags) if (x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')) (counts.get(x[1]) || counts.set(x[1], new Set()).get(x[1])).add(who); - } + const candidates = popularCandidates(re); const oldest = Math.min(...re.map((r) => r.created_at)); c.popUntil = c.popUntil && until ? Math.min(c.popUntil, oldest) : (c.popUntil || oldest); until = oldest - 1; - const ids = [...counts.entries()].filter(([, s]) => s.size >= POPULAR_MIN).sort((a, b) => b[1].size - a[1].size).slice(0, 80).map(([id]) => id); + const ids = candidates.ids; if (!ids.length) continue; const asks = []; for (let i = 0; i < ids.length; i += 40) asks.push(queryOn(wide, { ids: ids.slice(i, i + 40) }, 4500).catch(() => [])); - const posts = (await Promise.all(asks)).flat().filter((e) => e && e.kind === 1 && (counts.get(e.id) || new Set()).size - (counts.get(e.id)?.has(e.pubkey) ? 1 : 0) >= POPULAR_MIN); + const posts = (await Promise.all(asks)).flat().filter(candidates.qualifies); if (c.stopped) return got; if (await mergeFeed(posts, merge, c)) { got = true; scheduleRepaint(); } if (got && round >= 0 && (c.notes.length >= FEED_PAGE || extra.until != null)) break; @@ -4972,6 +4997,20 @@ export function messagesFeature(ctx) { } if (c.booting) await c.boot; if (c.stopped) return false; + if (opts.immediate && !c.notes.length && !opts.catchup) { + // The public snapshot already contains verified posts and profiles. + // Paint text now using the same progressive rows as the local cache; + // slow media/quotes must not hold the whole first page behind a spinner. + c.booting = true; + for (const e of add) staged.delete(e.id); + admitFeed(add, c); + c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => { + c.booting = false; + if (!c.stopped && c === feed) scheduleRepaint(); + }); + scheduleRepaint(); + return true; + } await notesReady(add); if (c.stopped) return false; for (const e of add) staged.delete(e.id); diff --git a/src/nostr.js b/src/nostr.js index dcf4bbc..f4dfa59 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -55,7 +55,7 @@ const _verifySync = (events) => events.map((e) => { try { return verifyEvent(e); // Verify a batch; resolves to a boolean[] aligned to `events`. Off-thread when // the worker is available, else synchronous. Never resolves an event as valid // without an actual check. -function verifyEventsAsync(events) { +export function verifyEventsAsync(events) { if (!events.length) return Promise.resolve([]); const w = verifyWorker(); if (!w) return Promise.resolve(_verifySync(events)); diff --git a/src/popular-feed.js b/src/popular-feed.js new file mode 100644 index 0000000..999ebc1 --- /dev/null +++ b/src/popular-feed.js @@ -0,0 +1,16 @@ +// Shared by the relay fallback and the server's public snapshot builder. +export const PUBLIC_FEED_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net']; +export function popularCandidates(reactions) { + const counts = new Map(); + for (const r of reactions) { + const who = r.kind === 9735 ? (r.tags.find((x) => x[0] === 'P')?.[1] || r.pubkey) : r.pubkey; + for (const x of r.tags) if (x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')) { + if (!counts.has(x[1])) counts.set(x[1], new Set()); + counts.get(x[1]).add(who); + } + } + return { + ids: [...counts].filter(([, s]) => s.size >= 2).sort((a, b) => b[1].size - a[1].size).slice(0, 80).map(([id]) => id), + qualifies: (e) => e?.kind === 1 && (counts.get(e.id)?.size || 0) - (counts.get(e.id)?.has(e.pubkey) ? 1 : 0) >= 2, + }; +} diff --git a/tools/public-feed-browser-test.js b/tools/public-feed-browser-test.js new file mode 100644 index 0000000..bfeb82b --- /dev/null +++ b/tools/public-feed-browser-test.js @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; +import { buildHtml, buildVerifyWorker } from '../build.js'; +const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000); +const note = finalizeEvent({ kind: 1, created_at: now, tags: [], content: 'This is the shared public snapshot for everyone to read. https://slow.example/photo.png' }, sk); +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Snapshot Author', picture: 'https://slow.example/avatar.png' }) }, sk); +const html = await buildHtml({ minify: true, pwa: false }); +const worker = await buildVerifyWorker({ minify: true }); +let mode = 'ok', requests = 0; +const server = Bun.serve({ port: 0, fetch(req) { + const path = new URL(req.url).pathname; + if (path === '/api/feed') { + requests++; + if (mode === 'unavailable') return new Response(null, { status: 503 }); + return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 100, + notes: [mode === 'corrupt' ? { ...note, content: 'Forged snapshot content' } : note], profiles: [profile] }); + } + if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } }); + if (path.startsWith('/punks')) return new Response(null, { status: 404 }); + return new Response(html, { headers: { 'Content-Type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +try { + async function visit(path) { + const page = await browser.newPage(); + await page.setRequestInterception(true); + page.on('request', (r) => { + // Leave media unresolved: first paint must not depend on its timeout. + if (r.url().startsWith('https://slow.example/')) return; + return r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort(); + }); + await page.evaluateOnNewDocument(() => { + window.relayFilters = []; + class FakeSocket extends EventTarget { + static OPEN = 1; static CLOSED = 3; + readyState = 0; + constructor() { super(); setTimeout(() => { this.readyState = 1; this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); } + send(data) { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + window.relayFilters.push(m[2]); + setTimeout(() => { const e = new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }); this.onmessage?.(e); this.dispatchEvent(e); }, 0); + } + } + close() { this.readyState = 3; this.onclose?.({}); } + } + window.WebSocket = FakeSocket; + }); + await page.goto(server.url.origin + path); + return page; + } + let page = await visit('/feed'); + await page.waitForFunction(() => document.body.innerText.includes('This is the shared public snapshot'), { timeout: 2500 }); + assert.equal(await page.$eval('.notes-feed', (el) => el.dataset.booting), '1', 'posts render while slow media is still warming'); + assert((await page.evaluate(() => document.body.innerText)).includes('Snapshot Author')); + assert.equal(await page.evaluate(() => window.relayFilters.filter((f) => f.kinds?.includes(7)).length), 0); + await page.close(); + for (const state of ['corrupt', 'unavailable']) { + mode = state; + page = await visit('/feed'); + await page.waitForFunction(() => window.relayFilters.some((f) => f.kinds?.includes(7)), { timeout: 15000 }); + assert(!(await page.evaluate(() => document.body.innerText)).includes('Forged snapshot content')); + await page.close(); + } + const before = requests; + page = await visit('/feed?r=wss://custom.example'); + await page.waitForFunction(() => window.relayFilters.some((f) => f.kinds?.includes(7)), { timeout: 15000 }); + assert.equal(requests, before, 'custom relays bypass snapshot'); + await page.close(); + console.log('✓ browser renders signed snapshot/profile before slow media and without reaction scans; corrupt/unavailable snapshots fall back; custom relays bypass cache'); +} finally { + await browser.close(); + server.stop(true); +} diff --git a/tools/public-feed-cache-test.js b/tools/public-feed-cache-test.js new file mode 100644 index 0000000..77f833a --- /dev/null +++ b/tools/public-feed-cache-test.js @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { buildSnapshot, createSnapshotCache, snapshotResponse } from '../feed/cache.js'; +import { popularCandidates } from '../src/popular-feed.js'; +const id = 'a'.repeat(64), author = 'b'.repeat(64); +const reaction = (pk, kind = 7, tags = []) => ({ kind, pubkey: pk, created_at: 100, tags: [['e', id], ...tags] }); +const post = { kind: 1, id, pubkey: author, created_at: 90, content: 'This is a post about the things we build together.', tags: [] }; +assert.equal(popularCandidates([reaction('c'), reaction('c')]).ids.length, 0); +assert.equal(popularCandidates([reaction(author), reaction('c')]).qualifies(post), false); +assert.equal(popularCandidates([reaction('c'), reaction('d')]).qualifies(post), true); +assert.equal(popularCandidates([reaction('zap-service', 9735, [['P', 'c']]), reaction('c')]).ids.length, 0); +const snapshot = await buildSnapshot(async (filter) => { + if (filter.ids) return [post, post]; + if (filter.kinds[0] === 0) return [{ kind: 0, pubkey: author, created_at: 1, content: '{}' }, { kind: 0, pubkey: author, created_at: 2, content: '{}' }]; + return [reaction('c'), reaction('d')]; +}, () => 1000); +assert.equal(snapshot.notes.length, 1); +assert.equal(snapshot.profiles[0].created_at, 2); +assert.equal(snapshot.popUntil, 100); +await assert.rejects(buildSnapshot(async () => []), /No public posts/); +let time = 1000, builds = 0, finish; +const cache = createSnapshotCache({ now: () => time, build: () => { builds++; return new Promise((resolve) => { finish = resolve; }); } }); +const request = new Request('http://localhost/api/feed'); +assert.equal(snapshotResponse(cache, request).status, 503); +for (let i = 0; i < 100; i++) assert.equal(cache.read(), null); +await Promise.resolve(); +assert.equal(builds, 1, 'cold visitors share one build'); +finish(snapshot); +await cache.refresh(); +assert.equal(cache.read(), snapshot); +assert.equal(snapshotResponse(cache, request).status, 200); +assert.equal(snapshotResponse(cache, new Request(request, { method: 'POST' })).status, 405); +assert.equal(await snapshotResponse(cache, new Request(request, { method: 'HEAD' })).text(), ''); +time += 61_000; +for (let i = 0; i < 100; i++) assert.equal(cache.read(), snapshot); +await Promise.resolve(); +assert.equal(builds, 2, 'stale visitors share one background build'); +finish({ ...snapshot, generatedAt: time }); +await cache.refresh(); +assert.equal(cache.read().generatedAt, time); +let failures = 0; +const failing = createSnapshotCache({ initial: snapshot, now: () => time, build: async () => { failures++; throw Error('offline'); }, onError: () => {} }); +assert.equal(failing.read(), snapshot); +await failing.refresh(); +assert.equal(failing.read(), snapshot); +await failing.refresh(); +assert.equal(failures, 1, 'failed refreshes are throttled and preserve stale data'); +time += 86400_000; +assert.equal(failing.read(), null, 'expired snapshots are not served'); +await failing.refresh(); +console.log('✓ public feed selection, profiles, single flight, stale refresh, failure recovery, expiry and HTTP behavior'); |
