From 5f12b83c9bc1b1285678529b6d5f6bfbe502f1aa Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 25 Feb 2026 09:58:55 +0000 Subject: [PATCH 01/12] perf: cache rendered payloads --- app/plugins/payload-cache.server.ts | 63 ++++++++++ modules/cache.ts | 9 ++ nuxt.config.ts | 4 + package.json | 1 + pnpm-lock.yaml | 15 ++- server/plugins/payload-cache.ts | 177 ++++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 app/plugins/payload-cache.server.ts create mode 100644 server/plugins/payload-cache.ts diff --git a/app/plugins/payload-cache.server.ts b/app/plugins/payload-cache.server.ts new file mode 100644 index 000000000..6ff419b97 --- /dev/null +++ b/app/plugins/payload-cache.server.ts @@ -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 + + 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) + } + } + }) + }, +}) diff --git a/modules/cache.ts b/modules/cache.ts index a2e7c3a41..ab317475a 100644 --- a/modules/cache.ts +++ b/modules/cache.ts @@ -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', @@ -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 diff --git a/nuxt.config.ts b/nuxt.config.ts index 148b48eaa..a59a40988 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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', diff --git a/package.json b/package.json index d0d000c24..47083501a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e75db945..28fd73c81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: axe-core: specifier: 4.11.1 version: 4.11.1 + devalue: + specifier: 5.6.3 + version: 5.6.3 eslint-plugin-regexp: specifier: 3.0.0 version: 3.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -5743,8 +5746,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.6.2: - resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -12420,7 +12423,7 @@ snapshots: consola: 3.4.2 defu: 6.1.4 destr: 2.0.5 - devalue: 5.6.2 + devalue: 5.6.3 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -12806,7 +12809,7 @@ snapshots: '@rollup/plugin-yaml': 4.1.2(rollup@4.59.0) '@vue/compiler-sfc': 3.5.29 defu: 6.1.4 - devalue: 5.6.2 + devalue: 5.6.3 h3: 1.15.5 knitwork: 1.3.0 magic-string: 0.30.21 @@ -15863,7 +15866,7 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.6.2: {} + devalue@5.6.3: {} devlop@1.1.0: dependencies: @@ -18611,7 +18614,7 @@ snapshots: cookie-es: 2.0.0 defu: 6.1.4 destr: 2.0.5 - devalue: 5.6.2 + devalue: 5.6.3 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 diff --git a/server/plugins/payload-cache.ts b/server/plugins/payload-cache.ts new file mode 100644 index 000000000..f69051548 --- /dev/null +++ b/server/plugins/payload-cache.ts @@ -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 + * 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 + 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(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 + + 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, + } + } 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 + + 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)) { + // 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 + } + } + }) + + /** + * Write a payload to the cache in the background (non-blocking). + */ + function cachePayload( + event: H3Event, + routePath: string, + payload: { body: string; statusCode: number; headers: Record }, + ) { + 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 + } + } +} From 98aec51dc6b78715d2397df246f48a27d1b5de9c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 25 Feb 2026 10:47:22 +0000 Subject: [PATCH 02/12] chore: show skeleton when status is idle + only throw when resolved version fetch has succeeded --- app/pages/package/[[org]]/[name].vue | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 8744e2e52..77a016dc7 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -210,15 +210,24 @@ const { data: skillsData } = useLazyFetch( const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) const { data: moduleReplacement } = useModuleReplacement(packageName) -const { data: resolvedVersion } = await useResolvedVersion(packageName, requestedVersion) +const { data: resolvedVersion, status: resolvedStatus } = await useResolvedVersion( + packageName, + requestedVersion, +) -if (resolvedVersion.value === null) { - throw createError({ - statusCode: 404, - statusMessage: $t('package.not_found'), - message: $t('package.not_found_message'), - }) -} +watch( + [resolvedStatus, resolvedVersion], + ([status, version]) => { + if (version === null && status === 'success') { + throw createError({ + statusCode: 404, + statusMessage: $t('package.not_found'), + message: $t('package.not_found_message'), + }) + } + }, + { immediate: true }, +) const { data: pkg, @@ -672,7 +681,7 @@ const showSkeleton = shallowRef(false)
- +
From be6099691b89f56aae71398862add060ef6f73c4 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 25 Feb 2026 10:52:04 +0000 Subject: [PATCH 03/12] fix: update fallback json --- modules/isr-fallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/isr-fallback.ts b/modules/isr-fallback.ts index 097bfe467..3040973dd 100644 --- a/modules/isr-fallback.ts +++ b/modules/isr-fallback.ts @@ -31,7 +31,7 @@ export default defineNuxtModule({ const outputPath = resolve(nitro.options.output.serverDir, '..', path, htmlFallback) mkdirSync(resolve(nitro.options.output.serverDir, '..', path), { recursive: true }) writeFileSync(outputPath, spaTemplate) - writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '{}') + writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '[{}]') } }) }) From 4cc1bc8ab1ae48fc74e079e37d51ae4392e1b564 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 25 Feb 2026 10:52:40 +0000 Subject: [PATCH 04/12] fix: use showError --- app/pages/package/[[org]]/[name].vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 77a016dc7..3fc1dee56 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -219,7 +219,7 @@ watch( [resolvedStatus, resolvedVersion], ([status, version]) => { if (version === null && status === 'success') { - throw createError({ + showError({ statusCode: 404, statusMessage: $t('package.not_found'), message: $t('package.not_found_message'), From 1c332ef2dda9d6ce6ce6b6a4f16a097aa5d1e31a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 25 Feb 2026 11:06:00 +0000 Subject: [PATCH 05/12] fix: handle deferred 404 --- app/composables/npm/useResolvedVersion.ts | 2 +- app/pages/package/[[org]]/[name].vue | 10 +++++++++- modules/isr-fallback.ts | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/composables/npm/useResolvedVersion.ts b/app/composables/npm/useResolvedVersion.ts index fda018525..d129a94b9 100644 --- a/app/composables/npm/useResolvedVersion.ts +++ b/app/composables/npm/useResolvedVersion.ts @@ -15,6 +15,6 @@ export function useResolvedVersion( const data = await $fetch(url) return data.version }, - { default: () => null }, + { default: () => undefined }, ) } diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 3fc1dee56..ba4f784db 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -215,10 +215,18 @@ const { data: resolvedVersion, status: resolvedStatus } = await useResolvedVersi requestedVersion, ) +if (import.meta.server && !resolvedVersion.value && resolvedStatus.value === 'success') { + throw createError({ + statusCode: 404, + statusMessage: $t('package.not_found'), + message: $t('package.not_found_message'), + }) +} + watch( [resolvedStatus, resolvedVersion], ([status, version]) => { - if (version === null && status === 'success') { + if (!version && status === 'success') { showError({ statusCode: 404, statusMessage: $t('package.not_found'), diff --git a/modules/isr-fallback.ts b/modules/isr-fallback.ts index 3040973dd..fd292302a 100644 --- a/modules/isr-fallback.ts +++ b/modules/isr-fallback.ts @@ -31,7 +31,7 @@ export default defineNuxtModule({ const outputPath = resolve(nitro.options.output.serverDir, '..', path, htmlFallback) mkdirSync(resolve(nitro.options.output.serverDir, '..', path), { recursive: true }) writeFileSync(outputPath, spaTemplate) - writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '[{}]') + writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '[]') } }) }) From cb9b498aa211c810a46c7e051365ff34b6ea961d Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 25 Feb 2026 11:10:31 +0000 Subject: [PATCH 06/12] chore: revert back to empty object in an array --- modules/isr-fallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/isr-fallback.ts b/modules/isr-fallback.ts index fd292302a..3040973dd 100644 --- a/modules/isr-fallback.ts +++ b/modules/isr-fallback.ts @@ -31,7 +31,7 @@ export default defineNuxtModule({ const outputPath = resolve(nitro.options.output.serverDir, '..', path, htmlFallback) mkdirSync(resolve(nitro.options.output.serverDir, '..', path), { recursive: true }) writeFileSync(outputPath, spaTemplate) - writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '[]') + writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '[{}]') } }) }) From 50ef48e3b9b82f22d6eb66cabcadff0a786f7d72 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 25 Feb 2026 11:31:28 +0000 Subject: [PATCH 07/12] fix: detect hydration without matching payload --- app/pages/package/[[org]]/[name].vue | 1397 +++++++++++++------------- 1 file changed, 712 insertions(+), 685 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index ba4f784db..95201e7d5 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -242,6 +242,20 @@ const { status, error, } = usePackage(packageName, () => resolvedVersion.value ?? requestedVersion.value) + +const nuxtApp = useNuxtApp() +const isHydratingWithoutPayload = shallowRef( + import.meta.client && + nuxtApp.isHydrating && + nuxtApp.payload.serverRendered && + !Object.keys(nuxtApp.payload.data ?? {}).length, +) +if (isHydratingWithoutPayload.value) { + nuxtApp.hooks.hookOnce('app:suspense:resolve', () => { + isHydratingWithoutPayload.value = false + }) +} + const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) const versionSecurityMetadata = computed(() => { if (!pkg.value) return [] @@ -689,746 +703,759 @@ const showSkeleton = shallowRef(false)
- - -
- -
- -
- -

+ +
+