Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/plugins/payload-cache.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { stringify } from 'devalue'

/**
* Nuxt server plugin that serializes the payload after SSR rendering
* and stashes it on the request event context.
*
* This allows the Nitro payload-cache plugin to cache the payload
* when rendering HTML pages, so that subsequent _payload.json requests
* for the same route can be served from cache without a full re-render.
*
* This mirrors what Nuxt does during pre-rendering (via `payloadCache`),
* but extends it to runtime for ISR-enabled routes.
*/
export default defineNuxtPlugin({
name: 'payload-cache',
setup(nuxtApp) {
// Only run on the server during SSR
if (import.meta.client) return

nuxtApp.hooks.hook('app:rendered', () => {
const ssrContext = nuxtApp.ssrContext
if (!ssrContext) return

// Don't cache error responses or noSSR renders
if (ssrContext.noSSR || ssrContext.error || ssrContext.payload?.error) return

// Don't cache if payload data is empty
const payloadData = ssrContext.payload?.data
if (!payloadData || Object.keys(payloadData).length === 0) return

Comment on lines +27 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid caching placeholder payloads that are effectively empty.

Line 29 only checks top-level key count. Payload shapes like placeholder entries (for example, fallback-style empty records wrapped in arrays) can still pass and get cached as valid data, which risks stale empty payload reuse and hydration inconsistencies. Please harden this emptiness check before caching.

Suggested patch
       // Don't cache if payload data is empty
       const payloadData = ssrContext.payload?.data
-      if (!payloadData || Object.keys(payloadData).length === 0) return
+      const isPlaceholderValue = (value: unknown) =>
+        Array.isArray(value)
+        && value.length === 1
+        && typeof value[0] === 'object'
+        && value[0] !== null
+        && Object.keys(value[0] as Record<string, unknown>).length === 0
+
+      const isEmptyPayload =
+        !payloadData
+        || Object.keys(payloadData).length === 0
+        || Object.values(payloadData).every((value) => value == null || isPlaceholderValue(value))
+
+      if (isEmptyPayload) return

try {
// Serialize the payload using devalue (same as Nuxt's renderPayloadResponse)
// splitPayload extracts only { data, prerenderedAt } for the external payload
const payload = {
data: ssrContext.payload.data,
prerenderedAt: ssrContext.payload.prerenderedAt,
}
const reducers = ssrContext['~payloadReducers'] ?? {}
const body = stringify(payload, reducers)

// Stash the serialized payload on the event context
// The Nitro payload-cache plugin will pick this up in render:response
const event = ssrContext.event
if (event) {
event.context._cachedPayloadResponse = {
body,
statusCode: 200,
headers: {
'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt',
},
}
}
} catch (error) {
// Serialization failed — don't cache, but don't break the render
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.warn('[payload-cache] Failed to serialize payload:', error)
}
}
})
},
})
9 changes: 9 additions & 0 deletions modules/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { provider } from 'std-env'
// Storage key for fetch cache - must match shared/utils/fetch-cache-config.ts
const FETCH_CACHE_STORAGE_BASE = 'fetch-cache'

// Storage key for payload cache - must match server/plugins/payload-cache.ts
const PAYLOAD_CACHE_STORAGE_KEY = 'payload-cache'

export default defineNuxtModule({
meta: {
name: 'vercel-cache',
Expand Down Expand Up @@ -37,6 +40,12 @@ export default defineNuxtModule({
...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE],
driver: 'vercel-runtime-cache',
}

// Payload cache storage (for runtime payload caching)
nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY] = {
...nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY],
driver: 'vercel-runtime-cache',
}
}

