aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 06:55:14 -0700
committerAdam Soltys <[email protected]>2026-10-01 06:55:14 -0700
commit5d441ebbce6066c3636168ec5332ae9c2bdb8607 (patch)
tree7e70c783fc0be40eccb09af7499db586c5410526
parent981ff1c7f89b27ff3d7c2957e211365baff0a359 (diff)
downloadcoinosv3-5d441ebbce6066c3636168ec5332ae9c2bdb8607.tar.gz
coinosv3-5d441ebbce6066c3636168ec5332ae9c2bdb8607.zip
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.
-rw-r--r--build.js3
-rw-r--r--dev.js6
-rw-r--r--feed/README.md66
-rw-r--r--feed/cache.js59
-rw-r--r--feed/server.js12
-rw-r--r--feed/service.js29
-rw-r--r--package.json1
-rw-r--r--src/features/messages.js59
-rw-r--r--src/nostr.js2
-rw-r--r--src/popular-feed.js16
-rw-r--r--tools/public-feed-browser-test.js75
-rw-r--r--tools/public-feed-cache-test.js50
12 files changed, 367 insertions, 11 deletions
diff --git a/build.js b/build.js
index 2bc5361..848ce92 100644
--- a/build.js
+++ b/build.js
@@ -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)
diff --git a/dev.js b/dev.js
index 347ef41..feb315b 100644
--- a/dev.js
+++ b/dev.js
@@ -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');