diff --git a/.changeset/green-pans-cheer.md b/.changeset/green-pans-cheer.md new file mode 100644 index 00000000..0b1269cd --- /dev/null +++ b/.changeset/green-pans-cheer.md @@ -0,0 +1,5 @@ +--- +'spiceflow': patch +--- + +add a production SSR HTML cache for fast React Server Component pages, keyed by a progressive hash of the flight stream and bounded by total byte size. The cache skips non-GET requests, responses with `Set-Cookie`, and can be disabled with `SPICEFLOW_DISABLE_SSR_CACHE=1` for benchmarking or debugging. diff --git a/.changeset/late-bottles-bathe.md b/.changeset/late-bottles-bathe.md new file mode 100644 index 00000000..b0313887 --- /dev/null +++ b/.changeset/late-bottles-bathe.md @@ -0,0 +1,5 @@ +--- +'spiceflow': patch +--- + +add an experimental `SPICEFLOW_SSR_CACHE_MODE=prehash` mode for benchmarking a cache path that hashes the RSC flight stream before SSR, and expand the node benchmark `/about` route so heavier JSX trees make SSR cache tradeoffs easier to measure. diff --git a/nodejs-example/app/main.tsx b/nodejs-example/app/main.tsx index 2c8f285d..bf52da9b 100644 --- a/nodejs-example/app/main.tsx +++ b/nodejs-example/app/main.tsx @@ -6,6 +6,76 @@ import { Link, ProgressBar, redirect } from 'spiceflow/react' import { sql } from './db' import { serveStatic } from 'spiceflow' +const aboutStats = [ + { label: 'Server components', value: 'Nested layouts + SSR' }, + { label: 'Runtime targets', value: 'Node, Bun, Workers' }, + { label: 'HTML strategy', value: 'Stream flight + inject payload' }, + { label: 'Benchmark focus', value: 'SSR and hydration cost' }, + { label: 'Data access', value: 'Direct Postgres queries' }, + { label: 'Transport', value: 'Web standard Request/Response' }, +] as const + +const aboutSections = [ + { + title: 'Why this benchmark exists', + body: + 'The goal is to measure how much work happens between a React Server Components payload and the final HTML that reaches the browser.', + }, + { + title: 'What the page exercises', + body: + 'This route intentionally renders a larger tree with repeated cards, nested lists, and descriptive content so the benchmark stresses JSX creation and HTML output more than a tiny static paragraph would.', + }, + { + title: 'Why the route stays deterministic', + body: + 'The content is static so cache hit rates are easy to reason about and benchmark results are not dominated by random per-request data differences.', + }, + { + title: 'What still matters', + body: + 'Even with a cache, the shape of the component tree, the cost of SSR, and the amount of HTML written to the socket still affect throughput and latency.', + }, +] as const + +const aboutFeatureCards = [ + 'Route matching with nested layouts', + 'React Server Components decode on the server', + 'HTML stream generation from the flight payload', + 'Inline flight payload injection for hydration', + 'Redirect and not-found error propagation', + 'Header preservation across SSR responses', + 'Benchmark toggles for cache modes', + 'Byte-bounded LRU caching for HTML output', + 'Progressive hashing of the RSC flight stream', + 'Streaming fallback when the response is slow', + 'Client bootstrap injection with Vite RSC', + 'Static asset serving beside the RSC app', +] as const + +const aboutFaqs = [ + { + question: 'Does this page query the database?', + answer: + 'No. The home page does. This route is intentionally deterministic so the benchmark can isolate rendering and caching behavior more clearly.', + }, + { + question: 'Why so much markup?', + answer: + 'A tiny route makes it hard to see whether HTML-side optimizations matter. A larger tree increases the amount of JSX work and serialized HTML.', + }, + { + question: 'Why compare against Next.js and Hono?', + answer: + 'They provide useful reference points: plain string HTML on one end and a popular RSC framework on the other.', + }, + { + question: 'What should improve with a good cache?', + answer: + 'The ideal case is skipping expensive repeated work while preserving the existing streaming behavior for slow pages.', + }, +] as const + export const app = new Spiceflow() .use(serveStatic({ root: './public' })) .use(serveStatic({ root: './dist/client' })) // required to serve vite built static files @@ -40,22 +110,74 @@ export const app = new Spiceflow() }) .page('/about', async function About() { return ( -
-

About

-

- This is a demo app built with{' '} - - Spiceflow - - , showcasing React Server Components with direct database queries from - the component tree. -

- - Back to home - +
+
+

About

+

