From 5d441ebbce6066c3636168ec5332ae9c2bdb8607 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 1 Oct 2026 06:55:14 -0700 Subject: Serve a shared snapshot for the public feed Build the default Popular feed once on the server and let browsers verify and paint its signed events before slow media finishes. Keep custom relays, pagination, filtering, and relay fallback on their existing paths.\n\nAdd stale-while-refresh persistence, service-worker bypass, development integration, and unit/browser coverage. --- tools/public-feed-browser-test.js | 75 +++++++++++++++++++++++++++++++++++++++ tools/public-feed-cache-test.js | 50 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tools/public-feed-browser-test.js create mode 100644 tools/public-feed-cache-test.js (limited to 'tools') 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'); -- cgit v1.2.3