Serve stale snapshots while refreshing, with cache windows from the env
This commit is contained in:
parent
5329c611b1
commit
d4c0192dcf
4 changed files with 52 additions and 19 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue