Serve stale snapshots while refreshing, with cache windows from the env

This commit is contained in:
dtonon 2026-08-20 16:29:19 +01:00
parent 5329c611b1
commit d4c0192dcf
4 changed files with 52 additions and 19 deletions

View file

@ -6,6 +6,9 @@ PUBLIC_TITLE= # top-bar title; empty falls back to the
PUBLIC_SSR=no PUBLIC_SSR=no
# With SSR on, warm the server cache from client-side navigations (yes/no) # With SSR on, warm the server cache from client-side navigations (yes/no)
PUBLIC_SSR_WARM=yes 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_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) PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)

View file

@ -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_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` | 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_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_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_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. | | `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 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=<PUBLIC_SSR_CACHE_FRESH>, stale-while-revalidate=<PUBLIC_SSR_CACHE_STALE>` (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.

View file

@ -26,11 +26,21 @@ export const SSR_ENABLED = __SQUALK_SSR__;
// next refresh, shared link or crawler. Costs one server→relay query per // next refresh, shared link or crawler. Costs one server→relay query per
// navigation; opt out with PUBLIC_SSR_WARM=no. // navigation; opt out with PUBLIC_SSR_WARM=no.
export const SSR_WARM = SSR_ENABLED && env.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 // Lifetime of server-rendered snapshots, in seconds. Within FRESH a cached
// hold them: fresh for 5 minutes, served stale for an hour while revalidating. // page is served as is; up to STALE it is still served at once but refreshed
// Browsers always revalidate (max-age=0) so a login shows its content at once. // in the background; beyond that it is fetched again before answering. Only
export const CACHE_CONTROL = // crawlers and cold refreshes see the snapshot — the browser always refetches
"public, max-age=0, s-maxage=300, stale-while-revalidate=3600"; // 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. // Requires a relay with NIP-50 support.
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes"; export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
export const LABELS = (env.PUBLIC_LABELS ?? "") export const LABELS = (env.PUBLIC_LABELS ?? "")

View file

@ -1,7 +1,7 @@
import { SimplePool } from "@nostr/tools"; import { SimplePool } from "@nostr/tools";
import type { Event } from "@nostr/tools/core"; import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter"; 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 { PROFILE_RELAYS } from "$lib/forum/profiles";
import type { Query } from "$lib/forum/query"; 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 // private rooms once the client takes over. Every query is bounded so a slow
// relay degrades to an empty page, never a hung request. // relay degrades to an empty page, never a hung request.
// //
// Results are cached in memory. Freshness only matters to crawlers and cold // Results are cached in memory with the fresh/stale windows from config:
// refreshes — the browser always refetches live data after hydration — so // fresh entries are returned as they are, stale ones are returned at once and
// forum data lives as long as the edge cache (CACHE_CONTROL's s-maxage) and // refreshed in the background, expired ones are fetched again. Profiles
// profiles, which rarely change, for an hour. // rarely change, so their fresh window is at least an hour.
const QUERY_TIMEOUT = 2500; const QUERY_TIMEOUT = 2500;
const PROFILE_TIMEOUT = 1500; const PROFILE_TIMEOUT = 1500;
const CONNECT_TIMEOUT = 1500; const CONNECT_TIMEOUT = 1500;
const FORUM_TTL = 5 * 60_000; const FORUM_FRESH = SSR_CACHE_FRESH * 1000;
const PROFILE_TTL = 60 * 60_000; 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 DEAD_RELAY_TTL = 5 * 60_000;
const CACHE_MAX = 2000; const CACHE_MAX = 2000;
const pool = new SimplePool(); const pool = new SimplePool();
const cache = new Map<string, { at: number; result: Promise<Event[]> }>(); type Entry = { at: number; result: Promise<Event[]>; refreshing: boolean };
const cache = new Map<string, Entry>();
// Relays that failed to connect are skipped for a while, so a dead profile // 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. // relay doesn't add its connect timeout to every cold page.
const deadUntil = new Map<string, number>(); const deadUntil = new Map<string, number>();
@ -94,20 +96,36 @@ function cachedQuery(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
maxWait: number, maxWait: number,
ttl: number, fresh: number,
): Promise<Event[]> { ): Promise<Event[]> {
const key = relays.join(",") + "|" + JSON.stringify(filter); const key = relays.join(",") + "|" + JSON.stringify(filter);
const now = Date.now(); const now = Date.now();
const hit = cache.get(key); 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(); if (cache.size >= CACHE_MAX) cache.clear();
const result = boundedQuery(relays, filter, maxWait); const result = boundedQuery(relays, filter, maxWait);
cache.set(key, { at: now, result }); cache.set(key, { at: now, result, refreshing: false });
return result; return result;
} }
export const forumQuery: Query = (filter) => 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 // Profiles mostly live off the forum relay; ask it too for members who only
// published there. // published there.
@ -116,5 +134,5 @@ export const profileQuery: Query = (filter) =>
[RELAY_URL, ...PROFILE_RELAYS], [RELAY_URL, ...PROFILE_RELAYS],
filter, filter,
PROFILE_TIMEOUT, PROFILE_TIMEOUT,
PROFILE_TTL, PROFILE_FRESH,
); );