|
| 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