diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index 2fd1c252c8a..d3af7370733 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -13,3 +13,4 @@ export { useRequestHeaders, useRequestEvent, setResponseStatus } from './ssr' export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, setPageLayout, navigateTo, useRoute, useActiveRoute, useRouter } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' export { preloadComponents, prefetchComponents } from './preload' +export { isPrerendered, loadPayload, preloadPayload } from './payload' diff --git a/packages/nuxt/src/app/composables/payload.ts b/packages/nuxt/src/app/composables/payload.ts new file mode 100644 index 00000000000..03c37bb9713 --- /dev/null +++ b/packages/nuxt/src/app/composables/payload.ts @@ -0,0 +1,60 @@ +import { parseURL, joinURL } from 'ufo' +import { useNuxtApp } from '../nuxt' +import { useHead } from '#app' + +interface LoadPayloadOptions { + fresh?: boolean + hash?: string +} + +export function loadPayload (url: string, opts: LoadPayloadOptions = {}) { + if (process.server) { return null } + const payloadURL = _getPayloadURL(url, opts) + const nuxtApp = useNuxtApp() + const cache = nuxtApp._payloadCache = nuxtApp._payloadCache || {} + if (cache[payloadURL]) { + return cache[payloadURL] + } + cache[url] = _importPayload(payloadURL).then((payload) => { + if (!payload) { + delete cache[url] + return null + } + return payload + }) + return cache[url] +} + +export function preloadPayload (url: string, opts: LoadPayloadOptions = {}) { + const payloadURL = _getPayloadURL(url, opts) + useHead({ + link: [ + { rel: 'modulepreload', href: payloadURL } + ] + }) +} + +// --- Internal --- + +function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) { + const parsed = parseURL(url) + if (parsed.search) { + throw new Error('Payload URL cannot contain search params: ' + url) + } + const hash = opts.hash || (opts.fresh ? Date.now() : '') + return joinURL(parsed.pathname, hash ? `_payload.${hash}.js` : '_payload.js') +} + +async function _importPayload (payloadURL: string) { + if (process.server) { return null } + const res = await import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).catch((err) => { + console.warn('[nuxt] Cannot load payload ', payloadURL, err) + }) + return res?.default || null +} + +export function isPrerendered () { + // Note: Alternative for server is checking x-nitro-prerender header + const nuxtApp = useNuxtApp() + return !!nuxtApp.payload.prerenderedAt +} diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 98744573791..d80a5f1d248 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -74,6 +74,7 @@ interface _NuxtApp { ssrContext?: NuxtSSRContext payload: { serverRendered?: boolean + prerenderedAt?: number data: Record state: Record rendered?: Function diff --git a/packages/nuxt/src/app/plugins/payload.client.ts b/packages/nuxt/src/app/plugins/payload.client.ts new file mode 100644 index 00000000000..fce8a62c618 --- /dev/null +++ b/packages/nuxt/src/app/plugins/payload.client.ts @@ -0,0 +1,19 @@ +import { defineNuxtPlugin, loadPayload, addRouteMiddleware, isPrerendered } from '#app' + +export default defineNuxtPlugin((nuxtApp) => { + // Only enable behavior if initial page is prerendered + // TOOD: Support hybrid + if (!isPrerendered()) { + return + } + addRouteMiddleware(async (to, from) => { + if (to.path === from.path) { return } + const url = to.path + const payload = await loadPayload(url) + if (!payload) { + return + } + Object.assign(nuxtApp.payload.data, payload.data) + Object.assign(nuxtApp.payload.state, payload.state) + }) +}) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index d65d972bed6..99e840de305 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -1,7 +1,7 @@ import { join, normalize, resolve } from 'pathe' import { createHooks } from 'hookable' import type { Nuxt, NuxtOptions, NuxtConfig, ModuleContainer, NuxtHooks } from '@nuxt/schema' -import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule } from '@nuxt/kit' +import { loadNuxtConfig, LoadNuxtOptions, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule, addPlugin } from '@nuxt/kit' // Temporary until finding better placement /* eslint-disable import/no-restricted-paths */ import escapeRE from 'escape-string-regexp' @@ -166,6 +166,9 @@ async function initNuxt (nuxt: Nuxt) { } }) + // Add prerender payload support + addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client')) + for (const m of modulesToInstall) { if (Array.isArray(m)) { await installModule(m[0], m[1]) diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 65e40cc6a6a..66883ecba11 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -1,8 +1,9 @@ import { createRenderer } from 'vue-bundle-renderer/runtime' import type { RenderResponse } from 'nitropack' import type { Manifest } from 'vite' -import { getQuery } from 'h3' +import { appendHeader, getQuery } from 'h3' import devalue from '@nuxt/devalue' +import { joinURL } from 'ufo' import { renderToString as _renderToString } from 'vue/server-renderer' import { useRuntimeConfig, useNitroApp, defineRenderHandler } from '#internal/nitro' // eslint-disable-next-line import/no-restricted-paths @@ -102,10 +103,25 @@ const getSPARenderer = lazyCachedFunction(async () => { return { renderToString } }) +const PAYLOAD_CACHE = process.env.prerender ? new Map() : null // TODO: Use LRU cache +const PAYLOAD_URL_RE = /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/ + export default defineRenderHandler(async (event) => { // Whether we're rendering an error page - const ssrError = event.req.url?.startsWith('/__nuxt_error') ? getQuery(event) as Exclude : null - const url = ssrError?.url as string || event.req.url! + const ssrError = event.req.url?.startsWith('/__nuxt_error') + ? getQuery(event) as Exclude + : null + let url = ssrError?.url as string || event.req.url! + + // Whether we are rendering payload route + const isRenderingPayload = PAYLOAD_URL_RE.test(url) + if (isRenderingPayload) { + url = url.substring(0, url.lastIndexOf('/')) || '/' + event.req.url = url + if (process.env.prerender && PAYLOAD_CACHE!.has(url)) { + return PAYLOAD_CACHE!.get(url) + } + } // Initialize ssr context const ssrContext: NuxtSSRContext = { @@ -117,7 +133,13 @@ export default defineRenderHandler(async (event) => { noSSR: !!event.req.headers['x-nuxt-no-ssr'], error: !!ssrError, nuxt: undefined!, /* NuxtApp */ - payload: ssrError ? { error: ssrError } as NuxtSSRContext['payload'] : undefined! + payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload'] + } + + // Whether we are prerendering route + const payloadURL = process.env.prerender ? joinURL(url, '_payload.js') : undefined + if (process.env.prerender) { + ssrContext.payload.prerenderedAt = Date.now() } // Render app @@ -138,6 +160,22 @@ export default defineRenderHandler(async (event) => { throw ssrContext.payload.error } + // Directly render payload routes + if (isRenderingPayload) { + const response = renderPayloadResponse(ssrContext) + if (process.env.prerender) { + PAYLOAD_CACHE!.set(url, response) + } + return response + } + + if (process.env.prerender) { + // Hint nitro to prerender payload for this route + appendHeader(event, 'x-nitro-prerender', payloadURL!) + // Use same ssr context to generate payload for this route + PAYLOAD_CACHE!.set(url, renderPayloadResponse(ssrContext)) + } + // Render meta const renderedMeta = await ssrContext.renderMeta?.() ?? {} @@ -151,6 +189,7 @@ export default defineRenderHandler(async (event) => { htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]), head: normalizeChunks([ renderedMeta.headTags, + !process.env.NUXT_NO_SCRIPTS && process.env.prerender ? `` : null, _rendered.renderResourceHints(), _rendered.renderStyles(), inlinedStyles, @@ -166,8 +205,13 @@ export default defineRenderHandler(async (event) => { _rendered.html ], bodyAppend: normalizeChunks([ - process.env.NUXT_NO_SCRIPTS ? '' : ``, - process.env.NUXT_NO_SCRIPTS ? '' : _rendered.renderScripts(), + process.env.NUXT_NO_SCRIPTS + ? undefined + : (process.env.prerender + ? `` + : `` + ), + _rendered.renderScripts(), // Note: bodyScripts may contain tags other than diff --git a/test/fixtures/basic/plugins/prerender.server.ts b/test/fixtures/basic/plugins/prerender.server.ts new file mode 100644 index 00000000000..9eec0f831c4 --- /dev/null +++ b/test/fixtures/basic/plugins/prerender.server.ts @@ -0,0 +1,4 @@ +export default defineNuxtPlugin((nuxtApp) => { + // Pretend to be prerendered + nuxtApp.payload.prerenderedAt = Date.now() +}) diff --git a/test/fixtures/basic/server/api/random.ts b/test/fixtures/basic/server/api/random.ts new file mode 100644 index 00000000000..6ab0aed5199 --- /dev/null +++ b/test/fixtures/basic/server/api/random.ts @@ -0,0 +1,3 @@ +export default eventHandler(() => { + return new Array(10).fill(0).map(() => Math.round(Math.random() * 10000)) +})