mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15:19 +00:00
feat(nuxt): payload rendering support (#6455)
Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
parent
674b53b3f9
commit
888bd7c145
@ -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'
|
||||
|
60
packages/nuxt/src/app/composables/payload.ts
Normal file
60
packages/nuxt/src/app/composables/payload.ts
Normal file
@ -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
|
||||
}
|
@ -74,6 +74,7 @@ interface _NuxtApp {
|
||||
ssrContext?: NuxtSSRContext
|
||||
payload: {
|
||||
serverRendered?: boolean
|
||||
prerenderedAt?: number
|
||||
data: Record<string, any>
|
||||
state: Record<string, any>
|
||||
rendered?: Function
|
||||
|
19
packages/nuxt/src/app/plugins/payload.client.ts
Normal file
19
packages/nuxt/src/app/plugins/payload.client.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
@ -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])
|
||||
|
@ -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<NuxtApp['payload']['error'], Error> : null
|
||||
const url = ssrError?.url as string || event.req.url!
|
||||
const ssrError = event.req.url?.startsWith('/__nuxt_error')
|
||||
? getQuery(event) as Exclude<NuxtApp['payload']['error'], Error>
|
||||
: 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 ? `<link rel="modulepreload" href="${payloadURL}">` : null,
|
||||
_rendered.renderResourceHints(),
|
||||
_rendered.renderStyles(),
|
||||
inlinedStyles,
|
||||
@ -166,8 +205,13 @@ export default defineRenderHandler(async (event) => {
|
||||
_rendered.html
|
||||
],
|
||||
bodyAppend: normalizeChunks([
|
||||
process.env.NUXT_NO_SCRIPTS ? '' : `<script>window.__NUXT__=${devalue(ssrContext.payload)}</script>`,
|
||||
process.env.NUXT_NO_SCRIPTS ? '' : _rendered.renderScripts(),
|
||||
process.env.NUXT_NO_SCRIPTS
|
||||
? undefined
|
||||
: (process.env.prerender
|
||||
? `<script type="module">import p from "${payloadURL}";window.__NUXT__={...p,...(${devalue(splitPayload(ssrContext).initial)})}</script>`
|
||||
: `<script>window.__NUXT__=${devalue(ssrContext.payload)}</script>`
|
||||
),
|
||||
_rendered.renderScripts(),
|
||||
// Note: bodyScripts may contain tags other than <script>
|
||||
renderedMeta.bodyScripts
|
||||
])
|
||||
@ -233,3 +277,23 @@ async function renderInlineStyles (usedModules: Set<string> | string[]) {
|
||||
}
|
||||
return Array.from(inlinedStyles).join('')
|
||||
}
|
||||
|
||||
function renderPayloadResponse (ssrContext: NuxtSSRContext) {
|
||||
return <RenderResponse> {
|
||||
body: `export default ${devalue(splitPayload(ssrContext).payload)}`,
|
||||
statusCode: ssrContext.event.res.statusCode,
|
||||
statusMessage: ssrContext.event.res.statusMessage,
|
||||
headers: {
|
||||
'content-type': 'text/javascript;charset=UTF-8',
|
||||
'x-powered-by': 'Nuxt'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitPayload (ssrContext: NuxtSSRContext) {
|
||||
const { data, state, prerenderedAt, ...initial } = ssrContext.payload
|
||||
return {
|
||||
initial: { ...initial, prerenderedAt },
|
||||
payload: { data, state, prerenderedAt }
|
||||
}
|
||||
}
|
||||
|
@ -55,7 +55,10 @@ const appPreset = defineUnimportPreset({
|
||||
'updateAppConfig',
|
||||
'defineAppConfig',
|
||||
'preloadComponents',
|
||||
'prefetchComponents'
|
||||
'prefetchComponents',
|
||||
'loadPayload',
|
||||
'preloadPayload',
|
||||
'isPrerendered'
|
||||
]
|
||||
})
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { joinURL } from 'ufo'
|
||||
// import { isWindows } from 'std-env'
|
||||
import { setup, fetch, $fetch, startServer, createPage } from '@nuxt/test-utils'
|
||||
import { setup, fetch, $fetch, startServer, createPage, url } from '@nuxt/test-utils'
|
||||
// eslint-disable-next-line import/order
|
||||
import { expectNoClientErrors, renderPage } from './utils'
|
||||
|
||||
@ -586,6 +586,52 @@ describe('app config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('payload rendering', () => {
|
||||
it('renders a payload', async () => {
|
||||
const payload = await $fetch('/random/a/_payload.js', { responseType: 'text' })
|
||||
expect(payload).toMatch(
|
||||
/export default \{data:\{\$frand_a:\[[^\]]*\]\},state:\{"\$srandom:rand_a":\d*,"\$srandom:default":\d*\},prerenderedAt:\d*\}/
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fetch a prefetched payload', async () => {
|
||||
const page = await createPage()
|
||||
const requests = [] as string[]
|
||||
page.on('request', (req) => {
|
||||
requests.push(req.url().replace(url('/'), '/'))
|
||||
})
|
||||
await page.goto(url('/random/a'))
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const importSuffix = process.env.NUXT_TEST_DEV && !process.env.TEST_WITH_WEBPACK ? '?import' : ''
|
||||
|
||||
// We are manually prefetching other payloads
|
||||
expect(requests).toContain('/random/c/_payload.js')
|
||||
|
||||
// We are not triggering API requests in the payload
|
||||
expect(requests).not.toContain(expect.stringContaining('/api/random'))
|
||||
requests.length = 0
|
||||
|
||||
await page.click('[href="/random/b"]')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// We are not triggering API requests in the payload in client-side nav
|
||||
expect(requests).not.toContain('/api/random')
|
||||
// We are fetching a payload we did not prefetch
|
||||
expect(requests).toContain('/random/b/_payload.js' + importSuffix)
|
||||
// We are not refetching payloads we've already prefetched
|
||||
expect(requests.filter(p => p.includes('_payload')).length).toBe(1)
|
||||
requests.length = 0
|
||||
|
||||
await page.click('[href="/random/c"]')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// We are not triggering API requests in the payload in client-side nav
|
||||
expect(requests).not.toContain('/api/random')
|
||||
// We are not refetching payloads we've already prefetched
|
||||
// Note: we refetch on dev as urls differ between '' and '?import'
|
||||
expect(requests.filter(p => p.includes('_payload')).length).toBe(process.env.NUXT_TEST_DEV ? 1 : 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAsyncData', () => {
|
||||
it('single request resolves', async () => {
|
||||
await expectNoClientErrors('/useAsyncData/single')
|
||||
|
3
test/fixtures/basic/composables/random.ts
vendored
Normal file
3
test/fixtures/basic/composables/random.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export function useRandomState (max: number = 100, name = 'default') {
|
||||
return useState('random:' + name, () => Math.round(Math.random() * max))
|
||||
}
|
9
test/fixtures/basic/nuxt.config.ts
vendored
9
test/fixtures/basic/nuxt.config.ts
vendored
@ -17,7 +17,14 @@ export default defineNuxtConfig({
|
||||
'./extends/node_modules/foo'
|
||||
],
|
||||
nitro: {
|
||||
output: { dir: process.env.NITRO_OUTPUT_DIR }
|
||||
output: { dir: process.env.NITRO_OUTPUT_DIR },
|
||||
prerender: {
|
||||
routes: [
|
||||
'/random/a',
|
||||
'/random/b',
|
||||
'/random/c'
|
||||
]
|
||||
}
|
||||
},
|
||||
publicRuntimeConfig: {
|
||||
testConfig: 123
|
||||
|
4
test/fixtures/basic/pages/assets.vue
vendored
4
test/fixtures/basic/pages/assets.vue
vendored
@ -13,12 +13,16 @@ import logo from '~/assets/logo.svg'
|
||||
<style>
|
||||
#__nuxt {
|
||||
background-image: url('~/assets/logo.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom right;
|
||||
@font-face {
|
||||
src: url("/public.svg") format("woff2");
|
||||
}
|
||||
}
|
||||
body {
|
||||
background-image: url('/public.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: top;
|
||||
@font-face {
|
||||
src: url('/public.svg') format('woff2');
|
||||
}
|
||||
|
47
test/fixtures/basic/pages/random/[id].vue
vendored
Normal file
47
test/fixtures/basic/pages/random/[id].vue
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLink to="/random/a">
|
||||
Random (A)
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/random/b">
|
||||
Random (B)
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/random/c">
|
||||
Random (C)
|
||||
</NuxtLink>
|
||||
<br>
|
||||
|
||||
Random: {{ random }}
|
||||
|
||||
Random: (global) {{ globalRandom }}
|
||||
|
||||
Random page: <b>{{ route.params.id }}</b><br>
|
||||
|
||||
Here are some random numbers for you:
|
||||
|
||||
<ul>
|
||||
<li v-for="n in randomNumbers" :key="n">
|
||||
{{ n }}
|
||||
</li>
|
||||
</ul>
|
||||
<button @click="() => refresh()">
|
||||
Give me another set
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
|
||||
const pageKey = 'rand_' + route.params.id
|
||||
|
||||
const { data: randomNumbers, refresh } = await useFetch('/api/random', { key: pageKey as string })
|
||||
|
||||
const random = useRandomState(100, pageKey)
|
||||
const globalRandom = useRandomState(100)
|
||||
|
||||
// TODO: NuxtLink should do this automatically on observed
|
||||
if (process.client) {
|
||||
preloadPayload('/random/c')
|
||||
}
|
||||
</script>
|
4
test/fixtures/basic/plugins/prerender.server.ts
vendored
Normal file
4
test/fixtures/basic/plugins/prerender.server.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// Pretend to be prerendered
|
||||
nuxtApp.payload.prerenderedAt = Date.now()
|
||||
})
|
3
test/fixtures/basic/server/api/random.ts
vendored
Normal file
3
test/fixtures/basic/server/api/random.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export default eventHandler(() => {
|
||||
return new Array(10).fill(0).map(() => Math.round(Math.random() * 10000))
|
||||
})
|
Loading…
Reference in New Issue
Block a user