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 8744e2e52..158f0cade 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -210,9 +210,16 @@ 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) { +if ( + import.meta.server && + !resolvedVersion.value && + ['success', 'error'].includes(resolvedStatus.value) +) { throw createError({ statusCode: 404, statusMessage: $t('package.not_found'), @@ -220,11 +227,62 @@ if (resolvedVersion.value === null) { }) } +watch( + [resolvedStatus, resolvedVersion], + ([status, version]) => { + if ((!version && status === 'success') || status === 'error') { + showError({ + statusCode: 404, + statusMessage: $t('package.not_found'), + message: $t('package.not_found_message'), + }) + } + }, + { immediate: true }, +) + const { data: pkg, status, error, } = usePackage(packageName, () => resolvedVersion.value ?? requestedVersion.value) + +// Detect two hydration scenarios where the external _payload.json is missing: +// +// 1. SPA fallback (200.html): No real content was server-rendered. +// → Show skeleton while data fetches on the client. +// +// 2. SSR-rendered HTML with missing payload: Content was rendered but the external _payload.json +// returned an ISR fallback. +// → Preserve the server-rendered DOM, don't flash to skeleton. +const nuxtApp = useNuxtApp() +const route = useRoute() +const hasEmptyPayload = + import.meta.client && + nuxtApp.isHydrating && + nuxtApp.payload.serverRendered && + !Object.keys(nuxtApp.payload.data ?? {}).length +const isSpaFallback = shallowRef(hasEmptyPayload && !nuxtApp.payload.path) +const isHydratingWithServerContent = shallowRef( + hasEmptyPayload && nuxtApp.payload.path === route.path, +) +// When we have server-rendered content but no payload data, capture the server +// DOM before Vue's hydration replaces it. This lets us show the server-rendered +// HTML as a static snapshot while data refetches, avoiding any visual flash. +const serverRenderedHtml = shallowRef( + isHydratingWithServerContent.value + ? (document.getElementById('package-article')?.innerHTML ?? null) + : null, +) + +if (isSpaFallback.value || isHydratingWithServerContent.value) { + nuxtApp.hooks.hookOnce('app:suspense:resolve', () => { + isSpaFallback.value = false + isHydratingWithServerContent.value = false + serverRenderedHtml.value = null + }) +} + const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) const versionSecurityMetadata = computed(() => { if (!pkg.value) return [] @@ -672,9 +730,30 @@ const showSkeleton = shallowRef(false)
- - -
+ + + + + +
+ +
{ + refreshNuxtData() + }) + } }, }) 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/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), '[{}]') } }) }) 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 63aaf3bf5..e9c375c7b 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 e0558fc23..d560fe301 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..7cdb12eb2 --- /dev/null +++ b/server/plugins/payload-cache.ts @@ -0,0 +1,187 @@ +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 + +/** + * Grace period beyond TTL where stale payloads are still served (seconds). + * Prevents a race where the HTML is served from Vercel's ISR cache right before + * expiry, but the payload request arrives a moment later after our cache expires. + */ +const PAYLOAD_CACHE_STALE_TTL = PAYLOAD_CACHE_TTL * 2 + +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 — serve stale payloads within the grace period to avoid + // a race where HTML is cached by Vercel but our payload has expired + const age = (Date.now() - cached.cachedAt) / 1000 + if (age > PAYLOAD_CACHE_STALE_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 or unknown 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 + 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 pathWithoutQuery = ctx.event.path.replace(/\?.*$/, '') + const routePath = pathWithoutQuery === '/' ? '/' : pathWithoutQuery.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 + } + } +}