rsnext/packages/next/export/worker.ts
Gerald Monaco 707afe1d4d
Add RenderResult (#27319)
Adds `RenderResult`, replacing the `string` that `renderToHTML` used to return, with an `Observable`-like API that callers can use to subscribe and get a callback when chunks are available to flush, etc.

This is the last architectural change needed for streaming. There are, however, other things currently standing in the way of streaming. For example, it is common to mutate `res` in `getServerSideProps` to do routing work, or write headers, before fetching page data. This pattern effectively nullifies any advantages of streaming. I may do a follow-up PR that adds an experimental alternative for applications not using React 18, but the main purpose for this support is for Suspense and Server Components.

For that reason, there's no actual streaming here yet: instead we just flush a single chunk. A follow-up PR will add support for streaming suspense boundaries in React 18.
2021-07-27 19:18:21 +00:00

502 lines
16 KiB
TypeScript

import url from 'url'
import { extname, join, dirname, sep } from 'path'
import { renderToHTML } from '../server/render'
import { promises } from 'fs'
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
import { loadComponents } from '../server/load-components'
import { isDynamicRoute } from '../shared/lib/router/utils/is-dynamic'
import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher'
import { getRouteRegex } from '../shared/lib/router/utils/route-regex'
import { normalizePagePath } from '../server/normalize-page-path'
import { SERVER_PROPS_EXPORT_ERROR } from '../lib/constants'
import '../server/node-polyfill-fetch'
import { IncomingMessage, ServerResponse } from 'http'
import { ComponentType } from 'react'
import { GetStaticProps } from '../types'
import { requireFontManifest } from '../server/require'
import { FontManifest } from '../server/font-utils'
import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
import { trace } from '../telemetry/trace'
import { isInAmpMode } from '../shared/lib/amp'
import { resultFromChunks, resultToChunks } from '../server/utils'
const envConfig = require('../shared/lib/runtime-config')
;(global as any).__NEXT_DATA__ = {
nextExport: true,
}
interface AmpValidation {
page: string
result: {
errors: AmpHtmlValidator.ValidationError[]
warnings: AmpHtmlValidator.ValidationError[]
}
}
interface PathMap {
page: string
query?: { [key: string]: string | string[] }
}
interface ExportPageInput {
path: string
pathMap: PathMap
distDir: string
outDir: string
pagesDataDir: string
renderOpts: RenderOpts
buildExport?: boolean
serverRuntimeConfig: { [key: string]: any }
subFolders?: boolean
serverless: boolean
optimizeFonts: boolean
optimizeImages?: boolean
optimizeCss: any
disableOptimizedLoading: any
parentSpanId: any
}
interface ExportPageResults {
ampValidations: AmpValidation[]
fromBuildExportRevalidate?: number
error?: boolean
ssgNotFound?: boolean
duration: number
}
interface RenderOpts {
runtimeConfig?: { [key: string]: any }
params?: { [key: string]: string | string[] }
ampPath?: string
ampValidatorPath?: string
ampSkipValidation?: boolean
optimizeFonts?: boolean
optimizeImages?: boolean
disableOptimizedLoading?: boolean
optimizeCss?: any
fontManifest?: FontManifest
locales?: string[]
locale?: string
defaultLocale?: string
trailingSlash?: boolean
}
type ComponentModule = ComponentType<{}> & {
renderReqToHTML: typeof renderToHTML
getStaticProps?: GetStaticProps
}
export default async function exportPage({
parentSpanId,
path,
pathMap,
distDir,
outDir,
pagesDataDir,
renderOpts,
buildExport,
serverRuntimeConfig,
subFolders,
serverless,
optimizeFonts,
optimizeImages,
optimizeCss,
disableOptimizedLoading,
}: ExportPageInput): Promise<ExportPageResults> {
const exportPageSpan = trace('export-page-worker', parentSpanId)
return exportPageSpan.traceAsyncFn(async () => {
const start = Date.now()
let results: Omit<ExportPageResults, 'duration'> = {
ampValidations: [],
}
try {
const { query: originalQuery = {} } = pathMap
const { page } = pathMap
const filePath = normalizePagePath(path)
const isDynamic = isDynamicRoute(page)
const ampPath = `${filePath}.amp`
let renderAmpPath = ampPath
let query = { ...originalQuery }
let params: { [key: string]: string | string[] } | undefined
let updatedPath = (query.__nextSsgPath as string) || path
let locale = query.__nextLocale || renderOpts.locale
delete query.__nextLocale
delete query.__nextSsgPath
if (renderOpts.locale) {
const localePathResult = normalizeLocalePath(path, renderOpts.locales)
if (localePathResult.detectedLocale) {
updatedPath = localePathResult.pathname
locale = localePathResult.detectedLocale
if (locale === renderOpts.defaultLocale) {
renderAmpPath = `${normalizePagePath(updatedPath)}.amp`
}
}
}
// We need to show a warning if they try to provide query values
// for an auto-exported page since they won't be available
const hasOrigQueryValues = Object.keys(originalQuery).length > 0
const queryWithAutoExportWarn = () => {
if (hasOrigQueryValues) {
throw new Error(
`\nError: you provided query values for ${path} which is an auto-exported page. These can not be applied since the page can no longer be re-rendered on the server. To disable auto-export for this page add \`getInitialProps\`\n`
)
}
}
// Check if the page is a specified dynamic route
const nonLocalizedPath = normalizeLocalePath(path, renderOpts.locales)
.pathname
if (isDynamic && page !== nonLocalizedPath) {
params = getRouteMatcher(getRouteRegex(page))(updatedPath) || undefined
if (params) {
// we have to pass these separately for serverless
if (!serverless) {
query = {
...query,
...params,
}
}
} else {
throw new Error(
`The provided export path '${updatedPath}' doesn't match the '${page}' page.\nRead more: https://nextjs.org/docs/messages/export-path-mismatch`
)
}
}
const headerMocks = {
headers: {},
getHeader: () => ({}),
setHeader: () => {},
hasHeader: () => false,
removeHeader: () => {},
getHeaderNames: () => [],
}
const req = ({
url: updatedPath,
...headerMocks,
} as unknown) as IncomingMessage
const res = ({
...headerMocks,
} as unknown) as ServerResponse
if (path === '/500' && page === '/_error') {
res.statusCode = 500
}
if (renderOpts.trailingSlash && !req.url?.endsWith('/')) {
req.url += '/'
}
envConfig.setConfig({
serverRuntimeConfig,
publicRuntimeConfig: renderOpts.runtimeConfig,
})
const getHtmlFilename = (_path: string) =>
subFolders ? `${_path}${sep}index.html` : `${_path}.html`
let htmlFilename = getHtmlFilename(filePath)
const pageExt = extname(page)
const pathExt = extname(path)
// Make sure page isn't a folder with a dot in the name e.g. `v1.2`
if (pageExt !== pathExt && pathExt !== '') {
const isBuiltinPaths = ['/500', '/404'].some(
(p) => p === path || p === path + '.html'
)
// If the ssg path has .html extension, and it's not builtin paths, use it directly
// Otherwise, use that as the filename instead
const isHtmlExtPath =
!serverless && !isBuiltinPaths && path.endsWith('.html')
htmlFilename = isHtmlExtPath ? getHtmlFilename(path) : path
} else if (path === '/') {
// If the path is the root, just use index.html
htmlFilename = 'index.html'
}
const baseDir = join(outDir, dirname(htmlFilename))
let htmlFilepath = join(outDir, htmlFilename)
await promises.mkdir(baseDir, { recursive: true })
let renderResult
let curRenderOpts: RenderOpts = {}
let renderMethod = renderToHTML
let inAmpMode = false,
hybridAmp = false
const renderedDuringBuild = (getStaticProps: any) => {
return !buildExport && getStaticProps && !isDynamicRoute(path)
}
if (serverless) {
const curUrl = url.parse(req.url!, true)
req.url = url.format({
...curUrl,
query: {
...curUrl.query,
...query,
},
})
const {
Component: mod,
getServerSideProps,
pageConfig,
} = await loadComponents(distDir, page, serverless)
const ampState = {
ampFirst: pageConfig?.amp === true,
hasQuery: Boolean(query.amp),
hybrid: pageConfig?.amp === 'hybrid',
}
inAmpMode = isInAmpMode(ampState)
hybridAmp = ampState.hybrid
if (getServerSideProps) {
throw new Error(
`Error for page ${page}: ${SERVER_PROPS_EXPORT_ERROR}`
)
}
// if it was auto-exported the HTML is loaded here
if (typeof mod === 'string') {
renderResult = resultFromChunks([mod])
queryWithAutoExportWarn()
} else {
// for non-dynamic SSG pages we should have already
// prerendered the file
if (renderedDuringBuild((mod as ComponentModule).getStaticProps))
return { ...results, duration: Date.now() - start }
if (
(mod as ComponentModule).getStaticProps &&
!htmlFilepath.endsWith('.html')
) {
// make sure it ends with .html if the name contains a dot
htmlFilename += '.html'
htmlFilepath += '.html'
}
renderMethod = (mod as ComponentModule).renderReqToHTML
const result = await renderMethod(
req,
res,
'export',
{
ampPath: renderAmpPath,
/// @ts-ignore
optimizeFonts,
/// @ts-ignore
optimizeImages,
/// @ts-ignore
optimizeCss,
disableOptimizedLoading,
distDir,
fontManifest: optimizeFonts
? requireFontManifest(distDir, serverless)
: null,
locale: locale!,
locales: renderOpts.locales!,
},
// @ts-ignore
params
)
curRenderOpts = (result as any).renderOpts || {}
renderResult = (result as any).html
}
if (!renderResult && !(curRenderOpts as any).isNotFound) {
throw new Error(`Failed to render serverless page`)
}
} else {
const components = await loadComponents(distDir, page, serverless)
const ampState = {
ampFirst: components.pageConfig?.amp === true,
hasQuery: Boolean(query.amp),
hybrid: components.pageConfig?.amp === 'hybrid',
}
inAmpMode = isInAmpMode(ampState)
hybridAmp = ampState.hybrid
if (components.getServerSideProps) {
throw new Error(
`Error for page ${page}: ${SERVER_PROPS_EXPORT_ERROR}`
)
}
// for non-dynamic SSG pages we should have already
// prerendered the file
if (renderedDuringBuild(components.getStaticProps)) {
return { ...results, duration: Date.now() - start }
}
// TODO: de-dupe the logic here between serverless and server mode
if (components.getStaticProps && !htmlFilepath.endsWith('.html')) {
// make sure it ends with .html if the name contains a dot
htmlFilepath += '.html'
htmlFilename += '.html'
}
if (typeof components.Component === 'string') {
renderResult = resultFromChunks([components.Component])
queryWithAutoExportWarn()
} else {
/**
* This sets environment variable to be used at the time of static export by head.tsx.
* Using this from process.env allows targeting both serverless and SSR by calling
* `process.env.__NEXT_OPTIMIZE_FONTS`.
* TODO(prateekbh@): Remove this when experimental.optimizeFonts are being cleaned up.
*/
if (optimizeFonts) {
process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true)
}
if (optimizeImages) {
process.env.__NEXT_OPTIMIZE_IMAGES = JSON.stringify(true)
}
if (optimizeCss) {
process.env.__NEXT_OPTIMIZE_CSS = JSON.stringify(true)
}
curRenderOpts = {
...components,
...renderOpts,
ampPath: renderAmpPath,
params,
optimizeFonts,
optimizeImages,
optimizeCss,
disableOptimizedLoading,
fontManifest: optimizeFonts
? requireFontManifest(distDir, serverless)
: null,
locale: locale as string,
}
renderResult = await renderMethod(
req,
res,
page,
query,
// @ts-ignore
curRenderOpts
)
}
}
results.ssgNotFound = (curRenderOpts as any).isNotFound
const validateAmp = async (
rawAmpHtml: string,
ampPageName: string,
validatorPath?: string
) => {
const validator = await AmpHtmlValidator.getInstance(validatorPath)
const result = validator.validateString(rawAmpHtml)
const errors = result.errors.filter((e) => e.severity === 'ERROR')
const warnings = result.errors.filter((e) => e.severity !== 'ERROR')
if (warnings.length || errors.length) {
results.ampValidations.push({
page: ampPageName,
result: {
errors,
warnings,
},
})
}
}
const htmlChunks = renderResult ? await resultToChunks(renderResult) : []
const html = htmlChunks.join('')
if (inAmpMode && !curRenderOpts.ampSkipValidation) {
if (!results.ssgNotFound) {
await validateAmp(html, path, curRenderOpts.ampValidatorPath)
}
} else if (hybridAmp) {
// we need to render the AMP version
let ampHtmlFilename = `${ampPath}${sep}index.html`
if (!subFolders) {
ampHtmlFilename = `${ampPath}.html`
}
const ampBaseDir = join(outDir, dirname(ampHtmlFilename))
const ampHtmlFilepath = join(outDir, ampHtmlFilename)
try {
await promises.access(ampHtmlFilepath)
} catch (_) {
// make sure it doesn't exist from manual mapping
let ampRenderResult
if (serverless) {
req.url += (req.url!.includes('?') ? '&' : '?') + 'amp=1'
// @ts-ignore
ampRenderResult = (
await (renderMethod as any)(
req,
res,
'export',
curRenderOpts,
params
)
).html
} else {
ampRenderResult = await renderMethod(
req,
res,
page,
// @ts-ignore
{ ...query, amp: '1' },
curRenderOpts as any
)
}
const ampChunks = await resultToChunks(ampRenderResult)
const ampHtml = ampChunks.join('')
if (!curRenderOpts.ampSkipValidation) {
await validateAmp(ampHtml, page + '?amp=1')
}
await promises.mkdir(ampBaseDir, { recursive: true })
await promises.writeFile(ampHtmlFilepath, ampHtml, 'utf8')
}
}
if ((curRenderOpts as any).pageData) {
const dataFile = join(
pagesDataDir,
htmlFilename.replace(/\.html$/, '.json')
)
await promises.mkdir(dirname(dataFile), { recursive: true })
await promises.writeFile(
dataFile,
JSON.stringify((curRenderOpts as any).pageData),
'utf8'
)
if (hybridAmp) {
await promises.writeFile(
dataFile.replace(/\.json$/, '.amp.json'),
JSON.stringify((curRenderOpts as any).pageData),
'utf8'
)
}
}
results.fromBuildExportRevalidate = (curRenderOpts as any).revalidate
if (!results.ssgNotFound) {
// don't attempt writing to disk if getStaticProps returned not found
await promises.writeFile(htmlFilepath, html, 'utf8')
}
} catch (error) {
console.error(
`\nError occurred prerendering page "${path}". Read more: https://nextjs.org/docs/messages/prerender-error\n` +
error.stack
)
results.error = true
}
return { ...results, duration: Date.now() - start }
})
}