Skip to content

Commit 5f12b83

Browse files
committed
perf: cache rendered payloads
1 parent 26d967e commit 5f12b83

File tree

6 files changed

+263
-6
lines changed

6 files changed

+263
-6
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { stringify } from 'devalue'
2+
3+
/**
4+
* Nuxt server plugin that serializes the payload after SSR rendering
5+
* and stashes it on the request event context.
6+
*
7+
* This allows the Nitro payload-cache plugin to cache the payload
8+
* when rendering HTML pages, so that subsequent _payload.json requests
9+
* for the same route can be served from cache without a full re-render.
10+
*
11+
* This mirrors what Nuxt does during pre-rendering (via `payloadCache`),
12+
* but extends it to runtime for ISR-enabled routes.
13+
*/
14+
export default defineNuxtPlugin({
15+
name: 'payload-cache',
16+
setup(nuxtApp) {
17+
// Only run on the server during SSR
18+
if (import.meta.client) return
19+
20+
nuxtApp.hooks.hook('app:rendered', () => {
21+
const ssrContext = nuxtApp.ssrContext
22+
if (!ssrContext) return
23+
24+
// Don't cache error responses or noSSR renders
25+
if (ssrContext.noSSR || ssrContext.error || ssrContext.payload?.error) return
26+
27+
// Don't cache if payload data is empty
28+
const payloadData = ssrContext.payload?.data
29+
if (!payloadData || Object.keys(payloadData).length === 0) return
30+
31+
try {
32+
// Serialize the payload using devalue (same as Nuxt's renderPayloadResponse)
33+
// splitPayload extracts only { data, prerenderedAt } for the external payload
34+
const payload = {
35+
data: ssrContext.payload.data,
36+
prerenderedAt: ssrContext.payload.prerenderedAt,
37+
}
38+
const reducers = ssrContext['~payloadReducers'] ?? {}
39+
const body = stringify(payload, reducers)
40+
41+
// Stash the serialized payload on the event context
42+
// The Nitro payload-cache plugin will pick this up in render:response
43+
const event = ssrContext.event
44+
if (event) {
45+
event.context._cachedPayloadResponse = {
46+
body,
47+
statusCode: 200,
48+
headers: {
49+
'content-type': 'application/json;charset=utf-8',
50+
'x-powered-by': 'Nuxt',
51+
},
52+
}
53+
}
54+
} catch (error) {
55+
// Serialization failed — don't cache, but don't break the render
56+
if (import.meta.dev) {
57+
// eslint-disable-next-line no-console
58+
console.warn('[payload-cache] Failed to serialize payload:', error)
59+
}
60+
}
61+
})
62+
},
63+
})

modules/cache.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { provider } from 'std-env'
55
// Storage key for fetch cache - must match shared/utils/fetch-cache-config.ts
66
const FETCH_CACHE_STORAGE_BASE = 'fetch-cache'
77

8+
// Storage key for payload cache - must match server/plugins/payload-cache.ts
9+
const PAYLOAD_CACHE_STORAGE_KEY = 'payload-cache'
10+
811
export default defineNuxtModule({
912
meta: {
1013
name: 'vercel-cache',
@@ -37,6 +40,12 @@ export default defineNuxtModule({
3740
...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE],
3841
driver: 'vercel-runtime-cache',
3942
}
43+
44+
// Payload cache storage (for runtime payload caching)
45+
nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY] = {
46+
...nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY],
47+
driver: 'vercel-runtime-cache',
48+
}
4049
}
4150

4251
const env = process.env.VERCEL_ENV