+ This is a demo app built with{' '} + + Spiceflow + + , showcasing React Server Components with direct database queries from + the component tree. This benchmark version intentionally renders more + JSX so the cost of SSR and HTML generation is easier to observe. +

+
+ +
+ {aboutStats.map((stat) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+ +
+ {aboutSections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+ +
+

Render workload

+
+ {aboutFeatureCards.map((feature, index) => ( +
+ Step {index + 1} + {feature} +

+ Repeated card markup increases the amount of JSX the server must + turn into HTML for every request in this benchmark route. +

+
+ ))} +
+
+ +
+

Benchmark FAQ

+
+ {aboutFaqs.map((faq) => ( +
+

{faq.question}

+

{faq.answer}

+
+ ))} +
+
+ +
+ + Back to home + +
) }) diff --git a/nodejs-example/hono-baseline.ts b/nodejs-example/hono-baseline.ts index 7b70c488..7a2210f9 100644 --- a/nodejs-example/hono-baseline.ts +++ b/nodejs-example/hono-baseline.ts @@ -7,18 +7,132 @@ import { serve } from '@hono/node-server' const app = new Hono() +const aboutStats = [ + { label: 'Server components', value: 'Nested layouts + SSR' }, + { label: 'Runtime targets', value: 'Node, Bun, Workers' }, + { label: 'HTML strategy', value: 'Stream flight + inject payload' }, + { label: 'Benchmark focus', value: 'SSR and hydration cost' }, + { label: 'Data access', value: 'Direct Postgres queries' }, + { label: 'Transport', value: 'Web standard Request/Response' }, +] + +const aboutSections = [ + { + title: 'Why this benchmark exists', + body: + 'The goal is to measure how much work happens between a React Server Components payload and the final HTML that reaches the browser.', + }, + { + title: 'What the page exercises', + body: + 'This route intentionally renders a larger tree with repeated cards, nested lists, and descriptive content so the benchmark stresses JSX creation and HTML output more than a tiny static paragraph would.', + }, + { + title: 'Why the route stays deterministic', + body: + 'The content is static so cache hit rates are easy to reason about and benchmark results are not dominated by random per-request data differences.', + }, + { + title: 'What still matters', + body: + 'Even with a cache, the shape of the component tree, the cost of SSR, and the amount of HTML written to the socket still affect throughput and latency.', + }, +] + +const aboutFeatureCards = [ + 'Route matching with nested layouts', + 'React Server Components decode on the server', + 'HTML stream generation from the flight payload', + 'Inline flight payload injection for hydration', + 'Redirect and not-found error propagation', + 'Header preservation across SSR responses', + 'Benchmark toggles for cache modes', + 'Byte-bounded LRU caching for HTML output', + 'Progressive hashing of the RSC flight stream', + 'Streaming fallback when the response is slow', + 'Client bootstrap injection with Vite RSC', + 'Static asset serving beside the RSC app', +] + +const aboutFaqs = [ + { + question: 'Does this page query the database?', + answer: + 'No. The home page does. This route is intentionally deterministic so the benchmark can isolate rendering and caching behavior more clearly.', + }, + { + question: 'Why so much markup?', + answer: + 'A tiny route makes it hard to see whether HTML-side optimizations matter. A larger tree increases the amount of JSX work and serialized HTML.', + }, + { + question: 'Why compare against Next.js and Hono?', + answer: + 'They provide useful reference points: plain string HTML on one end and a popular RSC framework on the other.', + }, + { + question: 'What should improve with a good cache?', + answer: + 'The ideal case is skipping expensive repeated work while preserving the existing streaming behavior for slow pages.', + }, +] + const html = ` About - Hono baseline -
+

How is this not illegal?

-
+

About

-

+

This is a demo app built with Spiceflow, showcasing React Server Components - with direct database queries from the component tree. + with direct database queries from the component tree. This benchmark version + intentionally renders more JSX so the cost of SSR and HTML generation is + easier to observe.

+
+ ${aboutStats.map((stat) => ` +
+ ${stat.label} + ${stat.value} +
+ `).join('')} +
+
+ ${aboutSections.map((section) => ` +
+

${section.title}

+

${section.body}

+
+ `).join('')} +
+
+

Render workload

+
+ ${aboutFeatureCards.map((feature, index) => ` +
+ Step ${index + 1} + ${feature} +

+ Repeated card markup increases the amount of JSX the server must + turn into HTML for every request in this benchmark route. +

+
+ `).join('')} +
+
+
+

Benchmark FAQ

+
+ ${aboutFaqs.map((faq) => ` +
+

${faq.question}

+

${faq.answer}

