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

View File

@ -8,8 +8,6 @@ import escapeRE from 'escape-string-regexp'
import { defu } from 'defu' import { defu } from 'defu'
import fsExtra from 'fs-extra' import fsExtra from 'fs-extra'
import { dynamicEventHandler } from 'h3' import { dynamicEventHandler } from 'h3'
import { createHeadCore } from '@unhead/vue'
import { renderSSRHead } from '@unhead/ssr'
import type { Nuxt } from 'nuxt/schema' import type { Nuxt } from 'nuxt/schema'
// @ts-expect-error TODO: add legacy type support for subpath imports // @ts-expect-error TODO: add legacy type support for subpath imports
import { template as defaultSpaLoadingTemplate } from '@nuxt/ui-templates/templates/spa-loading-icon.mjs' 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 // Resolve user-provided paths
nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!) 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` // Add fallback server for `ssr: false`
if (!nuxt.options.ssr) { if (!nuxt.options.ssr) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}' 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 { RenderResponse } from 'nitropack'
import type { Manifest } from 'vite' import type { Manifest } from 'vite'
import type { H3Event } from 'h3' import type { H3Event } from 'h3'
@ -9,14 +15,17 @@ import destr from 'destr'
import { joinURL, withoutTrailingSlash } from 'ufo' import { joinURL, withoutTrailingSlash } from 'ufo'
import { renderToString as _renderToString } from 'vue/server-renderer' import { renderToString as _renderToString } from 'vue/server-renderer'
import { hash } from 'ohash' import { hash } from 'ohash'
import { renderSSRHead } from '@unhead/ssr'
import { defineRenderHandler, getRouteRules, useRuntimeConfig } from '#internal/nitro' import { defineRenderHandler, getRouteRules, useRuntimeConfig } from '#internal/nitro'
import { useNitroApp } from '#internal/nitro/app' 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 // eslint-disable-next-line import/no-restricted-paths
import type { NuxtPayload, NuxtSSRContext } from '#app/nuxt' import type { NuxtPayload, NuxtSSRContext } from '#app/nuxt'
// @ts-expect-error virtual file // @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 // @ts-expect-error virtual file
import { buildAssetsURL, publicAssetsURL } from '#paths' import { buildAssetsURL, publicAssetsURL } from '#paths'
@ -71,9 +80,6 @@ const getEntryIds: () => Promise<string[]> = () => getClientManifest().then(r =>
r._globalCSS r._globalCSS
).map(r => r.src!)) ).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 // @ts-expect-error file will be produced after app build
const getServerEntry = () => import('#build/dist/server/server.mjs').then(r => r.default || r) const getServerEntry = () => import('#build/dist/server/server.mjs').then(r => r.default || r)
@ -140,7 +146,6 @@ const getSPARenderer = lazyCachedFunction(async () => {
public: config.public, public: config.public,
app: config.app app: config.app
} }
ssrContext!.renderMeta = ssrContext!.renderMeta ?? getStaticRenderedHead
return Promise.resolve(result) return Promise.resolve(result)
} }
@ -221,6 +226,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Get route options (currently to apply `ssr: false`) // Get route options (currently to apply `ssr: false`)
const routeOptions = getRouteRules(event) const routeOptions = getRouteRules(event)
const head = createServerHead()
head.push(appHead)
// Initialize ssr context // Initialize ssr context
const ssrContext: NuxtSSRContext = { const ssrContext: NuxtSSRContext = {
url, url,
@ -231,6 +239,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
event.context.nuxt?.noSSR || event.context.nuxt?.noSSR ||
routeOptions.ssr === false || routeOptions.ssr === false ||
(process.env.prerender ? PRERENDER_NO_SSR_ROUTES.has(url) : false), (process.env.prerender ? PRERENDER_NO_SSR_ROUTES.has(url) : false),
head,
error: !!ssrError, error: !!ssrError,
nuxt: undefined!, /* NuxtApp */ nuxt: undefined!, /* NuxtApp */
payload: (ssrError ? { error: ssrError } : {}) as NuxtPayload, 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)) PAYLOAD_CACHE!.set(withoutTrailingSlash(url), renderPayloadResponse(ssrContext))
} }
// Render meta
const renderedMeta = await ssrContext.renderMeta?.() ?? {}
if (process.env.NUXT_INLINE_STYLES && !islandContext) { if (process.env.NUXT_INLINE_STYLES && !islandContext) {
const source = ssrContext.modules ?? ssrContext._registeredComponents const source = ssrContext.modules ?? ssrContext._registeredComponents
if (source) { if (source) {
@ -303,45 +309,81 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Render inline styles // Render inline styles
const inlinedStyles = (process.env.NUXT_INLINE_STYLES || Boolean(islandContext)) const inlinedStyles = (process.env.NUXT_INLINE_STYLES || Boolean(islandContext))
? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? []) ? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? [])
: '' : []
const NO_SCRIPTS = process.env.NUXT_NO_SCRIPTS || routeOptions.experimentalNoScripts const NO_SCRIPTS = process.env.NUXT_NO_SCRIPTS || routeOptions.experimentalNoScripts
// Create render context // Setup head
const htmlContext: NuxtRenderHTMLContext = { const { styles, scripts } = getRequestDependencies(ssrContext, renderer.rendererContext)
island: Boolean(islandContext), // 1.Extracted payload preloading
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]), if (_PAYLOAD_EXTRACTION) {
head: normalizeChunks([ head.push({
renderedMeta.headTags, link: [
process.env.NUXT_JSON_PAYLOADS process.env.NUXT_JSON_PAYLOADS
? _PAYLOAD_EXTRACTION ? `<link rel="preload" as="fetch" crossorigin="anonymous" href="${payloadURL}">` : null ? { rel: 'preload', as: 'fetch', crossorigin: 'anonymous', href: payloadURL }
: _PAYLOAD_EXTRACTION ? `<link rel="modulepreload" href="${payloadURL}">` : null, : { rel: 'modulepreload', href: payloadURL }
NO_SCRIPTS ? null : _rendered.renderResourceHints(), ]
_rendered.renderStyles(), })
inlinedStyles, }
ssrContext.styles
]), if (!NO_SCRIPTS) {
bodyAttrs: normalizeChunks([renderedMeta.bodyAttrs!]), // 2. Resource Hints
bodyPrepend: normalizeChunks([ // @todo add priorities based on Capo
renderedMeta.bodyScriptsPrepend, head.push({
ssrContext.teleports?.body link: getPreloadLinks(ssrContext, renderer.rendererContext) as Link[]
]), })
body: [process.env.NUXT_COMPONENT_ISLANDS ? replaceServerOnlyComponentsSlots(ssrContext, _rendered.html) : _rendered.html], head.push({
bodyAppend: normalizeChunks([ link: getPrefetchLinks(ssrContext, renderer.rendererContext) as Link[]
NO_SCRIPTS })
? undefined // 3. Payloads
: (_PAYLOAD_EXTRACTION head.push({
script: _PAYLOAD_EXTRACTION
? process.env.NUXT_JSON_PAYLOADS ? process.env.NUXT_JSON_PAYLOADS
? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL }) ? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL })
: renderPayloadScript({ ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL }) : renderPayloadScript({ ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL })
: process.env.NUXT_JSON_PAYLOADS : process.env.NUXT_JSON_PAYLOADS
? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload }) ? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload })
: renderPayloadScript({ 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) })
), ),
routeOptions.experimentalNoScripts ? undefined : _rendered.renderScripts(), style: inlinedStyles
// Note: bodyScripts may contain tags other than <script> })
renderedMeta.bodyScripts
]) // 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: [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: [bodyTags]
} }
// Allow hooking into the rendered result // Allow hooking into the rendered result
@ -349,21 +391,21 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Response for component islands // Response for component islands
if (process.env.NUXT_COMPONENT_ISLANDS && islandContext) { if (process.env.NUXT_COMPONENT_ISLANDS && islandContext) {
const _tags = htmlContext.head.flatMap(head => extractHTMLTags(head)) const islandHead: NuxtIslandResponse['head'] = {
const head: NuxtIslandResponse['head'] = { link: [],
link: _tags.filter(tag => tag.tagName === 'link' && tag.attrs.rel === 'stylesheet' && tag.attrs.href.includes('scoped') && !tag.attrs.href.includes('pages/')).map(tag => ({ style: []
key: 'island-link-' + hash(tag.attrs.href), }
...tag.attrs 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/')) {
style: _tags.filter(tag => tag.tagName === 'style' && tag.innerHTML).map(tag => ({ islandHead.link.push({ ...tag.props, key: 'island-link-' + hash(tag.props.href) })
key: 'island-style-' + hash(tag.innerHTML), }
innerHTML: tag.innerHTML if (tag.tag === 'style' && tag.innerHTML) {
})) islandHead.style.push({ key: 'island-style-' + hash(tag.innerHTML), innerHTML: tag.innerHTML })
}
} }
const islandResponse: NuxtIslandResponse = { const islandResponse: NuxtIslandResponse = {
id: islandContext.id, id: islandContext.id,
head, head: islandHead,
html: getServerComponentHTML(htmlContext.body), html: getServerComponentHTML(htmlContext.body),
state: ssrContext.payload.state state: ssrContext.payload.state
} }
@ -429,33 +471,17 @@ function renderHTMLDocument (html: NuxtRenderHTMLContext) {
</html>` </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[]) { async function renderInlineStyles (usedModules: Set<string> | string[]) {
const styleMap = await getSSRStyles() const styleMap = await getSSRStyles()
const inlinedStyles = new Set<string>() const inlinedStyles = new Set<string>()
for (const mod of usedModules) { for (const mod of usedModules) {
if (mod in styleMap) { if (mod in styleMap) {
for (const style of await styleMap[mod]()) { 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) { function renderPayloadResponse (ssrContext: NuxtSSRContext) {
@ -472,25 +498,41 @@ function renderPayloadResponse (ssrContext: NuxtSSRContext) {
} satisfies RenderResponse } satisfies RenderResponse
} }
function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }) { function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }): Script[] {
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)
const contents = opts.data ? stringify(opts.data, opts.ssrContext._payloadReducers) : '' const contents = opts.data ? stringify(opts.data, opts.ssrContext._payloadReducers) : ''
return `<script ${attrs.join(' ')}>${contents}</script>` + const payload: Script = {
`<script>window.__NUXT__={};window.__NUXT__.config=${uneval(opts.ssrContext.config)}</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 opts.data.config = opts.ssrContext.config
const _PAYLOAD_EXTRACTION = process.env.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !opts.ssrContext.noSSR const _PAYLOAD_EXTRACTION = process.env.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !opts.ssrContext.noSSR
if (_PAYLOAD_EXTRACTION) { 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) { function splitPayload (ssrContext: NuxtSSRContext) {

View File

@ -1,16 +1,11 @@
import { createHead as createClientHead, createServerHead } from '@unhead/vue' import { createHead as createClientHead } from '@unhead/vue'
import { renderSSRHead } from '@unhead/ssr'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
// @ts-expect-error untyped
import { appHead } from '#build/nuxt.config.mjs'
export default defineNuxtPlugin({ export default defineNuxtPlugin({
name: 'nuxt:head', name: 'nuxt:head',
setup (nuxtApp) { setup (nuxtApp) {
const createHead = process.server ? createServerHead : createClientHead const head = process.server ? nuxtApp.ssrContext!.head : createClientHead()
const head = createHead() // nuxt.config appHead is set server-side within the renderer
head.push(appHead)
nuxtApp.vueApp.use(head) nuxtApp.vueApp.use(head)
if (process.client) { if (process.client) {
@ -28,17 +23,5 @@ export default defineNuxtPlugin({
// unpause the DOM once the mount suspense is resolved // unpause the DOM once the mount suspense is resolved
nuxtApp.hooks.hook('app:suspense:resolve', unpauseDom) 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']) { for (const outputDir of ['.output', '.output-inline']) {
it('default client bundle size', async () => { it('default client bundle size', async () => {
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public')) 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(` expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[ [
"_nuxt/entry.js", "_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 serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) 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) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2330k"') expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2330k"')