Skip to content

Commit

Permalink
feat(nuxt): render all head tags on server with unhead (#22179)
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw committed Jul 30, 2023
1 parent a2b5d31 commit 9b09b4d
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 123 deletions.
12 changes: 2 additions & 10 deletions packages/nuxt/src/app/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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']>> }
Expand Down Expand Up @@ -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>
Expand Down
8 changes: 0 additions & 8 deletions packages/nuxt/src/core/nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 () => {}'
Expand Down
208 changes: 125 additions & 83 deletions packages/nuxt/src/core/runtime/nitro/renderer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -140,7 +146,6 @@ const getSPARenderer = lazyCachedFunction(async () => {
public: config.public,
app: config.app
}
ssrContext!.renderMeta = ssrContext!.renderMeta ?? getStaticRenderedHead
return Promise.resolve(result)
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -303,67 +309,103 @@ 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
await nitroApp.hooks.callHook('render:html', htmlContext, { event })

// 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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down

0 comments on commit 9b09b4d

Please sign in to comment.