+
+ `).join('')} +
+
Back to home diff --git a/nodejs-example/nextjs-baseline/app/about/page.tsx b/nodejs-example/nextjs-baseline/app/about/page.tsx index 0fa7ebb0..80d81398 100644 --- a/nodejs-example/nextjs-baseline/app/about/page.tsx +++ b/nodejs-example/nextjs-baseline/app/about/page.tsx @@ -3,6 +3,76 @@ import Link from 'next/link' // Force dynamic rendering so RSC runs on every request (fair comparison) export const dynamic = 'force-dynamic' +const aboutStats = [ + { label: 'Server components', value: 'Nested layouts + SSR' }, + { label: 'Runtime targets', value: 'Node, Bun, Workers' }, + { label: 'HTML strategy', value: 'Stream flight + inject payload' }, + { label: 'Benchmark focus', value: 'SSR and hydration cost' }, + { label: 'Data access', value: 'Direct Postgres queries' }, + { label: 'Transport', value: 'Web standard Request/Response' }, +] as const + +const aboutSections = [ + { + title: 'Why this benchmark exists', + body: + 'The goal is to measure how much work happens between a React Server Components payload and the final HTML that reaches the browser.', + }, + { + title: 'What the page exercises', + body: + 'This route intentionally renders a larger tree with repeated cards, nested lists, and descriptive content so the benchmark stresses JSX creation and HTML output more than a tiny static paragraph would.', + }, + { + title: 'Why the route stays deterministic', + body: + 'The content is static so cache hit rates are easy to reason about and benchmark results are not dominated by random per-request data differences.', + }, + { + title: 'What still matters', + body: + 'Even with a cache, the shape of the component tree, the cost of SSR, and the amount of HTML written to the socket still affect throughput and latency.', + }, +] as const + +const aboutFeatureCards = [ + 'Route matching with nested layouts', + 'React Server Components decode on the server', + 'HTML stream generation from the flight payload', + 'Inline flight payload injection for hydration', + 'Redirect and not-found error propagation', + 'Header preservation across SSR responses', + 'Benchmark toggles for cache modes', + 'Byte-bounded LRU caching for HTML output', + 'Progressive hashing of the RSC flight stream', + 'Streaming fallback when the response is slow', + 'Client bootstrap injection with Vite RSC', + 'Static asset serving beside the RSC app', +] as const + +const aboutFaqs = [ + { + question: 'Does this page query the database?', + answer: + 'No. The home page does. This route is intentionally deterministic so the benchmark can isolate rendering and caching behavior more clearly.', + }, + { + question: 'Why so much markup?', + answer: + 'A tiny route makes it hard to see whether HTML-side optimizations matter. A larger tree increases the amount of JSX work and serialized HTML.', + }, + { + question: 'Why compare against Next.js and Hono?', + answer: + 'They provide useful reference points: plain string HTML on one end and a popular RSC framework on the other.', + }, + { + question: 'What should improve with a good cache?', + answer: + 'The ideal case is skipping expensive repeated work while preserving the existing streaming behavior for slow pages.', + }, +] as const + export default function About() { return (
-

About

-

- This is a demo app built with Next.js, showcasing React Server - Components for performance comparison. -

+
+

About

+

+ This is a demo app built with Next.js, showcasing React Server + Components for performance comparison. This benchmark version + intentionally renders more JSX so the cost of SSR and HTML generation + is easier to observe. +

+
+ +
+ {aboutStats.map((stat) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+ +
+ {aboutSections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+ +
+

Render workload

+
+ {aboutFeatureCards.map((feature, index) => ( +
+ Step {index + 1} + {feature} +

+ Repeated card markup increases the amount of JSX the server must + turn into HTML for every request in this benchmark route. +

+
+ ))} +
+
+ +
+

Benchmark FAQ

+
+ {aboutFaqs.map((faq) => ( +
+

{faq.question}

+

{faq.answer}

+
+ ))} +
+
+ { + private map = new Map() + private totalBytes = 0 + + constructor(public maxBytes: number) {} + + get(key: string): T | undefined { + const entry = this.map.get(key) + if (!entry) return undefined + // Move to end (most recently used) + this.map.delete(key) + this.map.set(key, entry) + return entry + } + + set(key: string, entry: T) { + const existing = this.map.get(key) + if (existing) { + this.totalBytes -= existing.byteSize + this.map.delete(key) + } + this.totalBytes += entry.byteSize + this.map.set(key, entry) + this.evict() + } + + get size() { + return this.map.size + } + + get bytes() { + return this.totalBytes + } + + clear() { + this.map.clear() + this.totalBytes = 0 + } + + private evict() { + while (this.totalBytes > this.maxBytes && this.map.size > 0) { + const oldest = this.map.keys().next() + if (oldest.done) break + const entry = this.map.get(oldest.value)! + this.totalBytes -= entry.byteSize + this.map.delete(oldest.value) + } + } +} diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index e766b89f..a947b3aa 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -13,6 +13,7 @@ import { getErrorContext, isNotFoundError, isRedirectError, contextHeaders, cont import { formatServerError } from './format-server-error.js' import { MetaProvider } from './head.js' import { MetaState } from './metastate.js' +import { ssrCache, createHashTransform, collectStream, getSsrCacheMode, hasUncacheableHeaders, prehashFlightStream } from './ssr-cache.js' import { injectRSCPayload } from './transform.js' let bootstrapScriptContentPromise: Promise | undefined @@ -33,6 +34,17 @@ async function importRscEnvironment(): Promise ) } +function buildHtmlHeaders(response: Response) { + const responseHeaders: [string, string][] = [...response.headers] + const htmlHeaders = responseHeaders.filter(([k]) => k.toLowerCase() !== 'content-type') + htmlHeaders.push(['content-type', 'text/html;charset=utf-8']) + return htmlHeaders +} + +function canUsePrehashCache(request: Request) { + return !request.headers.has('cookie') && !request.headers.has('authorization') +} + export async function fetchHandler(request: Request) { try { const url = new URL(request.url) @@ -66,15 +78,58 @@ export async function renderHtml({ prerender?: boolean request: Request response: Response +}) { + const cacheMode = getSsrCacheMode() + if (cacheMode === 'prehash') { + return renderHtmlWithPrehashCache({ response, request, prerender }) + } + + return renderHtmlStreaming({ + response, + request, + prerender, + cacheMode, + }) +} + +async function renderHtmlStreaming({ + response, + request, + prerender, + cacheMode, + allReadyTimeoutMs = 50, +}: { + prerender?: boolean + request: Request + response: Response + cacheMode: 'off' | 'post' + allReadyTimeoutMs?: number }) { // GET/HEAD requests only need one SSR-side decode. POST/form submissions still // split a second SSR copy to extract formState before hydrateRoot runs. const needsFormState = request.method !== 'GET' && request.method !== 'HEAD' const [flightForSsrOrForm, flightStream2] = response.body!.tee() - const [flightForFormState, flightForSsr] = needsFormState + const [flightForFormState, flightForSsrRaw] = needsFormState ? flightForSsrOrForm.tee() : [undefined, flightForSsrOrForm] + // In production, for cacheable GET/HEAD 2xx requests, pipe the SSR flight + // stream through a hash transform. The hash accumulates as React consumes + // chunks — no extra tee, no extra race. After allReady resolves within 50ms + // we check the LRU cache using the completed hash digest. + const canCache = cacheMode === 'post' + && !import.meta.env.DEV + && !prerender + && !needsFormState + && response.status >= 200 + && response.status < 300 + && !hasUncacheableHeaders(response.headers) + + const hashTransform = canCache ? createHashTransform() : undefined + const flightForSsr = hashTransform + ? flightForSsrRaw.pipeThrough({ readable: hashTransform.readable, writable: hashTransform.writable }) + : flightForSsrRaw + let baseUrl = new URL('/', request.url).href if (baseUrl.endsWith('/')) { baseUrl = baseUrl.slice(0, -1) @@ -131,6 +186,10 @@ export async function renderHtml({ } } + // Track whether allReady resolved before the 50ms timeout. When true, the + // full RSC stream was consumed and the hash digest is available for caching. + let allReadyBeforeTimeout = false + try { const renderOptions = { bootstrapScriptContent, @@ -165,15 +224,21 @@ export async function renderHtml({ } if (prerender || isbot(request.headers.get('user-agent') || '')) { await htmlStream.allReady + allReadyBeforeTimeout = true } else { // Race allReady against a short timeout to catch redirect/notFound // errors from Suspense boundaries without blocking normal streaming. - let timerId: ReturnType | undefined - const timeout = new Promise((r) => { timerId = setTimeout(r, 50) }) - await Promise.race([ - htmlStream.allReady.finally(() => { if (timerId) clearTimeout(timerId) }), - timeout, - ]) + if (allReadyTimeoutMs <= 0) { + allReadyBeforeTimeout = false + } else { + let timerId: ReturnType | undefined + const timeout = new Promise<'timeout'>((r) => { timerId = setTimeout(() => r('timeout'), allReadyTimeoutMs) }) + const winner = await Promise.race([ + htmlStream.allReady.then(() => 'ready' as const).finally(() => { if (timerId) clearTimeout(timerId) }), + timeout, + ]) + allReadyBeforeTimeout = winner === 'ready' + } } if (ssrErrorCtx) { @@ -213,6 +278,49 @@ export async function renderHtml({ } let appendToHead = metaState.getProcessedTags() + + const htmlHeaders = buildHtmlHeaders(response) + + // When allReady resolved before timeout, await the hash digest. The + // transform's flush() may run on a later microtask after allReady, so we + // must await the promise rather than reading synchronously. + const digest = allReadyBeforeTimeout && hashTransform + ? await hashTransform.digestPromise + : undefined + if (digest && status < 300) { + const cached = ssrCache.get(digest) + if (cached) { + // Cancel unconsumed streams to free tee branch buffers + void htmlStream.cancel().catch(() => {}) + void flightStream2.cancel().catch(() => {}) + return new Response(cached.html, { + status, + headers: htmlHeaders, + }) + } + + // Cache miss — collect the full HTML output and store it. + const finalStream = htmlStream.pipeThrough( + injectRSCPayload({ + rscStream: flightStream2, + appendToHead, + }), + ) + const htmlBytes = await collectStream(finalStream) + + ssrCache.set(digest, { + html: htmlBytes, + status, + headers: htmlHeaders, + byteSize: htmlBytes.byteLength, + }) + + return new Response(htmlBytes, { + status, + headers: htmlHeaders, + }) + } + return new Response( htmlStream.pipeThrough( injectRSCPayload({ @@ -222,14 +330,110 @@ export async function renderHtml({ ), { status, - headers: { - ...Object.fromEntries(response.headers), - 'content-type': 'text/html;charset=utf-8', - }, + headers: htmlHeaders, }, ) } +async function renderHtmlWithPrehashCache({ + response, + request, + prerender, +}: { + prerender?: boolean + request: Request + response: Response +}) { + const isGetOrHead = request.method === 'GET' || request.method === 'HEAD' + const canCache = !import.meta.env.DEV + && !prerender + && isGetOrHead + && response.status >= 200 + && response.status < 300 + && !hasUncacheableHeaders(response.headers) + && canUsePrehashCache(request) + && !isbot(request.headers.get('user-agent') || '') + + if (!canCache) { + return renderHtmlStreaming({ + response, + request, + prerender, + cacheMode: 'off', + }) + } + + const [flightForHash, flightOriginal] = response.body!.tee() + const prehash = prehashFlightStream(flightForHash) + const startedAt = Date.now() + let timerId: ReturnType | undefined + const timeout = new Promise<'timeout'>((resolve) => { + timerId = setTimeout(() => resolve('timeout'), 50) + }) + + const prehashResult = await Promise.race([ + prehash.resultPromise.finally(() => { + if (timerId) clearTimeout(timerId) + }), + timeout, + ]) + + if (prehashResult === 'timeout') { + await prehash.cancel() + const elapsedMs = Date.now() - startedAt + const remainingAllReadyBudget = Math.max(0, 50 - elapsedMs) + return renderHtmlStreaming({ + response: new Response(flightOriginal, { + status: response.status, + headers: response.headers, + }), + request, + prerender, + cacheMode: 'off', + allReadyTimeoutMs: remainingAllReadyBudget, + }) + } + + await flightOriginal.cancel().catch(() => {}) + const htmlHeaders = buildHtmlHeaders(response) + const cached = ssrCache.get(prehashResult.digest) + if (cached) { + return new Response(cached.html, { + status: response.status, + headers: htmlHeaders, + }) + } + + const htmlResponse = await renderHtmlStreaming({ + response: new Response(new Blob(prehashResult.chunks), { + status: response.status, + headers: response.headers, + }), + request, + prerender, + cacheMode: 'off', + allReadyTimeoutMs: 0, + }) + + if (htmlResponse.status >= 300 || !htmlResponse.body) { + return htmlResponse + } + + const htmlBytes = await collectStream(htmlResponse.body) + const headers: [string, string][] = [...htmlResponse.headers] + ssrCache.set(prehashResult.digest, { + html: htmlBytes, + status: htmlResponse.status, + headers, + byteSize: htmlBytes.byteLength, + }) + + return new Response(htmlBytes, { + status: htmlResponse.status, + headers, + }) +} + export async function prerender(request: Request) { const reactServer = await importRscEnvironment() const response = await reactServer.handler(request) diff --git a/spiceflow/src/react/ssr-cache.test.ts b/spiceflow/src/react/ssr-cache.test.ts new file mode 100644 index 00000000..f9e4402b --- /dev/null +++ b/spiceflow/src/react/ssr-cache.test.ts @@ -0,0 +1,298 @@ +import { describe, test, expect } from 'vitest' +import { LRUCache } from '../lru.js' +import { createHashTransform, collectStream, getSsrCacheMode, hasUncacheableHeaders, isSsrCacheEnabled, prehashFlightStream } from './ssr-cache.js' + +const encoder = new TextEncoder() + +function makeEntry(size: number) { + return { + html: new Uint8Array(size), + status: 200, + headers: [['content-type', 'text/html']] as [string, string][], + byteSize: size, + } +} + +async function hashBytes(chunks: Uint8Array[]) { + const { readable, writable, digestPromise } = createHashTransform() + const source = new Blob(chunks).stream() + // Must consume readable concurrently — pipeTo blocks if no one drains the output + await Promise.all([ + source.pipeTo(writable), + readable.pipeTo(new WritableStream()), + ]) + return { digest: await digestPromise } +} + +describe('LRUCache', () => { + test('get returns undefined for missing key', () => { + const cache = new LRUCache(1024) + expect(cache.get('missing')).toBe(undefined) + }) + + test('set and get round-trip', () => { + const cache = new LRUCache(1024) + const entry = makeEntry(100) + cache.set('a', entry) + expect(cache.get('a')).toBe(entry) + expect(cache.size).toBe(1) + expect(cache.bytes).toBe(100) + }) + + test('evicts oldest entries when exceeding maxBytes', () => { + const cache = new LRUCache(250) + cache.set('a', makeEntry(100)) + cache.set('b', makeEntry(100)) + cache.set('c', makeEntry(100)) + expect(cache.get('a')).toBe(undefined) + expect(cache.get('b')).not.toBe(undefined) + expect(cache.get('c')).not.toBe(undefined) + expect(cache.bytes).toBeLessThanOrEqual(250) + }) + + test('accessing an entry makes it most recent (not evicted)', () => { + const cache = new LRUCache(250) + cache.set('a', makeEntry(100)) + cache.set('b', makeEntry(100)) + cache.get('a') + cache.set('c', makeEntry(100)) + expect(cache.get('a')).not.toBe(undefined) + expect(cache.get('b')).toBe(undefined) + expect(cache.get('c')).not.toBe(undefined) + }) + + test('replacing an existing key updates byteSize', () => { + const cache = new LRUCache(1024) + cache.set('a', makeEntry(100)) + expect(cache.bytes).toBe(100) + cache.set('a', makeEntry(200)) + expect(cache.bytes).toBe(200) + expect(cache.size).toBe(1) + }) + + test('clear resets everything', () => { + const cache = new LRUCache(1024) + cache.set('a', makeEntry(100)) + cache.set('b', makeEntry(200)) + cache.clear() + expect(cache.size).toBe(0) + expect(cache.bytes).toBe(0) + expect(cache.get('a')).toBe(undefined) + }) + + test('single entry larger than maxBytes gets evicted immediately', () => { + const cache = new LRUCache(50) + cache.set('big', makeEntry(100)) + expect(cache.size).toBe(0) + }) +}) + +describe('createHashTransform', () => { + test('same content produces same digest', async () => { + const r1 = await hashBytes([encoder.encode('hello'), encoder.encode(' world')]) + const r2 = await hashBytes([encoder.encode('hello'), encoder.encode(' world')]) + expect(r1.digest).toBe(r2.digest) + }) + + test('different content produces different digest', async () => { + const r1 = await hashBytes([encoder.encode('aaa')]) + const r2 = await hashBytes([encoder.encode('bbb')]) + expect(r1.digest).not.toBe(r2.digest) + }) + + test('digest is a 32-char hex string (md5)', async () => { + const r = await hashBytes([encoder.encode('test')]) + expect(r.digest).toMatch(/^[0-9a-f]{32}$/) + }) + + test('digestPromise resolves only after stream closes', async () => { + const { readable, writable, digestPromise } = createHashTransform() + let resolved = false + digestPromise.then(() => { resolved = true }) + // Start draining readable concurrently to avoid backpressure + const drain = readable.pipeTo(new WritableStream()) + // Write a chunk but don't close yet + const writer = writable.getWriter() + await writer.write(encoder.encode('partial')) + // Digest should not be resolved yet since stream is still open + await new Promise((r) => setTimeout(r, 10)) + expect(resolved).toBe(false) + // Close the stream — flush runs and digest resolves + await writer.close() + await drain + const digest = await digestPromise + expect(digest).toMatch(/^[0-9a-f]{32}$/) + }) + + test('passes chunks through unmodified', async () => { + const { readable, writable } = createHashTransform() + const source = new Blob([encoder.encode('hello'), encoder.encode(' world')]).stream() + const piped = source.pipeThrough({ readable, writable }) + const result = await collectStream(piped) + expect(new TextDecoder().decode(result)).toBe('hello world') + }) +}) + +describe('collectStream', () => { + test('concatenates all chunks into single Uint8Array', async () => { + const chunks = [encoder.encode('hello'), encoder.encode(' '), encoder.encode('world')] + const result = await collectStream(new Blob(chunks).stream()) + expect(new TextDecoder().decode(result)).toBe('hello world') + expect(result.byteLength).toBe(11) + }) + + test('empty stream produces empty Uint8Array', async () => { + const result = await collectStream(new Blob([]).stream()) + expect(result.byteLength).toBe(0) + }) +}) + +describe('prehashFlightStream', () => { + test('returns digest and original bytes', async () => { + const chunks = [encoder.encode('hello'), encoder.encode(' world')] + const prehash = prehashFlightStream(new Blob(chunks).stream()) + const result = await prehash.resultPromise + expect(result.digest).toMatch(/^[0-9a-f]{32}$/) + expect(await new Blob(result.chunks).text()).toBe('hello world') + }) + + test('cancel stops the reader cleanly', async () => { + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(encoder.encode('chunk')) + }, + }) + const prehash = prehashFlightStream(stream) + await prehash.cancel() + await expect(prehash.resultPromise).resolves.toEqual({ + digest: 'd41d8cd98f00b204e9800998ecf8427e', + chunks: [], + }) + }) +}) + +describe('hasUncacheableHeaders', () => { + test('returns true when Set-Cookie is present', () => { + const headers = new Headers() + headers.set('set-cookie', 'session=abc') + expect(hasUncacheableHeaders(headers)).toBe(true) + }) + + test('returns false for normal headers', () => { + const headers = new Headers() + headers.set('content-type', 'text/html') + headers.set('x-custom', 'value') + expect(hasUncacheableHeaders(headers)).toBe(false) + }) +}) + +describe('isSsrCacheEnabled', () => { + test('is enabled by default', () => { + const original = process.env.SPICEFLOW_DISABLE_SSR_CACHE + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + try { + expect(isSsrCacheEnabled()).toBe(true) + } finally { + if (original === undefined) { + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + } else { + process.env.SPICEFLOW_DISABLE_SSR_CACHE = original + } + } + }) + + test('is disabled when SPICEFLOW_DISABLE_SSR_CACHE is set', () => { + const original = process.env.SPICEFLOW_DISABLE_SSR_CACHE + process.env.SPICEFLOW_DISABLE_SSR_CACHE = '1' + try { + expect(isSsrCacheEnabled()).toBe(false) + } finally { + if (original === undefined) { + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + } else { + process.env.SPICEFLOW_DISABLE_SSR_CACHE = original + } + } + }) + + test('treats 0 and false as enabled', () => { + const original = process.env.SPICEFLOW_DISABLE_SSR_CACHE + try { + process.env.SPICEFLOW_DISABLE_SSR_CACHE = '0' + expect(isSsrCacheEnabled()).toBe(true) + process.env.SPICEFLOW_DISABLE_SSR_CACHE = 'false' + expect(isSsrCacheEnabled()).toBe(true) + } finally { + if (original === undefined) { + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + } else { + process.env.SPICEFLOW_DISABLE_SSR_CACHE = original + } + } + }) +}) + +describe('getSsrCacheMode', () => { + test('defaults to post', () => { + const originalDisable = process.env.SPICEFLOW_DISABLE_SSR_CACHE + const originalMode = process.env.SPICEFLOW_SSR_CACHE_MODE + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + delete process.env.SPICEFLOW_SSR_CACHE_MODE + try { + expect(getSsrCacheMode()).toBe('post') + } finally { + if (originalDisable === undefined) { + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + } else { + process.env.SPICEFLOW_DISABLE_SSR_CACHE = originalDisable + } + if (originalMode === undefined) { + delete process.env.SPICEFLOW_SSR_CACHE_MODE + } else { + process.env.SPICEFLOW_SSR_CACHE_MODE = originalMode + } + } + }) + + test('supports prehash mode', () => { + const originalDisable = process.env.SPICEFLOW_DISABLE_SSR_CACHE + const originalMode = process.env.SPICEFLOW_SSR_CACHE_MODE + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + process.env.SPICEFLOW_SSR_CACHE_MODE = 'prehash' + try { + expect(getSsrCacheMode()).toBe('prehash') + } finally { + if (originalDisable === undefined) { + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + } else { + process.env.SPICEFLOW_DISABLE_SSR_CACHE = originalDisable + } + if (originalMode === undefined) { + delete process.env.SPICEFLOW_SSR_CACHE_MODE + } else { + process.env.SPICEFLOW_SSR_CACHE_MODE = originalMode + } + } + }) + + test('disable flag wins over mode', () => { + const originalDisable = process.env.SPICEFLOW_DISABLE_SSR_CACHE + const originalMode = process.env.SPICEFLOW_SSR_CACHE_MODE + process.env.SPICEFLOW_DISABLE_SSR_CACHE = '1' + process.env.SPICEFLOW_SSR_CACHE_MODE = 'prehash' + try { + expect(getSsrCacheMode()).toBe('off') + } finally { + if (originalDisable === undefined) { + delete process.env.SPICEFLOW_DISABLE_SSR_CACHE + } else { + process.env.SPICEFLOW_DISABLE_SSR_CACHE = originalDisable + } + if (originalMode === undefined) { + delete process.env.SPICEFLOW_SSR_CACHE_MODE + } else { + process.env.SPICEFLOW_SSR_CACHE_MODE = originalMode + } + } + }) +}) diff --git a/spiceflow/src/react/ssr-cache.ts b/spiceflow/src/react/ssr-cache.ts new file mode 100644 index 00000000..425d0f57 --- /dev/null +++ b/spiceflow/src/react/ssr-cache.ts @@ -0,0 +1,130 @@ +// SSR HTML cache utilities. Caches the final HTML output keyed by a progressive +// MD5 hash of the RSC flight stream. Only used when the RSC stream completes +// within 50ms (fast pages), so slow streaming responses bypass caching entirely. + +import crypto from 'node:crypto' +import { LRUCache } from '../lru.js' + +export interface SsrCacheEntry { + html: Uint8Array + status: number + headers: [string, string][] + byteSize: number +} + +export type SsrCacheMode = 'off' | 'post' | 'prehash' + +const HEADERS_TO_SKIP_CACHING = new Set([ + 'set-cookie', +]) + +export function hasUncacheableHeaders(headers: Headers): boolean { + for (const name of HEADERS_TO_SKIP_CACHING) { + if (headers.has(name)) return true + } + return false +} + +export function isSsrCacheEnabled() { + if (typeof process === 'undefined') return true + const value = process.env.SPICEFLOW_DISABLE_SSR_CACHE + if (!value) return true + return value === '0' || value === 'false' +} + +export function getSsrCacheMode(): SsrCacheMode { + if (!isSsrCacheEnabled()) return 'off' + if (typeof process === 'undefined') return 'post' + const value = process.env.SPICEFLOW_SSR_CACHE_MODE + if (!value) return 'post' + if (value === 'off') return 'off' + if (value === 'prehash') return 'prehash' + return 'post' +} + +// 5 MB default +export const ssrCache = new LRUCache(5 * 1024 * 1024) + +/** + * Creates a TransformStream that progressively hashes each chunk with MD5 as + * it passes through. Chunks pass through unmodified — the hash is a side effect. + * `digestPromise` resolves with the hex digest when the stream fully closes + * (flush runs). Await it instead of polling, since flush may run on a later + * microtask after allReady resolves. + */ +export function createHashTransform() { + const hash = crypto.createHash('md5') + let resolveDigest: (value: string) => void + const digestPromise = new Promise((r) => { resolveDigest = r }) + + const transform = new TransformStream({ + transform(chunk, controller) { + hash.update(chunk) + controller.enqueue(chunk) + }, + flush() { + resolveDigest(hash.digest('hex')) + }, + }) + + return { + readable: transform.readable, + writable: transform.writable, + digestPromise, + } +} + +export function prehashFlightStream(stream: ReadableStream) { + const hash = crypto.createHash('md5') + const chunks: Uint8Array[] = [] + const reader = stream.getReader() + + const resultPromise = (async () => { + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + hash.update(value) + chunks.push(value) + } + return { + digest: hash.digest('hex'), + chunks, + } + } finally { + reader.releaseLock() + } + })() + + return { + resultPromise, + async cancel() { + try { + await reader.cancel() + } catch {} + }, + } +} + +/** + * Reads a ReadableStream to completion and concatenates all chunks into a + * single Uint8Array. + */ +export async function collectStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + const parts: Uint8Array[] = [] + let totalLength = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + parts.push(value) + totalLength += value.byteLength + } + const result = new Uint8Array(totalLength) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.byteLength + } + return result +}