diff --git a/packages/viewer/package.json b/packages/viewer/package.json index e7b6eeb..e73870f 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -45,6 +45,7 @@ "unist-util-visit": "^4.1.2" }, "devDependencies": { + "@cloudflare/workers-types": "4.20260627.1", "@sveltejs/adapter-cloudflare": "^7.2.9", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0", diff --git a/packages/viewer/src/debug.ts b/packages/viewer/src/debug.ts index 0308f4f..1019049 100644 --- a/packages/viewer/src/debug.ts +++ b/packages/viewer/src/debug.ts @@ -27,10 +27,14 @@ export type ServerEvent = } | { // One per bundle sync. `changed` is true on a 200 (full parse+transform), - // false on a 304. Counts size the parse/transform work; `ms` is the (valid) - // network fetch duration. + // false on a 304. `cached` is true when the request was served from this + // isolate's in-memory bundle cache (a warm isolate revalidating via ETag) + // instead of parsing fresh — the signal that the cache is doing its job; + // group by it to read the warm-hit rate. Counts size the parse/transform + // work; `ms` is the (valid) network fetch duration. evt: 'bundle_sync' changed: boolean + cached: boolean spaces: number properties: number theorems: number diff --git a/packages/viewer/src/gateway.ts b/packages/viewer/src/gateway.ts index 6e7a697..495c51d 100644 --- a/packages/viewer/src/gateway.ts +++ b/packages/viewer/src/gateway.ts @@ -1,3 +1,4 @@ +import { browser } from '$app/environment' import * as pb from '@pi-base/core' import { Id, type Property, type Space, type Trait } from './models' @@ -18,20 +19,50 @@ export type Result = { sha: string } +// Isolate-level cache of the parsed + transformed bundle, keyed by source. A +// warm Cloudflare Worker isolate serves many SSR requests; caching here lets +// them reuse the parse/transform work and revalidate cheaply against the S3 +// ETag (a conditional request → 304) instead of re-downloading and re-parsing +// on every request. We only cache during SSR — client-side the store already +// persists to localStorage. +// +// Bounded so a parsed bundle (the whole dataset) can't accumulate unbounded in +// a long-lived isolate: a site serves a single host/branch, so in practice this +// holds one entry, and we keep at most the few most-recently used. +const SSR_CACHE_MAX = 2 +const ssrCache = new Map() + +function cacheBundle(key: string, etag: string, result: Result): void { + // Re-insert to mark most-recently used, then evict the oldest over the cap. + ssrCache.delete(key) + ssrCache.set(key, { etag, result }) + if (ssrCache.size > SSR_CACHE_MAX) { + const oldest = ssrCache.keys().next().value + if (oldest !== undefined) { + ssrCache.delete(oldest) + } + } +} + export function sync( fetch: (input: RequestInfo, init?: RequestInit) => Promise, bundle?: pb.Bundle, ): Sync { return async (host: string, branch: string, etag?: string) => { trace({ event: 'remote_fetch_started', host, branch }) + + // A warm isolate may already hold the parsed bundle; pass its ETag so an + // unchanged source costs a conditional request instead of a re-parse. An + // injected bundle (tests/prerender) is never cached against the live source. + const key = `${host}/${branch}` + const warm = browser || bundle ? undefined : ssrCache.get(key) + // `ms` wraps the network fetch — real I/O, so the Workers clock advances and - // this is a trustworthy duration (unlike CPU-bound timings). Faithful to - // current behaviour: there is no isolate bundle cache, so every SSR request - // re-fetches and (on a 200) re-parses/transforms the whole bundle. + // this is a trustworthy duration (unlike CPU-bound timings). const startedAt = Date.now() const result = bundle ? { bundle, etag: 'etag' } - : await pb.bundle.fetch({ host, branch, etag, fetch }) + : await pb.bundle.fetch({ host, branch, etag: etag ?? warm?.etag, fetch }) const ms = Date.now() - startedAt if (result) { @@ -40,25 +71,37 @@ export function sync( serverLog({ evt: 'bundle_sync', changed: true, + cached: false, spaces: spaces.size, properties: properties.size, theorems: theorems.size, traits: traits.size, ms, }) - return { - spaces: transform(space, result.bundle.spaces), - properties: transform(property, result.bundle.properties), - traits: transform(trait, result.bundle.traits), - theorems: transform(theorem, result.bundle.theorems), - etag: result.etag, - sha: result.bundle.version.sha, + const transformed = build(result.bundle, result.etag) + if (!browser && !bundle) { + cacheBundle(key, result.etag, transformed) } + return transformed + } else if (warm) { + // 304 Not Modified — reuse the transform this isolate already parsed. + serverLog({ + evt: 'bundle_sync', + changed: false, + cached: true, + spaces: 0, + properties: 0, + theorems: 0, + traits: 0, + ms, + }) + return warm.result } else if (etag) { trace({ event: 'bundle_unchanged', etag }) serverLog({ evt: 'bundle_sync', changed: false, + cached: false, spaces: 0, properties: 0, theorems: 0, @@ -69,6 +112,17 @@ export function sync( } } +function build(bundle: pb.Bundle, etag: string): Result { + return { + spaces: transform(space, bundle.spaces), + properties: transform(property, bundle.properties), + traits: transform(trait, bundle.traits), + theorems: transform(theorem, bundle.theorems), + etag, + sha: bundle.version.sha, + } +} + function property({ uid, name, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 389b2af..c0231d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: specifier: ^4.1.2 version: 4.1.2 devDependencies: + '@cloudflare/workers-types': + specifier: 4.20260627.1 + version: 4.20260627.1 '@sveltejs/adapter-cloudflare': specifier: ^7.2.9 version: 7.2.9(@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.20)(vite@5.4.21(@types/node@22.15.31)(terser@5.31.1)))(svelte@4.2.20)(vite@5.4.21(@types/node@22.15.31)(terser@5.31.1)))(wrangler@4.105.0(@cloudflare/workers-types@4.20260627.1))