nuxt.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ export default defineNuxtConfig({
205205
driver: 'fsLite',
206206
base: './.cache/fetch',
207207
},
208+
'payload-cache': {
209+
driver: 'fsLite',
210+
base: './.cache/payload',
211+
},
208212
'atproto': {
209213
driver: 'fsLite',
210214
base: './.cache/atproto',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
"@vue/test-utils": "2.4.6",
127127
"axe-core": "4.11.1",
128128
"defu": "6.1.4",
129+
"devalue": "5.6.3",
129130
"eslint-plugin-regexp": "3.0.0",
130131
"fast-check": "4.5.3",
131132
"h3": "1.15.5",

pnpm-lock.yaml

Lines changed: 9 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/plugins/payload-cache.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { H3Event } from 'h3'
2+
3+
/**
4+
* Runtime payload cache for ISR-enabled routes.
5+
*
6+
* Mirrors Nuxt's pre-render `payloadCache` behavior at runtime:
7+
* - When an HTML page is rendered, the payload is cached (serialized by the
8+
* Nuxt app plugin `payload-cache.server.ts` and stashed on event.context)
9+
* - When a `_payload.json` request arrives, the cache is checked first.
10+
* If a cached payload exists, it's served immediately — completely skipping
11+
* the full Vue SSR render.
12+
*
13+
* This eliminates redundant full SSR renders for payload requests when the
14+
* same route was already rendered as HTML (or as a payload) recently.
15+
*/
16+
17+
const PAYLOAD_URL_RE = /^[^?]*\/_payload\.json(?:\?.*)?$/
18+
const PAYLOAD_CACHE_STORAGE_KEY = 'payload-cache'
19+
20+
/** Default TTL for cached payloads (seconds). Matches ISR expiration for package routes. */
21+
const PAYLOAD_CACHE_TTL = 60
22+
23+
interface CachedPayload {
24+
body: string
25+
statusCode: number
26+
headers: Record<string, string>
27+
cachedAt: number
28+
buildId: string
29+
}
30+
31+
export default defineNitroPlugin(nitroApp => {
32+
const storage = useStorage(PAYLOAD_CACHE_STORAGE_KEY)
33+
const buildId = useRuntimeConfig().app.buildId as string
34+
35+
/**
36+
* Get the route path from a _payload.json URL.
37+
* e.g. "/package/vue/v/3.4.0/_payload.json?abc123" → "/package/vue/v/3.4.0"
38+
*/
39+
function getRouteFromPayloadUrl(url: string): string {
40+
const withoutQuery = url.replace(/\?.*$/, '')
41+
return withoutQuery.substring(0, withoutQuery.lastIndexOf('/')) || '/'
42+
}
43+
44+
/**
45+
* Generate a cache key for a route path.
46+
* Includes the build ID to prevent serving stale payloads after deploys.
47+
*/
48+
function getCacheKey(routePath: string): string {
49+
return `${buildId}:${routePath}`
50+
}
51+
52+
/**
53+
* Check if a route has ISR or cache rules enabled.
54+
*/
55+
function isISRRoute(event: H3Event): boolean {
56+
const rules = getRouteRules(event)
57+
return !!(rules.isr || rules.cache)
58+
}
59+
60+
// -------------------------------------------------------------------------
61+
// render:before — Serve cached payloads, skip full SSR render
62+
// -------------------------------------------------------------------------
63+
nitroApp.hooks.hook('render:before', async ctx => {
64+
// Only intercept _payload.json requests
65+
if (!PAYLOAD_URL_RE.test(ctx.event.path)) return
66+
67+
const routePath = getRouteFromPayloadUrl(ctx.event.path)
68+
const cacheKey = getCacheKey(routePath)
69+
70+
try {
71+
const cached = await storage.getItem<CachedPayload>(cacheKey)
72+
if (!cached) return
73+
74+
// Verify build ID matches (extra safety beyond cache key)
75+
if (cached.buildId !== buildId) return
76+
77+
// Check TTL
78+
const age = (Date.now() - cached.cachedAt) / 1000
79+
if (age > PAYLOAD_CACHE_TTL) return
80+
81+
if (import.meta.dev) {
82+
// eslint-disable-next-line no-console
83+
console.log(`[payload-cache] HIT: ${routePath} (age: ${age.toFixed(1)}s)`)
84+
}
85+
86+
// Set the response — this completely skips the Nuxt render function
87+
ctx.response = {
88+
body: cached.body,
89+
statusCode: cached.statusCode,
90+
statusMessage: 'OK',
91+
headers: cached.headers,
92+
}
93+
} catch (error) {
94+
// Cache read failed — let the render proceed normally
95+
if (import.meta.dev) {
96+
// eslint-disable-next-line no-console
97+
console.warn(`[payload-cache] Cache read failed for ${routePath}:`, error)
98+
}
99+
}
100+
})
101+
102+
// -------------------------------------------------------------------------
103+
// render:response — Cache payloads after rendering
104+
// -------------------------------------------------------------------------
105+
nitroApp.hooks.hook('render:response', (response, ctx) => {
106+
// Don't cache error responses
107+
if (response.statusCode && response.statusCode >= 400) return
108+
109+
const isPayloadRequest = PAYLOAD_URL_RE.test(ctx.event.path)
110+
const isHtmlResponse = response.headers?.['content-type']?.includes('text/html')
111+
112+
if (isPayloadRequest) {
113+
// This was a _payload.json render — cache the response body directly
114+
const routePath = getRouteFromPayloadUrl(ctx.event.path)
115+
cachePayload(ctx.event, routePath, {
116+
body: response.body as string,
117+
statusCode: response.statusCode ?? 200,
118+
headers: {
119+
'content-type': 'application/json;charset=utf-8',
120+
'x-powered-by': 'Nuxt',
121+
},
122+
})
123+
} else if (isHtmlResponse && isISRRoute(ctx.event)) {
124+
// This was an HTML render for an ISR route — check if the Nuxt plugin
125+
// stashed a serialized payload on the event context
126+
const cachedPayload = ctx.event.context._cachedPayloadResponse
127+
if (cachedPayload) {
128+
const routePath = ctx.event.path === '/' ? '/' : ctx.event.path.replace(/\/$/, '')
129+
cachePayload(ctx.event, routePath, cachedPayload)
130+
// Clean up the stashed payload
131+
delete ctx.event.context._cachedPayloadResponse
132+
}
133+
}
134+
})
135+
136+
/**
137+
* Write a payload to the cache in the background (non-blocking).
138+
*/
139+
function cachePayload(
140+
event: H3Event,
141+
routePath: string,
142+
payload: { body: string; statusCode: number; headers: Record<string, string> },
143+
) {
144+
const cacheKey = getCacheKey(routePath)
145+
const entry: CachedPayload = {
146+
...payload,
147+
cachedAt: Date.now(),
148+
buildId,
149+
}
150+
151+
// Use waitUntil for non-blocking cache writes in serverless environments
152+
event.waitUntil(
153+
storage.setItem(cacheKey, entry).catch(error => {
154+
if (import.meta.dev) {
155+
// eslint-disable-next-line no-console
156+
console.warn(`[payload-cache] Cache write failed for ${routePath}:`, error)
157+
}
158+
}),
159+
)
160+
161+
if (import.meta.dev) {
162+
// eslint-disable-next-line no-console
163+
console.log(`[payload-cache] CACHED: ${routePath}`)
164+
}
165+
}
166+
})
167+
168+
// Extend the H3EventContext type
169+
declare module 'h3' {
170+
interface H3EventContext {
171+
_cachedPayloadResponse?: {
172+
body: string
173+
statusCode: number
174+
headers: Record<string, string>
175+
}
176+
}
177+
}

0 commit comments

Comments
 (0)