mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt): render all head tags on server with unhead
(#22179)
This commit is contained in:
parent
a2b5d31270
commit
9b09b4d112
@ -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>
|
||||
|
@ -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 () => {}'
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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"')
|
||||
|
Loading…
Reference in New Issue
Block a user