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
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// relay doesn't add its connect timeout to every cold page.
|
||||
const deadUntil = new Map<string, number>();
|
||||
|
|
@ -94,20 +96,36 @@ function cachedQuery(
|
|||
relays: string[],
|
||||
filter: Filter,
|
||||
maxWait: number,
|
||||
ttl: number,
|
||||
fresh: number,
|
||||
): Promise<Event[]> {
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue