From 503b032fe61974a9ef6d2f935119079107e817df Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 18 Mar 2026 13:16:41 +0100 Subject: [PATCH 1/3] add SSR HTML cache with progressive RSC stream hashing Cache the final HTML output for fast GET/HEAD pages using an LRU cache keyed by an MD5 hash of the RSC flight stream. How it works: - A TransformStream wraps the flight-for-SSR stream, hashing each chunk as React consumes it during SSR (zero extra tees, zero extra races) - After the existing allReady vs 50ms race, if allReady won, the hash digest is available via getDigest() - Cache hit: return cached HTML bytes immediately (cancel unconsumed streams) - Cache miss: collect full HTML via collectStream, store in LRU, return - Timeout (>50ms): stream normally, no caching Safety: - Disabled in dev (import.meta.env.DEV) - Only GET/HEAD requests with 2xx RSC response status - Responses with Set-Cookie or other uncacheable headers skip cache entirely - Cache hit uses current request's computed headers, not stale cached ones - Headers stored as [string, string][] tuples to preserve multi-value semantics - LRU bounded to 5MB total byte size with oldest-first eviction Files: - lru.ts: generic byte-bounded LRU cache (reusable) - react/ssr-cache.ts: SSR-specific cache entry, hash transform, utilities - react/entry.ssr.tsx: caching logic integrated into renderHtml - react/ssr-cache.test.ts: 16 tests covering LRU, hash transform, utilities --- spiceflow/src/lru.ts | 57 ++++++++++ spiceflow/src/react/entry.ssr.tsx | 79 ++++++++++++-- spiceflow/src/react/ssr-cache.test.ts | 149 ++++++++++++++++++++++++++ spiceflow/src/react/ssr-cache.ts | 76 +++++++++++++ 4 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 spiceflow/src/lru.ts create mode 100644 spiceflow/src/react/ssr-cache.test.ts create mode 100644 spiceflow/src/react/ssr-cache.ts diff --git a/spiceflow/src/lru.ts b/spiceflow/src/lru.ts new file mode 100644 index 00000000..e0b8bb87 --- /dev/null +++ b/spiceflow/src/lru.ts @@ -0,0 +1,57 @@ +// Generic LRU cache bounded by total byte size. Each entry must declare its +// byteSize so the cache can track memory usage and evict least-recently-used +// entries when the total exceeds maxBytes. + +export interface Sized { + byteSize: number +} + +export class LRUCache { + 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..225f4308 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, hasUncacheableHeaders } from './ssr-cache.js' import { injectRSCPayload } from './transform.js' let bootstrapScriptContentPromise: Promise | undefined @@ -71,10 +72,26 @@ export async function renderHtml({ // 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 = !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 +148,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 +186,17 @@ 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) }), + const timeout = new Promise<'timeout'>((r) => { timerId = setTimeout(() => r('timeout'), 50) }) + const winner = await Promise.race([ + htmlStream.allReady.then(() => 'ready' as const).finally(() => { if (timerId) clearTimeout(timerId) }), timeout, ]) + allReadyBeforeTimeout = winner === 'ready' } if (ssrErrorCtx) { @@ -213,6 +236,49 @@ export async function renderHtml({ } let appendToHead = metaState.getProcessedTags() + + const responseHeaders: [string, string][] = [...response.headers] + // Override content-type for HTML response + const htmlHeaders = responseHeaders.filter(([k]) => k.toLowerCase() !== 'content-type') + htmlHeaders.push(['content-type', 'text/html;charset=utf-8']) + + // When allReady resolved before timeout and we have a hash digest, try + // caching the fully rendered HTML for future requests. + const digest = hashTransform?.getDigest() + if (allReadyBeforeTimeout && 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,10 +288,7 @@ export async function renderHtml({ ), { status, - headers: { - ...Object.fromEntries(response.headers), - 'content-type': 'text/html;charset=utf-8', - }, + headers: htmlHeaders, }, ) } diff --git a/spiceflow/src/react/ssr-cache.test.ts b/spiceflow/src/react/ssr-cache.test.ts new file mode 100644 index 00000000..893340eb --- /dev/null +++ b/spiceflow/src/react/ssr-cache.test.ts @@ -0,0 +1,149 @@ +import { describe, test, expect } from 'vitest' +import { LRUCache } from '../lru.js' +import { createHashTransform, collectStream, hasUncacheableHeaders } 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[]): Promise<{ digest: string | undefined }> { + const { readable, writable, getDigest } = 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: getDigest() } +} + +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('digest is undefined before stream is fully consumed', () => { + const { getDigest } = createHashTransform() + expect(getDigest()).toBe(undefined) + }) + + 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('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) + }) +}) diff --git a/spiceflow/src/react/ssr-cache.ts b/spiceflow/src/react/ssr-cache.ts new file mode 100644 index 00000000..a44746ff --- /dev/null +++ b/spiceflow/src/react/ssr-cache.ts @@ -0,0 +1,76 @@ +// 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 +} + +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 +} + +// 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. Call `getDigest()` after the stream is fully consumed to + * get the final hash. Chunks pass through unmodified — the hash is a side effect. + */ +export function createHashTransform() { + const hash = crypto.createHash('md5') + let digest: string | undefined + + const transform = new TransformStream({ + transform(chunk, controller) { + hash.update(chunk) + controller.enqueue(chunk) + }, + flush() { + digest = hash.digest('hex') + }, + }) + + return { + readable: transform.readable, + writable: transform.writable, + getDigest() { return digest }, + } +} + +/** + * 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 +} From 657a334902c1ef8c8e1fb3f3049ae08693a82e64 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 18 Mar 2026 14:11:25 +0100 Subject: [PATCH 2/3] refine SSR HTML cache timing and add benchmark kill switch Fix the hash finalization race by awaiting a digest promise that resolves when the hash transform flushes, instead of reading a synchronous getter after SSR allReady. This makes the cache key deterministic even when the transform closes on a later microtask. Also add so the cache can be disabled at runtime for benchmarking and debugging, and document the behavior in tests. The release note is included via a changeset for the public package. --- .changeset/green-pans-cheer.md | 5 ++ spiceflow/src/react/entry.ssr.tsx | 14 +++-- spiceflow/src/react/ssr-cache.test.ts | 74 ++++++++++++++++++++++++--- spiceflow/src/react/ssr-cache.ts | 20 ++++++-- 4 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 .changeset/green-pans-cheer.md 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/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 225f4308..b5adba22 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -13,7 +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, hasUncacheableHeaders } from './ssr-cache.js' +import { ssrCache, createHashTransform, collectStream, hasUncacheableHeaders, isSsrCacheEnabled } from './ssr-cache.js' import { injectRSCPayload } from './transform.js' let bootstrapScriptContentPromise: Promise | undefined @@ -81,6 +81,7 @@ export async function renderHtml({ // chunks — no extra tee, no extra race. After allReady resolves within 50ms // we check the LRU cache using the completed hash digest. const canCache = !import.meta.env.DEV + && isSsrCacheEnabled() && !prerender && !needsFormState && response.status >= 200 @@ -242,10 +243,13 @@ export async function renderHtml({ const htmlHeaders = responseHeaders.filter(([k]) => k.toLowerCase() !== 'content-type') htmlHeaders.push(['content-type', 'text/html;charset=utf-8']) - // When allReady resolved before timeout and we have a hash digest, try - // caching the fully rendered HTML for future requests. - const digest = hashTransform?.getDigest() - if (allReadyBeforeTimeout && digest && status < 300) { + // 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 diff --git a/spiceflow/src/react/ssr-cache.test.ts b/spiceflow/src/react/ssr-cache.test.ts index 893340eb..e3433340 100644 --- a/spiceflow/src/react/ssr-cache.test.ts +++ b/spiceflow/src/react/ssr-cache.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest' import { LRUCache } from '../lru.js' -import { createHashTransform, collectStream, hasUncacheableHeaders } from './ssr-cache.js' +import { createHashTransform, collectStream, hasUncacheableHeaders, isSsrCacheEnabled } from './ssr-cache.js' const encoder = new TextEncoder() @@ -13,15 +13,15 @@ function makeEntry(size: number) { } } -async function hashBytes(chunks: Uint8Array[]): Promise<{ digest: string | undefined }> { - const { readable, writable, getDigest } = createHashTransform() +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: getDigest() } + return { digest: await digestPromise } } describe('LRUCache', () => { @@ -105,9 +105,23 @@ describe('createHashTransform', () => { expect(r.digest).toMatch(/^[0-9a-f]{32}$/) }) - test('digest is undefined before stream is fully consumed', () => { - const { getDigest } = createHashTransform() - expect(getDigest()).toBe(undefined) + 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 () => { @@ -147,3 +161,49 @@ describe('hasUncacheableHeaders', () => { 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 + } + } + }) +}) diff --git a/spiceflow/src/react/ssr-cache.ts b/spiceflow/src/react/ssr-cache.ts index a44746ff..9a05a650 100644 --- a/spiceflow/src/react/ssr-cache.ts +++ b/spiceflow/src/react/ssr-cache.ts @@ -23,17 +23,27 @@ export function hasUncacheableHeaders(headers: Headers): boolean { 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' +} + // 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. Call `getDigest()` after the stream is fully consumed to - * get the final hash. Chunks pass through unmodified — the hash is a side effect. + * 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 digest: string | undefined + let resolveDigest: (value: string) => void + const digestPromise = new Promise((r) => { resolveDigest = r }) const transform = new TransformStream({ transform(chunk, controller) { @@ -41,14 +51,14 @@ export function createHashTransform() { controller.enqueue(chunk) }, flush() { - digest = hash.digest('hex') + resolveDigest(hash.digest('hex')) }, }) return { readable: transform.readable, writable: transform.writable, - getDigest() { return digest }, + digestPromise, } } From 78d7cdd9ba34846171790d76035759e87f35bc9d Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 18 Mar 2026 14:56:07 +0100 Subject: [PATCH 3/3] benchmark prehash SSR cache mode on a heavier about route Add an experimental path that hashes the RSC flight stream before SSR so cache hits can skip HTML rendering entirely. The implementation keeps the existing 50ms streaming budget by handing the remaining time to the normal allReady race on timeout. Also make the node benchmark route much heavier in the Spiceflow, Next.js, and Hono examples so SSR and HTML generation costs are easier to compare under load. This makes the current post-SSR cache win more obvious and shows that the prehash path still does not pay off on this route. --- .changeset/late-bottles-bathe.md | 5 + nodejs-example/app/main.tsx | 154 ++++++++++++++-- nodejs-example/hono-baseline.ts | 122 ++++++++++++- .../nextjs-baseline/app/about/page.tsx | 156 ++++++++++++++++- spiceflow/src/react/entry.ssr.tsx | 165 ++++++++++++++++-- spiceflow/src/react/ssr-cache.test.ts | 91 +++++++++- spiceflow/src/react/ssr-cache.ts | 44 +++++ 7 files changed, 695 insertions(+), 42 deletions(-) create mode 100644 .changeset/late-bottles-bathe.md 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}

