feat(nuxt): payload rendering support (#6455)

Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
pooya parsa 2022-09-10 15:57:16 +02:00 committed by GitHub
parent 674b53b3f9
commit 888bd7c145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 275 additions and 10 deletions

View File

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

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

View File

@ -74,6 +74,7 @@ interface _NuxtApp {
ssrContext?: NuxtSSRContext
payload: {
serverRendered?: boolean
prerenderedAt?: number
data: Record<string, any>
state: Record<string, any>
rendered?: Function

View 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)
})
})

View File

@ -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])

View File

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

View File

@ -55,7 +55,10 @@ const appPreset = defineUnimportPreset({
'updateAppConfig',
'defineAppConfig',
'preloadComponents',
'prefetchComponents'
'prefetchComponents',
'loadPayload',
'preloadPayload',
'isPrerendered'
]
})

View File

@ -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')

View File

@ -0,0 +1,3 @@
export function useRandomState (max: number = 100, name = 'default') {
return useState('random:' + name, () => Math.round(Math.random() * max))
}

View File

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

View File

@ -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');
}

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

View File

@ -0,0 +1,4 @@
export default defineNuxtPlugin((nuxtApp) => {
// Pretend to be prerendered
nuxtApp.payload.prerenderedAt = Date.now()
})

View File

@ -0,0 +1,3 @@
export default eventHandler(() => {
return new Array(10).fill(0).map(() => Math.round(Math.random() * 10000))
})