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

feat(nuxt): payload rendering support #6455

Merged
merged 56 commits into from Sep 10, 2022
Merged
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
9bf9e42
feat(nuxt): add payload js endpoint
pi0 Aug 9, 2022
9202c55
feat: use esm module
pi0 Aug 9, 2022
1acb659
feat: `loadPayload` (wip)
pi0 Aug 9, 2022
0217f3d
Merge branch 'main' into feat/payload-jsonp
pi0 Aug 9, 2022
bd84ef3
add usePayload and prefetchPayload (woking)
pi0 Aug 9, 2022
ba576e4
refactor: use internal #app
pi0 Aug 9, 2022
e7dd892
always use link to prefetch
pi0 Aug 9, 2022
ccc982f
use x-nitro-prerender to prerender payload
pi0 Aug 9, 2022
112694c
feat: isPrerendering composable
pi0 Aug 9, 2022
39cbe73
universal isPrerender and prerenderedAt to payload
pi0 Aug 9, 2022
bf16a1a
add random fixture
pi0 Aug 9, 2022
5cdad5c
Merge branch 'main' into feat/payload-jsonp
pi0 Aug 9, 2022
c3f2343
automarically use external payload in prerender mode and set hint
pi0 Aug 9, 2022
fedca28
cache payload from original response to dedup
pi0 Aug 9, 2022
820078b
rename usePayload to loadPayload
pi0 Aug 9, 2022
5242daf
preload payload of current page
pi0 Aug 9, 2022
52f1002
fix ts error
pi0 Aug 9, 2022
270583b
update fixture to load another api in each random page
pi0 Aug 9, 2022
4488042
restore payload data on navigation
pi0 Aug 9, 2022
2d74ccd
Merge branch 'main' into feat/payload-jsonp
pi0 Sep 4, 2022
37f3172
fix merge issues
pi0 Sep 4, 2022
266ad92
improve renderer logic
pi0 Sep 4, 2022
537083a
fix: only use payload cache during prerendering
pi0 Sep 4, 2022
1b171a4
fix: fix payload url regex
pi0 Sep 4, 2022
fd37bef
refactor: make logic more compact and only use static flag for now
pi0 Sep 4, 2022
0023917
Merge branch 'main' into feat/payload-jsonp
pi0 Sep 5, 2022
4fcc6c2
Merge branch 'main' into feat/payload-jsonp
pi0 Sep 5, 2022
34c03ff
use process.prerender
pi0 Sep 5, 2022
a215c11
refactor: rename to isPrerendered
pi0 Sep 5, 2022
b2d97a5
fix: use process.env.prerender
pi0 Sep 5, 2022
d0c3b25
remove console.log
pi0 Sep 5, 2022
a4bcf5f
perf: split initial payload and inline
pi0 Sep 5, 2022
a366431
add random state test
pi0 Sep 5, 2022
206c3b5
add missing spread
pi0 Sep 5, 2022
2181d62
improve plugin
pi0 Sep 5, 2022
f54e44f
invalidate cache if import fails
pi0 Sep 5, 2022
9a4c5d0
assign state too
pi0 Sep 5, 2022
a1df2f7
use process.env.prerender condition
pi0 Sep 5, 2022
4d05286
prefetchPayload ~> preloadPayload
pi0 Sep 5, 2022
c19b189
simplify preloadPayload and use in fixture do demonestrate
pi0 Sep 5, 2022
e8404b1
Merge branch 'main' into feat/payload-jsonp
pi0 Sep 5, 2022
a16d258
lint
pi0 Sep 5, 2022
a84683e
Merge branch 'main' into feat/payload-jsonp
pi0 Sep 5, 2022
136b399
unrelated change
pi0 Sep 7, 2022
db5c808
feat: options for load payload and use in-path hash
pi0 Sep 7, 2022
5629abc
Merge branch 'main' into feat/payload-jsonp
pi0 Sep 7, 2022
31047f1
chore: small tweaks
danielroe Sep 7, 2022
bb00934
fix: don't swallow payloads
danielroe Sep 9, 2022
8480842
fix: match query parameters on payloads
danielroe Sep 9, 2022
6ec95ac
test: add some basic tests for payload rendering
danielroe Sep 9, 2022
eaa0164
Merge remote-tracking branch 'origin/main' into feat/payload-jsonp
danielroe Sep 9, 2022
4aaa9fb
fix: prevent webpack from processing browser import
danielroe Sep 9, 2022
820c17f
style: revert whitespace change
danielroe Sep 9, 2022
ff4aa14
test: fix dev test
danielroe Sep 9, 2022
bd5fdcd
Merge branch 'main' into feat/payload-jsonp
pi0 Sep 10, 2022
374429a
test: remove comment as it should happen
pi0 Sep 10, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nuxt/src/app/composables/index.ts
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'
59 changes: 59 additions & 0 deletions packages/nuxt/src/app/composables/payload.ts
@@ -0,0 +1,59 @@
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 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(/* @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
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
@@ -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
@@ -1,7 +1,7 @@
import { 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 @@ -164,6 +164,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
@@ -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
Expand Up @@ -55,7 +55,10 @@ const appPreset = defineUnimportPreset({
'updateAppConfig',
'defineAppConfig',
'preloadComponents',
'prefetchComponents'
'prefetchComponents',
'loadPayload',
'preloadPayload',
'isPrerendered'
]
})

Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/basic/composables/random.ts
@@ -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
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
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
49 changes: 49 additions & 0 deletions test/fixtures/basic/pages/random/[id].vue
@@ -0,0 +1,49 @@
<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 })
danielroe marked this conversation as resolved.
Show resolved Hide resolved

const random = useRandomState(100, pageKey)
const globalRandom = useRandomState(100)

// TODO: NuxtLink should do this automatically on observed
if (process.client) {
preloadPayload('/random/a')
preloadPayload('/random/b')
preloadPayload('/random/c')
}
</script>
3 changes: 3 additions & 0 deletions 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))
})