feat(nuxt): render all head tags on server with unhead (#22179)

This commit is contained in:
Harlan Wilton 2023-07-30 21:46:16 +03:00 committed by GitHub
parent a2b5d31270
commit 9b09b4d112
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 132 additions and 123 deletions

View File

@ -10,6 +10,7 @@ import type { H3Event } from 'h3'
import type { AppConfig, AppConfigInput, RuntimeConfig } from 'nuxt/schema'
import type { RenderResponse } from 'nitropack'
import type { MergeHead, VueHeadClient } from '@unhead/vue'
// eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandContext } from '../core/runtime/nitro/renderer'
import type { RouteMiddleware } from '../../app'
@ -18,15 +19,6 @@ import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app')
type NuxtMeta = {
htmlAttrs?: string
headAttrs?: string
bodyAttrs?: string
headTags?: string
bodyScriptsPrepend?: string
bodyScripts?: string
}
type HookResult = Promise<void> | void
type AppRenderedContext = { ssrContext: NuxtApp['ssrContext'], renderResult: null | Awaited<ReturnType<ReturnType<typeof createRenderer>['renderToString']>> }
@ -59,10 +51,10 @@ export interface NuxtSSRContext extends SSRContext {
error?: boolean
nuxt: _NuxtApp
payload: NuxtPayload
head: VueHeadClient<MergeHead>
/** This is used solely to render runtime config with SPA renderer. */
config?: Pick<RuntimeConfig, 'public' | 'app'>
teleports?: Record<string, string>
renderMeta?: () => Promise<NuxtMeta> | NuxtMeta
islandContext?: NuxtIslandContext
/** @internal */
_renderResponse?: Partial<RenderResponse>

View File

@ -8,8 +8,6 @@ import escapeRE from 'escape-string-regexp'
import { defu } from 'defu'
import fsExtra from 'fs-extra'
import { dynamicEventHandler } from 'h3'
import { createHeadCore } from '@unhead/vue'
import { renderSSRHead } from '@unhead/ssr'
import type { Nuxt } from 'nuxt/schema'
// @ts-expect-error TODO: add legacy type support for subpath imports
import { template as defaultSpaLoadingTemplate } from '@nuxt/ui-templates/templates/spa-loading-icon.mjs'
@ -205,12 +203,6 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
// Resolve user-provided paths
nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!)
// Add head chunk for SPA renders
const head = createHeadCore()
head.push(nuxt.options.app.head)
const headChunk = await renderSSRHead(head)
nitroConfig.virtual!['#head-static'] = `export default ${JSON.stringify(headChunk)}`
// Add fallback server for `ssr: false`
if (!nuxt.options.ssr) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}'

View File

