From d4c0192dcf51d80b9e4496d62c80a285f75e944c Mon Sep 17 00:00:00 2001 From: dtonon Date: Thu, 20 Aug 2026 16:29:19 +0100 Subject: [PATCH] Serve stale snapshots while refreshing, with cache windows from the env --- .env.example | 3 +++ README.md | 4 +++- src/lib/config.ts | 20 +++++++++++++++----- src/lib/ssr/relay.ts | 44 +++++++++++++++++++++++++++++++------------- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 8bcb2b4..96a08a7 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ PUBLIC_TITLE= # top-bar title; empty falls back to the PUBLIC_SSR=no # With SSR on, warm the server cache from client-side navigations (yes/no) PUBLIC_SSR_WARM=yes +# Snapshot cache windows in seconds: served as is / served stale while refreshing +PUBLIC_SSR_CACHE_FRESH=300 +PUBLIC_SSR_CACHE_STALE=21600 PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support) PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question) diff --git a/README.md b/README.md index dcf76e9..4f02a7f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Squalk is configured entirely through environment variables (all prefixed `PUBLI | `PUBLIC_JOINCODE` | no | `no` | `yes` to show an invite-code field when a join request is rejected (for code-gated relays). | | `PUBLIC_SSR` | no | `no` | `yes` to render pages on the server (crawlable HTML, real 404s). The build then targets Node (`node build`) instead of a static bundle; see [Deploying](#deploying). | | `PUBLIC_SSR_WARM` | no | `yes` | With `PUBLIC_SSR=yes`, each client-side navigation also asks the server to fetch and cache that page, so a later refresh, shared link or crawler hit is served warm. Costs one extra relay query per navigation on the server; set to `no` to disable. | +| `PUBLIC_SSR_CACHE_FRESH` | no | `300` | Seconds a server-rendered snapshot is served as is. Also the edge cache's `s-maxage`. | +| `PUBLIC_SSR_CACHE_STALE` | no | `21600` | Seconds after which a snapshot is no longer served while being refreshed in the background (until then a stale page is answered instantly and updated for the next visitor). Also the edge cache's `stale-while-revalidate`. | | `PUBLIC_SEARCH` | no | `no` | `yes` to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. | | `PUBLIC_LABELS` | no | — | Comma-separated discussion labels offered when composing, e.g. `bug,feature,question`. | | `PUBLIC_BLOSSOM_URL` | no | — | Blossom server URL used for media uploads, e.g. `https://blossom.primal.net`. Uploads are disabled when unset. | @@ -113,4 +115,4 @@ Preview a build locally with `npm run preview` (static) or `node --env-file=.env reverse_proxy 127.0.0.1:3000 } ``` -- If Cloudflare sits in front, a cache rule that caches HTML and respects origin headers: pages and snapshots are sent with `Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=3600`, so the edge serves them for five minutes and refreshes in the background for an hour after that. `just deploy-ssr` purges the cache after each release. +- If Cloudflare sits in front, a cache rule that caches HTML and respects origin headers: pages and snapshots are sent with `Cache-Control: public, max-age=0, s-maxage=, stale-while-revalidate=` (by default served for five minutes, then refreshed in the background for up to six hours), the same windows the server's own in-memory cache uses. `just deploy-ssr` purges the cache after each release. diff --git a/src/lib/config.ts b/src/lib/config.ts index c68cb94..a9b5485 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -26,11 +26,21 @@ export const SSR_ENABLED = __SQUALK_SSR__; // next refresh, shared link or crawler. Costs one server→relay query per // navigation; opt out with PUBLIC_SSR_WARM=no. export const SSR_WARM = SSR_ENABLED && env.PUBLIC_SSR_WARM !== "no"; -// Server-rendered pages and snapshots are anonymous, so a shared cache may -// hold them: fresh for 5 minutes, served stale for an hour while revalidating. -// Browsers always revalidate (max-age=0) so a login shows its content at once. -export const CACHE_CONTROL = - "public, max-age=0, s-maxage=300, stale-while-revalidate=3600"; +// Lifetime of server-rendered snapshots, in seconds. Within FRESH a cached +// page is served as is; up to STALE it is still served at once but refreshed +// in the background; beyond that it is fetched again before answering. Only +// crawlers and cold refreshes see the snapshot — the browser always refetches +// live data after hydration — so these trade first-paint staleness for speed. +function seconds(raw: string | undefined, fallback: number): number { + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} +export const SSR_CACHE_FRESH = seconds(env.PUBLIC_SSR_CACHE_FRESH, 300); +export const SSR_CACHE_STALE = seconds(env.PUBLIC_SSR_CACHE_STALE, 6 * 3600); +// The same policy for a shared HTTP cache in front (Cloudflare honours it once +// HTML caching is enabled). Browsers always revalidate (max-age=0) so a login +// shows its content at once. +export const CACHE_CONTROL = `public, max-age=0, s-maxage=${SSR_CACHE_FRESH}, stale-while-revalidate=${SSR_CACHE_STALE}`; // Requires a relay with NIP-50 support. export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes"; export const LABELS = (env.PUBLIC_LABELS ?? "") diff --git a/src/lib/ssr/relay.ts b/src/lib/ssr/relay.ts index 3cf95f9..0249f72 100644 --- a/src/lib/ssr/relay.ts +++ b/src/lib/ssr/relay.ts @@ -1,7 +1,7 @@ import { SimplePool } from "@nostr/tools"; import type { Event } from "@nostr/tools/core"; import type { Filter } from "@nostr/tools/filter"; -import { RELAY_URL } from "$lib/config"; +import { RELAY_URL, SSR_CACHE_FRESH, SSR_CACHE_STALE } from "$lib/config"; import { PROFILE_RELAYS } from "$lib/forum/profiles"; import type { Query } from "$lib/forum/query"; @@ -10,20 +10,22 @@ import type { Query } from "$lib/forum/query"; // private rooms once the client takes over. Every query is bounded so a slow // relay degrades to an empty page, never a hung request. // -// Results are cached in memory. Freshness only matters to crawlers and cold -// refreshes — the browser always refetches live data after hydration — so -// forum data lives as long as the edge cache (CACHE_CONTROL's s-maxage) and -// profiles, which rarely change, for an hour. +// Results are cached in memory with the fresh/stale windows from config: +// fresh entries are returned as they are, stale ones are returned at once and +// refreshed in the background, expired ones are fetched again. Profiles +// rarely change, so their fresh window is at least an hour. const QUERY_TIMEOUT = 2500; const PROFILE_TIMEOUT = 1500; const CONNECT_TIMEOUT = 1500; -const FORUM_TTL = 5 * 60_000; -const PROFILE_TTL = 60 * 60_000; +const FORUM_FRESH = SSR_CACHE_FRESH * 1000; +const PROFILE_FRESH = Math.max(FORUM_FRESH, 60 * 60_000); +const STALE = Math.max(SSR_CACHE_STALE * 1000, FORUM_FRESH); const DEAD_RELAY_TTL = 5 * 60_000; const CACHE_MAX = 2000; const pool = new SimplePool(); -const cache = new Map }>(); +type Entry = { at: number; result: Promise; refreshing: boolean }; +const cache = new Map(); // Relays that failed to connect are skipped for a while, so a dead profile // relay doesn't add its connect timeout to every cold page. const deadUntil = new Map(); @@ -94,20 +96,36 @@ function cachedQuery( relays: string[], filter: Filter, maxWait: number, - ttl: number, + fresh: number, ): Promise { const key = relays.join(",") + "|" + JSON.stringify(filter); const now = Date.now(); const hit = cache.get(key); - if (hit && now - hit.at < ttl) return hit.result; + if (hit) { + const age = now - hit.at; + if (age < fresh) return hit.result; + if (age < STALE) { + if (!hit.refreshing) { + hit.refreshing = true; + boundedQuery(relays, filter, maxWait).then((events) => { + cache.set(key, { + at: Date.now(), + result: Promise.resolve(events), + refreshing: false, + }); + }); + } + return hit.result; + } + } if (cache.size >= CACHE_MAX) cache.clear(); const result = boundedQuery(relays, filter, maxWait); - cache.set(key, { at: now, result }); + cache.set(key, { at: now, result, refreshing: false }); return result; } export const forumQuery: Query = (filter) => - cachedQuery([RELAY_URL], filter, QUERY_TIMEOUT, FORUM_TTL); + cachedQuery([RELAY_URL], filter, QUERY_TIMEOUT, FORUM_FRESH); // Profiles mostly live off the forum relay; ask it too for members who only // published there. @@ -116,5 +134,5 @@ export const profileQuery: Query = (filter) => [RELAY_URL, ...PROFILE_RELAYS], filter, PROFILE_TIMEOUT, - PROFILE_TTL, + PROFILE_FRESH, );