rsnext/packages/next/server/post-process.ts
Shu Ding 70a53e0789
Optimize Edge SSR bundle size (#38570)
Both `Buffer` and `node-html-parser` are not necessary to be introduced to the Edge SSR bundle. This makes the bundle size 25% smaller.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples)
2022-07-12 23:39:18 +00:00

260 lines
7.7 KiB
TypeScript

import type { RenderOpts } from './render'
import type { HTMLElement } from 'next/dist/compiled/node-html-parser'
import { OPTIMIZED_FONT_PROVIDERS } from '../shared/lib/constants'
import { nonNullable } from '../lib/non-nullable'
let optimizeAmp: typeof import('./optimize-amp').default | undefined
let getFontDefinitionFromManifest:
| typeof import('./font-utils').getFontDefinitionFromManifest
| undefined
let parse: typeof import('next/dist/compiled/node-html-parser').parse
if (process.env.NEXT_RUNTIME !== 'edge') {
optimizeAmp = require('./optimize-amp').default
getFontDefinitionFromManifest =
require('./font-utils').getFontDefinitionFromManifest
parse = (
require('next/dist/compiled/node-html-parser') as typeof import('next/dist/compiled/node-html-parser')
).parse
}
type postProcessOptions = {
optimizeFonts: boolean
}
type renderOptions = {
getFontDefinition?: (url: string) => string
}
interface PostProcessMiddleware {
inspect: (originalDom: HTMLElement, options: renderOptions) => any
mutate: (markup: string, data: any, options: renderOptions) => Promise<string>
}
type middlewareSignature = {
name: string
middleware: PostProcessMiddleware
condition: ((options: postProcessOptions) => boolean) | null
}
const middlewareRegistry: Array<middlewareSignature> = []
function registerPostProcessor(
name: string,
middleware: PostProcessMiddleware,
condition?: (options: postProcessOptions) => boolean
) {
middlewareRegistry.push({ name, middleware, condition: condition || null })
}
async function processHTML(
html: string,
data: renderOptions,
options: postProcessOptions
): Promise<string> {
// Don't parse unless there's at least one processor middleware
if (!middlewareRegistry[0]) {
return html
}
const root: HTMLElement = parse(html)
let document = html
// Calls the middleware, with some instrumentation and logging
async function callMiddleWare(middleware: PostProcessMiddleware) {
// let timer = Date.now()
const inspectData = middleware.inspect(root, data)
document = await middleware.mutate(document, inspectData, data)
// timer = Date.now() - timer
// if (timer > MIDDLEWARE_TIME_BUDGET) {
// TODO: Identify a correct upper limit for the postprocess step
// and add a warning to disable the optimization
// }
return
}
for (let i = 0; i < middlewareRegistry.length; i++) {
let middleware = middlewareRegistry[i]
if (!middleware.condition || middleware.condition(options)) {
await callMiddleWare(middlewareRegistry[i].middleware)
}
}
return document
}
class FontOptimizerMiddleware implements PostProcessMiddleware {
inspect(originalDom: HTMLElement, options: renderOptions) {
if (!options.getFontDefinition) {
return
}
const fontDefinitions: (string | undefined)[][] = []
// collecting all the requested font definitions
originalDom
.querySelectorAll('link')
.filter(
(tag: HTMLElement) =>
tag.getAttribute('rel') === 'stylesheet' &&
tag.hasAttribute('data-href') &&
OPTIMIZED_FONT_PROVIDERS.some(({ url }) => {
const dataHref = tag.getAttribute('data-href')
return dataHref ? dataHref.startsWith(url) : false
})
)
.forEach((element: HTMLElement) => {
const url = element.getAttribute('data-href')
const nonce = element.getAttribute('nonce')
if (url) {
fontDefinitions.push([url, nonce])
}
})
return fontDefinitions
}
mutate = async (
markup: string,
fontDefinitions: string[][],
options: renderOptions
) => {
let result = markup
let preconnectUrls = new Set<string>()
if (!options.getFontDefinition) {
return markup
}
fontDefinitions.forEach((fontDef) => {
const [url, nonce] = fontDef
const fallBackLinkTag = `<link rel="stylesheet" href="${url}"/>`
if (
result.indexOf(`<style data-href="${url}">`) > -1 ||
result.indexOf(fallBackLinkTag) > -1
) {
// The font is already optimized and probably the response is cached
return
}
const fontContent = options.getFontDefinition
? options.getFontDefinition(url as string)
: null
if (!fontContent) {
/**
* In case of unreachable font definitions, fallback to default link tag.
*/
result = result.replace('</head>', `${fallBackLinkTag}</head>`)
} else {
const nonceStr = nonce ? ` nonce="${nonce}"` : ''
result = result.replace(
'</head>',
`<style data-href="${url}"${nonceStr}>${fontContent}</style></head>`
)
// Remove inert font tag
const escapedUrl = url
.replace(/&/g, '&amp;')
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const fontRegex = new RegExp(
`<link[^>]*data-href="${escapedUrl}"[^>]*/>`
)
result = result.replace(fontRegex, '')
const provider = OPTIMIZED_FONT_PROVIDERS.find((p) =>
url.startsWith(p.url)
)
if (provider) {
preconnectUrls.add(provider.preconnect)
}
}
})
let preconnectTag = ''
preconnectUrls.forEach((url) => {
preconnectTag += `<link rel="preconnect" href="${url}" crossorigin />`
})
result = result.replace(
'<meta name="next-font-preconnect"/>',
preconnectTag
)
return result
}
}
async function postProcessHTML(
pathname: string,
content: string,
renderOpts: RenderOpts,
{ inAmpMode, hybridAmp }: { inAmpMode: boolean; hybridAmp: boolean }
) {
const postProcessors: Array<(html: string) => Promise<string>> = [
process.env.NEXT_RUNTIME !== 'edge' && inAmpMode
? async (html: string) => {
html = await optimizeAmp!(html, renderOpts.ampOptimizerConfig)
if (!renderOpts.ampSkipValidation && renderOpts.ampValidator) {
await renderOpts.ampValidator(html, pathname)
}
return html
}
: null,
process.env.NEXT_RUNTIME !== 'edge' && process.env.__NEXT_OPTIMIZE_FONTS
? async (html: string) => {
const getFontDefinition = (url: string): string => {
if (renderOpts.fontManifest) {
return getFontDefinitionFromManifest!(
url,
renderOpts.fontManifest
)
}
return ''
}
return await processHTML(
html,
{ getFontDefinition },
{
optimizeFonts: renderOpts.optimizeFonts,
}
)
}
: null,
process.env.NEXT_RUNTIME !== 'edge' && renderOpts.optimizeCss
? async (html: string) => {
// eslint-disable-next-line import/no-extraneous-dependencies
const Critters = require('critters')
const cssOptimizer = new Critters({
ssrMode: true,
reduceInlineStyles: false,
path: renderOpts.distDir,
publicPath: `${renderOpts.assetPrefix}/_next/`,
preload: 'media',
fonts: false,
...renderOpts.optimizeCss,
})
return await cssOptimizer.process(html)
}
: null,
inAmpMode || hybridAmp
? async (html: string) => {
return html.replace(/&amp;amp=1/g, '&amp=1')
}
: null,
].filter(nonNullable)
for (const postProcessor of postProcessors) {
if (postProcessor) {
content = await postProcessor(content)
}
}
return content
}
// Initialization
registerPostProcessor(
'Inline-Fonts',
new FontOptimizerMiddleware(),
// Using process.env because passing Experimental flag through loader is not possible.
// @ts-ignore
(options) => options.optimizeFonts || process.env.__NEXT_OPTIMIZE_FONTS
)
export { postProcessHTML }