@ -1,4 +1,10 @@
import { createRenderer, renderResourceHeaders } from 'vue-bundle-renderer/runtime'
import {
createRenderer,
getPrefetchLinks,
getPreloadLinks,
getRequestDependencies,
renderResourceHeaders
} from 'vue-bundle-renderer/runtime'
import type { RenderResponse } from 'nitropack'
import type { Manifest } from 'vite'
import type { H3Event } from 'h3'
@ -9,14 +15,17 @@ import destr from 'destr'
import { joinURL, withoutTrailingSlash } from 'ufo'
import { renderToString as _renderToString } from 'vue/server-renderer'
import { hash } from 'ohash'
import { renderSSRHead } from '@unhead/ssr'
import { defineRenderHandler, getRouteRules, useRuntimeConfig } from '#internal/nitro'
import { useNitroApp } from '#internal/nitro/app'
import type { Link, Script } from '@unhead/vue'
import { createServerHead } from '@unhead/vue'
// eslint-disable-next-line import/no-restricted-paths
import type { NuxtPayload, NuxtSSRContext } from '#app/nuxt'
// @ts-expect-error virtual file
import { appRootId, appRootTag } from '#internal/nuxt.config.mjs'
import { appHead, appRootId, appRootTag } from '#internal/nuxt.config.mjs'
// @ts-expect-error virtual file
import { buildAssetsURL, publicAssetsURL } from '#paths'
@ -71,9 +80,6 @@ const getEntryIds: () => Promise<string[]> = () => getClientManifest().then(r =>
r._globalCSS
).map(r => r.src!))
// @ts-expect-error virtual file
const getStaticRenderedHead = (): Promise<NuxtMeta> => import('#head-static').then(r => r.default || r)
// @ts-expect-error file will be produced after app build
const getServerEntry = () => import('#build/dist/server/server.mjs').then(r => r.default || r)
@ -140,7 +146,6 @@ const getSPARenderer = lazyCachedFunction(async () => {
public: config.public,
app: config.app
}
ssrContext!.renderMeta = ssrContext!.renderMeta ?? getStaticRenderedHead
return Promise.resolve(result)
}
@ -221,6 +226,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Get route options (currently to apply `ssr: false`)
const routeOptions = getRouteRules(event)
const head = createServerHead()
head.push(appHead)
// Initialize ssr context
const ssrContext: NuxtSSRContext = {
url,
@ -231,6 +239,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
event.context.nuxt?.noSSR ||
routeOptions.ssr === false ||
(process.env.prerender ? PRERENDER_NO_SSR_ROUTES.has(url) : false),
head,
error: !!ssrError,
nuxt: undefined!, /* NuxtApp */
payload: (ssrError ? { error: ssrError } : {}) as NuxtPayload,
@ -288,9 +297,6 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
PAYLOAD_CACHE!.set(withoutTrailingSlash(url), renderPayloadResponse(ssrContext))
}
// Render meta
const renderedMeta = await ssrContext.renderMeta?.() ?? {}
if (process.env.NUXT_INLINE_STYLES && !islandContext) {
const source = ssrContext.modules ?? ssrContext._registeredComponents
if (source) {
@ -303,45 +309,81 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Render inline styles
const inlinedStyles = (process.env.NUXT_INLINE_STYLES || Boolean(islandContext))
? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? [])
: ''
: []
const NO_SCRIPTS = process.env.NUXT_NO_SCRIPTS || routeOptions.experimentalNoScripts
// Setup head
const { styles, scripts } = getRequestDependencies(ssrContext, renderer.rendererContext)
// 1.Extracted payload preloading
if (_PAYLOAD_EXTRACTION) {
head.push({
link: [
process.env.NUXT_JSON_PAYLOADS
? { rel: 'preload', as: 'fetch', crossorigin: 'anonymous', href: payloadURL }
: { rel: 'modulepreload', href: payloadURL }
]
})
}
if (!NO_SCRIPTS) {
// 2. Resource Hints
// @todo add priorities based on Capo
head.push({
link: getPreloadLinks(ssrContext, renderer.rendererContext) as Link[]
})
head.push({
link: getPrefetchLinks(ssrContext, renderer.rendererContext) as Link[]
})
// 3. Payloads
head.push({
script: _PAYLOAD_EXTRACTION
? process.env.NUXT_JSON_PAYLOADS
? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL })
: renderPayloadScript({ ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL })
: process.env.NUXT_JSON_PAYLOADS
? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload })
: renderPayloadScript({ ssrContext, data: ssrContext.payload })
}, {
// this should come before another end of body scripts
tagPosition: 'bodyClose',
tagPriority: 'high'
})
}
// 4. Styles
head.push({
link: Object.values(styles)
.map(resource =>
({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file) })
),
style: inlinedStyles
})
// 4. Scripts
if (!routeOptions.experimentalNoScripts) {
head.push({
script: Object.values(scripts).map(resource => (<Script> {
type: resource.module ? 'module' : null,
src: renderer.rendererContext.buildAssetsURL(resource.file),
defer: resource.module ? null : true,
crossorigin: ''
}))
})
}
// remove certain tags for nuxt islands
const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = await renderSSRHead(head)
// Create render context
const htmlContext: NuxtRenderHTMLContext = {
island: Boolean(islandContext),
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]),
head: normalizeChunks([
renderedMeta.headTags,
process.env.NUXT_JSON_PAYLOADS
? _PAYLOAD_EXTRACTION ? `<link rel="preload" as="fetch" crossorigin="anonymous" href="${payloadURL}">` : null
: _PAYLOAD_EXTRACTION ? `<link rel="modulepreload" href="${payloadURL}">` : null,
NO_SCRIPTS ? null : _rendered.renderResourceHints(),
_rendered.renderStyles(),
inlinedStyles,
ssrContext.styles
]),
bodyAttrs: normalizeChunks([renderedMeta.bodyAttrs!]),
bodyPrepend: normalizeChunks([
renderedMeta.bodyScriptsPrepend,
ssrContext.teleports?.body
]),
htmlAttrs: [htmlAttrs],
head: normalizeChunks([headTags, ssrContext.styles]),
bodyAttrs: [bodyAttrs],
bodyPrepend: normalizeChunks([bodyTagsOpen, ssrContext.teleports?.body]),
body: [process.env.NUXT_COMPONENT_ISLANDS ? replaceServerOnlyComponentsSlots(ssrContext, _rendered.html) : _rendered.html],
bodyAppend: normalizeChunks([
NO_SCRIPTS
? undefined
: (_PAYLOAD_EXTRACTION
? process.env.NUXT_JSON_PAYLOADS
? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL })
: renderPayloadScript({ ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL })
: process.env.NUXT_JSON_PAYLOADS
? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload })
: renderPayloadScript({ ssrContext, data: ssrContext.payload })
),
routeOptions.experimentalNoScripts ? undefined : _rendered.renderScripts(),
// Note: bodyScripts may contain tags other than <script>
renderedMeta.bodyScripts
])
bodyAppend: [bodyTags]
}
// Allow hooking into the rendered result
@ -349,21 +391,21 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Response for component islands
if (process.env.NUXT_COMPONENT_ISLANDS && islandContext) {
const _tags = htmlContext.head.flatMap(head => extractHTMLTags(head))
const head: NuxtIslandResponse['head'] = {
link: _tags.filter(tag => tag.tagName === 'link' && tag.attrs.rel === 'stylesheet' && tag.attrs.href.includes('scoped') && !tag.attrs.href.includes('pages/')).map(tag => ({
key: 'island-link-' + hash(tag.attrs.href),
...tag.attrs
})),
style: _tags.filter(tag => tag.tagName === 'style' && tag.innerHTML).map(tag => ({
key: 'island-style-' + hash(tag.innerHTML),
innerHTML: tag.innerHTML
}))
const islandHead: NuxtIslandResponse['head'] = {
link: [],
style: []
}
for (const tag of await head.resolveTags()) {
if (tag.tag === 'link' && tag.props.rel === 'stylesheet' && tag.props.href.includes('scoped') && !tag.props.href.includes('pages/')) {
islandHead.link.push({ ...tag.props, key: 'island-link-' + hash(tag.props.href) })
}
if (tag.tag === 'style' && tag.innerHTML) {
islandHead.style.push({ key: 'island-style-' + hash(tag.innerHTML), innerHTML: tag.innerHTML })
}
}
const islandResponse: NuxtIslandResponse = {
id: islandContext.id,
head,
head: islandHead,
html: getServerComponentHTML(htmlContext.body),
state: ssrContext.payload.state
}
@ -429,33 +471,17 @@ function renderHTMLDocument (html: NuxtRenderHTMLContext) {
</html>`
}
// TODO: Move to external library
const HTML_TAG_RE = /<(?<tag>[a-z]+)(?<rawAttrs> [^>]*)?>(?:(?<innerHTML>[\s\S]*?)<\/\k<tag>)?/g
const HTML_TAG_ATTR_RE = /(?<name>[a-z]+)="(?<value>[^"]*)"/g
function extractHTMLTags (html: string) {
const tags: { tagName: string, attrs: Record<string, string>, innerHTML: string }[] = []
for (const tagMatch of html.matchAll(HTML_TAG_RE)) {
const attrs: Record<string, string> = {}
for (const attrMatch of tagMatch.groups!.rawAttrs?.matchAll(HTML_TAG_ATTR_RE) || []) {
attrs[attrMatch.groups!.name] = attrMatch.groups!.value
}
const innerHTML = tagMatch.groups!.innerHTML || ''
tags.push({ tagName: tagMatch.groups!.tag, attrs, innerHTML })
}
return tags
}
async function renderInlineStyles (usedModules: Set<string> | string[]) {
const styleMap = await getSSRStyles()
const inlinedStyles = new Set<string>()
for (const mod of usedModules) {
if (mod in styleMap) {
for (const style of await styleMap[mod]()) {
inlinedStyles.add(`<style>${style}</style>`)
inlinedStyles.add(style)
}
}
}
return Array.from(inlinedStyles).join('')
return Array.from(inlinedStyles).map(style => ({ innerHTML: style }))
}
function renderPayloadResponse (ssrContext: NuxtSSRContext) {
@ -472,25 +498,41 @@ function renderPayloadResponse (ssrContext: NuxtSSRContext) {
} satisfies RenderResponse
}
function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }) {
const attrs = [
'type="application/json"',
`id="${opts.id}"`,
`data-ssr="${!(process.env.NUXT_NO_SSR || opts.ssrContext.noSSR)}"`,
opts.src ? `data-src="${opts.src}"` : ''
].filter(Boolean)
function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }): Script[] {
const contents = opts.data ? stringify(opts.data, opts.ssrContext._payloadReducers) : ''
return `<script ${attrs.join(' ')}>${contents}</script>` +
`<script>window.__NUXT__={};window.__NUXT__.config=${uneval(opts.ssrContext.config)}</script>`
const payload: Script = {
type: 'application/json',
id: opts.id,
innerHTML: contents,
'data-ssr': !(process.env.NUXT_NO_SSR || opts.ssrContext.noSSR)
}
if (opts.src) {
payload['data-src'] = opts.src
}
return [
payload,
{
innerHTML: `window.__NUXT__={};window.__NUXT__.config=${uneval(opts.ssrContext.config)}`
}
]
}
function renderPayloadScript (opts: { ssrContext: NuxtSSRContext, data?: any, src?: string }) {
function renderPayloadScript (opts: { ssrContext: NuxtSSRContext, data?: any, src?: string }): Script[] {
opts.data.config = opts.ssrContext.config
const _PAYLOAD_EXTRACTION = process.env.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !opts.ssrContext.noSSR
if (_PAYLOAD_EXTRACTION) {
return `<script type="module">import p from "${opts.src}";window.__NUXT__={...p,...(${devalue(opts.data)})}</script>`
return [
{
type: 'module',
innerHTML: `import p from "${opts.src}";window.__NUXT__={...p,...(${devalue(opts.data)})`
}
]
}
return `<script>window.__NUXT__=${devalue(opts.data)}</script>`
return [
{
innerHTML: `window.__NUXT__=${devalue(opts.data)}`
}
]
}
function splitPayload (ssrContext: NuxtSSRContext) {

View File

@ -1,16 +1,11 @@
import { createHead as createClientHead, createServerHead } from '@unhead/vue'
import { renderSSRHead } from '@unhead/ssr'
import { createHead as createClientHead } from '@unhead/vue'
import { defineNuxtPlugin } from '#app/nuxt'
// @ts-expect-error untyped
import { appHead } from '#build/nuxt.config.mjs'
export default defineNuxtPlugin({
name: 'nuxt:head',
setup (nuxtApp) {
const createHead = process.server ? createServerHead : createClientHead
const head = createHead()
head.push(appHead)
const head = process.server ? nuxtApp.ssrContext!.head : createClientHead()
// nuxt.config appHead is set server-side within the renderer
nuxtApp.vueApp.use(head)
if (process.client) {
@ -28,17 +23,5 @@ export default defineNuxtPlugin({
// unpause the DOM once the mount suspense is resolved
nuxtApp.hooks.hook('app:suspense:resolve', unpauseDom)
}
if (process.server) {
nuxtApp.ssrContext!.renderMeta = async () => {
const meta = await renderSSRHead(head)
return {
...meta,
bodyScriptsPrepend: meta.bodyTagsOpen,
// resolves naming difference with NuxtMeta and Unhead
bodyScripts: meta.bodyTags
}
}
}
}
})

View File

@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
for (const outputDir of ['.output', '.output-inline']) {
it('default client bundle size', async () => {
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.5k"')
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.3k"')
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[
"_nuxt/entry.js",
@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.5k"')
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.2k"')
const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2330k"')