+
+ ))} +
+
+ | undefined @@ -34,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) @@ -67,6 +78,32 @@ 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. @@ -80,8 +117,8 @@ export async function renderHtml({ // 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 = !import.meta.env.DEV - && isSsrCacheEnabled() + const canCache = cacheMode === 'post' + && !import.meta.env.DEV && !prerender && !needsFormState && response.status >= 200 @@ -191,13 +228,17 @@ export async function renderHtml({ } 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<'timeout'>((r) => { timerId = setTimeout(() => r('timeout'), 50) }) - const winner = await Promise.race([ - htmlStream.allReady.then(() => 'ready' as const).finally(() => { if (timerId) clearTimeout(timerId) }), - timeout, - ]) - allReadyBeforeTimeout = winner === 'ready' + 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) { @@ -238,10 +279,7 @@ export async function renderHtml({ let appendToHead = metaState.getProcessedTags() - const responseHeaders: [string, string][] = [...response.headers] - // Override content-type for HTML response - const htmlHeaders = responseHeaders.filter(([k]) => k.toLowerCase() !== 'content-type') - htmlHeaders.push(['content-type', 'text/html;charset=utf-8']) + 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 @@ -297,6 +335,105 @@ export async function renderHtml({ ) } +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 index e3433340..f9e4402b 100644 --- a/spiceflow/src/react/ssr-cache.test.ts +++ b/spiceflow/src/react/ssr-cache.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest' import { LRUCache } from '../lru.js' -import { createHashTransform, collectStream, hasUncacheableHeaders, isSsrCacheEnabled } from './ssr-cache.js' +import { createHashTransform, collectStream, getSsrCacheMode, hasUncacheableHeaders, isSsrCacheEnabled, prehashFlightStream } from './ssr-cache.js' const encoder = new TextEncoder() @@ -147,6 +147,30 @@ describe('collectStream', () => { }) }) +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() @@ -207,3 +231,68 @@ describe('isSsrCacheEnabled', () => { } }) }) + +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 index 9a05a650..425d0f57 100644 --- a/spiceflow/src/react/ssr-cache.ts +++ b/spiceflow/src/react/ssr-cache.ts @@ -12,6 +12,8 @@ export interface SsrCacheEntry { byteSize: number } +export type SsrCacheMode = 'off' | 'post' | 'prehash' + const HEADERS_TO_SKIP_CACHING = new Set([ 'set-cookie', ]) @@ -30,6 +32,16 @@ export function isSsrCacheEnabled() { 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) @@ -62,6 +74,38 @@ export function createHashTransform() { } } +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.