/** * The site's copy of the published notes. * * The site reads only the content service, with a reader token, and only * published notes. It keeps every note in memory and refreshes in the * background, so a saved note shows within a minute and a slow or restarting * content service never takes a page down: the last good copy keeps serving. */ import { env } from "./env.ts"; import type { NoteData, NoteType } from "@gk/schema"; import { SiteIndex } from "./site.ts"; export interface Note { path: string; type: T; /** The file name without `.md`: what wikilinks point at. */ name: string; slug: string; title: string; data: NoteData; body: string; acceptedAt: string; } export interface Redirect { type: string; fromSlug: string; toPath: string; } const baseUrl = () => (env("CONTENT_URL", "http://localhost:4310") as string).replace(/\/$/, ""); const token = () => env("CONTENT_TOKEN", "") as string; /** Preview only: include unpublished notes. Needs an editor or admin token; never set in production. */ const includeDrafts = () => env("CONTENT_INCLUDE_DRAFTS", "") === "true"; const refreshMs = () => Number(env("CONTENT_REFRESH_SECONDS", "30")) * 1000; async function api(path: string): Promise { const res = await fetch(`${baseUrl()}${path}`, { headers: { authorization: `Bearer ${token()}` }, signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`Content service answered ${res.status} for ${path}`); return (await res.json()) as T; } async function loadAll(): Promise { const notes: Note[] = []; for (let offset = 0; ; offset += 1000) { const published = includeDrafts() ? "" : "&published=true"; const page = await api<(Note & { publish?: boolean })[]>(`/api/notes?full=true${published}&limit=1000&offset=${offset}`); notes.push(...page); if (page.length < 1000) break; } const redirects = await api("/api/redirects").catch(() => []); return new SiteIndex(notes, redirects); } let current: SiteIndex | null = null; let loadedAt = 0; let inflight: Promise | null = null; function refresh(): Promise { inflight ??= loadAll() .then((index) => { current = index; loadedAt = Date.now(); return index; }) .finally(() => { inflight = null; }); return inflight; } /** The current index. Waits only on the very first load. */ export async function site(): Promise { if (!current) return refresh(); if (Date.now() - loadedAt > refreshMs()) { refresh().catch((err) => console.error("Content refresh failed; serving the last copy.", err)); } return current; } /** Fetch a vault image from the content service, for the /media route. */ export async function attachment(file: string): Promise { return fetch(`${baseUrl()}/attachments/${encodeURIComponent(file)}`, { headers: { authorization: `Bearer ${token()}` }, signal: AbortSignal.timeout(15_000), }); } /** Use a fixed set of notes instead of the content service, for tests. */ export function useIndex(index: SiteIndex) { current = index; loadedAt = Number.POSITIVE_INFINITY; }