Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

Commit

Permalink
feat(nuxt): payload rendering support (#6455)
Browse files Browse the repository at this point in the history
Co-authored-by: Daniel Roe <daniel@roe.dev>
  • Loading branch information
pi0 and danielroe committed Sep 10, 2022
1 parent 674b53b commit 888bd7c
Show file tree
Hide file tree
Showing 14 changed files with 275 additions and 10 deletions.
1 change: 1 addition & 0 deletions packages/nuxt/src/app/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 60 additions & 0 deletions packages/nuxt/src/app/composables/payload.ts
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions packages/nuxt/src/app/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ interface _NuxtApp {
ssrContext?: NuxtSSRContext
payload: {
serverRendered?: boolean
prerenderedAt?: number
data: Record<string, any>
state: Record<string, any>
rendered?: Function
Expand Down
19 changes: 19 additions & 0 deletions packages/nuxt/src/app/plugins/payload.client.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
5 changes: 4 additions & 1 deletion packages/nuxt/src/core/nuxt.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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])
Expand Down
76 changes: 70 additions & 6 deletions packages/nuxt/src/core/runtime/nitro/renderer.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -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?.() ?? {}

Expand All @@ -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,
Expand All @@ -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
])
Expand Down Expand Up @@ -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 }
}
}
5 changes: 4 additions & 1 deletion packages/nuxt/src/imports/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ const appPreset = defineUnimportPreset({
'updateAppConfig',
'defineAppConfig',
'preloadComponents',
'prefetchComponents'
'prefetchComponents',
'loadPayload',
'preloadPayload',
'isPrerendered'
]
})

Expand Down
48 changes: 47 additions & 1 deletion test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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')
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/basic/composables/random.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function useRandomState (max: number = 100, name = 'default') {
return useState('random:' + name, () => Math.round(Math.random() * max))
}
9 changes: 8 additions & 1 deletion test/fixtures/basic/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/basic/pages/assets.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
47 changes: 47 additions & 0 deletions test/fixtures/basic/pages/random/[id].vue
Original file line number Diff line number Diff line change
@@ -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>
Loading

1 comment on commit 888bd7c

@bot08
Copy link
Contributor

@bot08 bot08 commented on 888bd7c Sep 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job 👍🏿

Please sign in to comment.