const env = process.env.VERCEL_ENV
Expand Down
4 changes: 4 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ export default defineNuxtConfig({
driver: 'fsLite',
base: './.cache/fetch',
},
'payload-cache': {
driver: 'fsLite',
base: './.cache/payload',
},
'atproto': {
driver: 'fsLite',
base: './.cache/atproto',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"@vue/test-utils": "2.4.6",
"axe-core": "4.11.1",
"defu": "6.1.4",
"devalue": "5.6.3",
"eslint-plugin-regexp": "3.0.0",
"fast-check": "4.5.3",
"h3": "1.15.5",
Expand Down
15 changes: 9 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

177 changes: 177 additions & 0 deletions server/plugins/payload-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { H3Event } from 'h3'

/**
* Runtime payload cache for ISR-enabled routes.
*
* Mirrors Nuxt's pre-render `payloadCache` behavior at runtime:
* - When an HTML page is rendered, the payload is cached (serialized by the
* Nuxt app plugin `payload-cache.server.ts` and stashed on event.context)
* - When a `_payload.json` request arrives, the cache is checked first.
* If a cached payload exists, it's served immediately — completely skipping
Comment thread
alexdln marked this conversation as resolved.
* the full Vue SSR render.
*
* This eliminates redundant full SSR renders for payload requests when the
* same route was already rendered as HTML (or as a payload) recently.
*/

const PAYLOAD_URL_RE = /^[^?]*\/_payload\.json(?:\?.*)?$/
const PAYLOAD_CACHE_STORAGE_KEY = 'payload-cache'

/** Default TTL for cached payloads (seconds). Matches ISR expiration for package routes. */
const PAYLOAD_CACHE_TTL = 60

interface CachedPayload {
body: string
statusCode: number
headers: Record<string, string>
cachedAt: number
buildId: string
}

export default defineNitroPlugin(nitroApp => {
const storage = useStorage(PAYLOAD_CACHE_STORAGE_KEY)
const buildId = useRuntimeConfig().app.buildId as string

/**
* Get the route path from a _payload.json URL.
* e.g. "/package/vue/v/3.4.0/_payload.json?abc123" → "/package/vue/v/3.4.0"
*/
function getRouteFromPayloadUrl(url: string): string {
const withoutQuery = url.replace(/\?.*$/, '')
return withoutQuery.substring(0, withoutQuery.lastIndexOf('/')) || '/'
}

/**
* Generate a cache key for a route path.
* Includes the build ID to prevent serving stale payloads after deploys.
*/
function getCacheKey(routePath: string): string {
return `${buildId}:${routePath}`
}

/**
* Check if a route has ISR or cache rules enabled.
*/
function isISRRoute(event: H3Event): boolean {
const rules = getRouteRules(event)
return !!(rules.isr || rules.cache)
}

// -------------------------------------------------------------------------
// render:before — Serve cached payloads, skip full SSR render
// -------------------------------------------------------------------------
nitroApp.hooks.hook('render:before', async ctx => {
// Only intercept _payload.json requests
if (!PAYLOAD_URL_RE.test(ctx.event.path)) return

const routePath = getRouteFromPayloadUrl(ctx.event.path)
const cacheKey = getCacheKey(routePath)

try {
const cached = await storage.getItem<CachedPayload>(cacheKey)
if (!cached) return

// Verify build ID matches (extra safety beyond cache key)
if (cached.buildId !== buildId) return

// Check TTL
const age = (Date.now() - cached.cachedAt) / 1000
if (age > PAYLOAD_CACHE_TTL) return

Comment on lines +86 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Evict expired payload entries to avoid unbounded cache growth.

Line 87 skips stale entries but never deletes them. Over time this can leave old keys accumulating in storage indefinitely.

🧹 Suggested fix
       const age = (Date.now() - cached.cachedAt) / 1000
-      if (age > PAYLOAD_CACHE_STALE_TTL) return
+      if (age > PAYLOAD_CACHE_STALE_TTL) {
+        ctx.event.waitUntil(storage.removeItem(cacheKey).catch(() => {}))
+        return
+      }

Also applies to: 149-169

if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log(`[payload-cache] HIT: ${routePath} (age: ${age.toFixed(1)}s)`)
}

// Set the response — this completely skips the Nuxt render function
ctx.response = {
body: cached.body,
statusCode: cached.statusCode,
statusMessage: 'OK',
headers: cached.headers,
}
Comment thread
alexdln marked this conversation as resolved.
} catch (error) {
// Cache read failed — let the render proceed normally
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.warn(`[payload-cache] Cache read failed for ${routePath}:`, error)
}
}
})

// -------------------------------------------------------------------------
// render:response — Cache payloads after rendering
// -------------------------------------------------------------------------
nitroApp.hooks.hook('render:response', (response, ctx) => {
// Don't cache error responses
if (response.statusCode && response.statusCode >= 400) return
Comment thread
danielroe marked this conversation as resolved.
Outdated

const isPayloadRequest = PAYLOAD_URL_RE.test(ctx.event.path)
const isHtmlResponse = response.headers?.['content-type']?.includes('text/html')

if (isPayloadRequest) {
// This was a _payload.json render — cache the response body directly
const routePath = getRouteFromPayloadUrl(ctx.event.path)
cachePayload(ctx.event, routePath, {
body: response.body as string,
statusCode: response.statusCode ?? 200,
headers: {
'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt',
},
})
} else if (isHtmlResponse && isISRRoute(ctx.event)) {
Comment on lines +120 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply the same cacheability gate to payload-response writes.

The _payload.json branch caches every successful payload response, but the HTML branch only caches when isISRRoute(ctx.event) is true. This inconsistency can cache routes that were not intended to be cache-backed.

🔒 Suggested fix
     if (isPayloadRequest) {
+      if (!isISRRoute(ctx.event)) return
       // This was a _payload.json render — cache the response body directly
       if (typeof response.body !== 'string') return
       const routePath = getRouteFromPayloadUrl(ctx.event.path)
       cachePayload(ctx.event, routePath, {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isPayloadRequest) {
// This was a _payload.json render — cache the response body directly
if (typeof response.body !== 'string') return
const routePath = getRouteFromPayloadUrl(ctx.event.path)
cachePayload(ctx.event, routePath, {
body: response.body,
statusCode: response.statusCode ?? 200,
headers: {
'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt',
},
})
} else if (isHtmlResponse && isISRRoute(ctx.event)) {
if (isPayloadRequest) {
if (!isISRRoute(ctx.event)) return
// This was a _payload.json render — cache the response body directly
if (typeof response.body !== 'string') return
const routePath = getRouteFromPayloadUrl(ctx.event.path)
cachePayload(ctx.event, routePath, {
body: response.body,
statusCode: response.statusCode ?? 200,
headers: {
'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt',
},
})
} else if (isHtmlResponse && isISRRoute(ctx.event)) {

// This was an HTML render for an ISR route — check if the Nuxt plugin
// stashed a serialized payload on the event context
const cachedPayload = ctx.event.context._cachedPayloadResponse
if (cachedPayload) {
const routePath = ctx.event.path === '/' ? '/' : ctx.event.path.replace(/\/$/, '')
cachePayload(ctx.event, routePath, cachedPayload)
// Clean up the stashed payload
delete ctx.event.context._cachedPayloadResponse
}
}
Comment thread
alexdln marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

/**
* Write a payload to the cache in the background (non-blocking).
*/
function cachePayload(
event: H3Event,
routePath: string,
payload: { body: string; statusCode: number; headers: Record<string, string> },
) {
const cacheKey = getCacheKey(routePath)
const entry: CachedPayload = {
...payload,
cachedAt: Date.now(),
buildId,
}

// Use waitUntil for non-blocking cache writes in serverless environments
event.waitUntil(
storage.setItem(cacheKey, entry).catch(error => {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.warn(`[payload-cache] Cache write failed for ${routePath}:`, error)
}
}),
)

if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log(`[payload-cache] CACHED: ${routePath}`)
}
}
})

// Extend the H3EventContext type
declare module 'h3' {
interface H3EventContext {
_cachedPayloadResponse?: {
body: string
statusCode: number
headers: Record<string, string>
}
}
}
Loading