Split prerending by route kind (#55622)
In order to support updates to the prerendering pipelines, this breaks out some of the prerendering code into discrete chunks importable for each route kind rather than them all sharing the same function. Some small optimizations were made, namely some matcher memoization (only the last one) that should help with large builds locally.
This commit is contained in:
parent
f0ffa1c79e
commit
a88e9953b9
16 changed files with 981 additions and 764 deletions
52
packages/next/src/export/helpers/create-incremental-cache.ts
Normal file
52
packages/next/src/export/helpers/create-incremental-cache.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { IncrementalCache } from '../../server/lib/incremental-cache'
|
||||
import { hasNextSupport } from '../../telemetry/ci-info'
|
||||
|
||||
export function createIncrementalCache(
|
||||
incrementalCacheHandlerPath: string | undefined,
|
||||
isrMemoryCacheSize: number | undefined,
|
||||
fetchCacheKeyPrefix: string | undefined,
|
||||
distDir: string
|
||||
) {
|
||||
// Custom cache handler overrides.
|
||||
let CacheHandler: any
|
||||
if (incrementalCacheHandlerPath) {
|
||||
CacheHandler = require(incrementalCacheHandlerPath)
|
||||
CacheHandler = CacheHandler.default || CacheHandler
|
||||
}
|
||||
|
||||
const incrementalCache = new IncrementalCache({
|
||||
dev: false,
|
||||
requestHeaders: {},
|
||||
flushToDisk: true,
|
||||
fetchCache: true,
|
||||
maxMemoryCacheSize: isrMemoryCacheSize,
|
||||
fetchCacheKeyPrefix,
|
||||
getPrerenderManifest: () => ({
|
||||
version: 4,
|
||||
routes: {},
|
||||
dynamicRoutes: {},
|
||||
preview: {
|
||||
previewModeEncryptionKey: '',
|
||||
previewModeId: '',
|
||||
previewModeSigningKey: '',
|
||||
},
|
||||
notFoundRoutes: [],
|
||||
}),
|
||||
fs: {
|
||||
readFile: (f) => fs.promises.readFile(f),
|
||||
readFileSync: (f) => fs.readFileSync(f),
|
||||
writeFile: (f, d) => fs.promises.writeFile(f, d),
|
||||
mkdir: (dir) => fs.promises.mkdir(dir, { recursive: true }),
|
||||
stat: (f) => fs.promises.stat(f),
|
||||
},
|
||||
serverDistDir: path.join(distDir, 'server'),
|
||||
CurCacheHandler: CacheHandler,
|
||||
minimalMode: hasNextSupport,
|
||||
})
|
||||
|
||||
;(globalThis as any).__incrementalCache = incrementalCache
|
||||
|
||||
return incrementalCache
|
||||
}
|
38
packages/next/src/export/helpers/get-params.ts
Normal file
38
packages/next/src/export/helpers/get-params.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
type RouteMatchFn,
|
||||
getRouteMatcher,
|
||||
} from '../../shared/lib/router/utils/route-matcher'
|
||||
import { getRouteRegex } from '../../shared/lib/router/utils/route-regex'
|
||||
|
||||
// The last page and matcher that this function handled.
|
||||
let last: {
|
||||
page: string
|
||||
matcher: RouteMatchFn
|
||||
} | null = null
|
||||
|
||||
/**
|
||||
* Gets the params for the provided page.
|
||||
* @param page the page that contains dynamic path parameters
|
||||
* @param pathname the pathname to match
|
||||
* @returns the matches that were found, throws otherwise
|
||||
*/
|
||||
export function getParams(page: string, pathname: string) {
|
||||
// Because this is often called on the output of `getStaticPaths` or similar
|
||||
// where the `page` here doesn't change, this will "remember" the last page
|
||||
// it created the RegExp for. If it matches, it'll just re-use it.
|
||||
let matcher: RouteMatchFn
|
||||
if (last?.page === page) {
|
||||
matcher = last.matcher
|
||||
} else {
|
||||
matcher = getRouteMatcher(getRouteRegex(page))
|
||||
}
|
||||
|
||||
const params = matcher(pathname)
|
||||
if (!params) {
|
||||
throw new Error(
|
||||
`The provided export path '${pathname}' doesn't match the '${page}' page.\nRead more: https://nextjs.org/docs/messages/export-path-mismatch`
|
||||
)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
10
packages/next/src/export/helpers/is-dynamic-usage-error.ts
Normal file
10
packages/next/src/export/helpers/is-dynamic-usage-error.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { DYNAMIC_ERROR_CODE } from '../../client/components/hooks-server-context'
|
||||
import { isNotFoundError } from '../../client/components/not-found'
|
||||
import { isRedirectError } from '../../client/components/redirect'
|
||||
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../../shared/lib/lazy-dynamic/no-ssr-error'
|
||||
|
||||
export const isDynamicUsageError = (err: any) =>
|
||||
err.digest === DYNAMIC_ERROR_CODE ||
|
||||
isNotFoundError(err) ||
|
||||
err.digest === NEXT_DYNAMIC_NO_SSR_CODE ||
|
||||
isRedirectError(err)
|
|
@ -1,3 +1,5 @@
|
|||
import type { WorkerRenderOptsPartial } from './types'
|
||||
|
||||
import chalk from 'next/dist/compiled/chalk'
|
||||
import findUp from 'next/dist/compiled/find-up'
|
||||
import {
|
||||
|
@ -9,6 +11,7 @@ import {
|
|||
} from 'fs'
|
||||
|
||||
import '../server/require-hook'
|
||||
|
||||
import { Worker } from '../lib/worker'
|
||||
import { dirname, join, resolve, sep } from 'path'
|
||||
import { promisify } from 'util'
|
||||
|
@ -447,35 +450,31 @@ export default async function exportApp(
|
|||
}
|
||||
|
||||
// Start the rendering process
|
||||
const renderOpts = {
|
||||
dir,
|
||||
const renderOpts: WorkerRenderOptsPartial = {
|
||||
previewProps: prerenderManifest?.preview,
|
||||
buildId,
|
||||
nextExport: true,
|
||||
assetPrefix: nextConfig.assetPrefix.replace(/\/$/, ''),
|
||||
distDir,
|
||||
dev: false,
|
||||
hotReloader: null,
|
||||
basePath: nextConfig.basePath,
|
||||
canonicalBase: nextConfig.amp?.canonicalBase || '',
|
||||
ampValidatorPath: nextConfig.experimental.amp?.validator || undefined,
|
||||
ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false,
|
||||
ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined,
|
||||
locales: i18n?.locales,
|
||||
locale: i18n?.defaultLocale,
|
||||
defaultLocale: i18n?.defaultLocale,
|
||||
domainLocales: i18n?.domains,
|
||||
trailingSlash: nextConfig.trailingSlash,
|
||||
disableOptimizedLoading: nextConfig.experimental.disableOptimizedLoading,
|
||||
// Exported pages do not currently support dynamic HTML.
|
||||
supportsDynamicHTML: false,
|
||||
crossOrigin: nextConfig.crossOrigin,
|
||||
crossOrigin: nextConfig.crossOrigin || '',
|
||||
optimizeCss: nextConfig.experimental.optimizeCss,
|
||||
nextConfigOutput: nextConfig.output,
|
||||
nextScriptWorkers: nextConfig.experimental.nextScriptWorkers,
|
||||
optimizeFonts: nextConfig.optimizeFonts as FontConfig,
|
||||
largePageDataBytes: nextConfig.experimental.largePageDataBytes,
|
||||
serverComponents: options.hasAppDir,
|
||||
hasServerComponents: options.hasAppDir,
|
||||
serverActionsBodySizeLimit:
|
||||
nextConfig.experimental.serverActionsBodySizeLimit,
|
||||
nextFontManifest: require(join(
|
||||
|
@ -716,6 +715,9 @@ export default async function exportApp(
|
|||
outDir,
|
||||
pagesDataDir,
|
||||
renderOpts,
|
||||
ampValidatorPath:
|
||||
nextConfig.experimental.amp?.validator || undefined,
|
||||
trailingSlash: nextConfig.trailingSlash,
|
||||
serverRuntimeConfig,
|
||||
subFolders,
|
||||
buildExport: options.buildExport,
|
||||
|
@ -734,15 +736,19 @@ export default async function exportApp(
|
|||
enableExperimentalReact: needsExperimentalReact(nextConfig),
|
||||
})
|
||||
|
||||
for (const validation of result.ampValidations || []) {
|
||||
const { page, result: ampValidationResult } = validation
|
||||
ampValidations[page] = ampValidationResult
|
||||
hadValidationError =
|
||||
hadValidationError ||
|
||||
(Array.isArray(ampValidationResult?.errors) &&
|
||||
ampValidationResult.errors.length > 0)
|
||||
if (result.ampValidations) {
|
||||
for (const validation of result.ampValidations) {
|
||||
const { page, result: ampValidationResult } = validation
|
||||
ampValidations[page] = ampValidationResult
|
||||
hadValidationError =
|
||||
hadValidationError ||
|
||||
(Array.isArray(ampValidationResult?.errors) &&
|
||||
ampValidationResult.errors.length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
renderError = renderError || !!result.error
|
||||
|
||||
if (!!result.error) {
|
||||
const { page } = pathMap
|
||||
errorPaths.push(page !== path ? `${page}: ${path}` : path)
|
||||
|
|
167
packages/next/src/export/routes/app-page.ts
Normal file
167
packages/next/src/export/routes/app-page.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import type { ExportPageResult } from '../types'
|
||||
import type { AppPageRender } from '../../server/app-render/app-render'
|
||||
import type { RenderOpts } from '../../server/app-render/types'
|
||||
import type { OutgoingHttpHeaders } from 'http'
|
||||
import type { NextParsedUrlQuery } from '../../server/request-meta'
|
||||
|
||||
import fs from 'fs/promises'
|
||||
import { MockedRequest, MockedResponse } from '../../server/lib/mock-request'
|
||||
import {
|
||||
RSC,
|
||||
NEXT_URL,
|
||||
NEXT_ROUTER_PREFETCH,
|
||||
} from '../../client/components/app-router-headers'
|
||||
import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error'
|
||||
import { NEXT_CACHE_TAGS_HEADER } from '../../lib/constants'
|
||||
import { hasNextSupport } from '../../telemetry/ci-info'
|
||||
|
||||
/**
|
||||
* Lazily loads and runs the app page render function.
|
||||
*/
|
||||
const render: AppPageRender = (...args) => {
|
||||
return require('../../server/future/route-modules/app-page/module.compiled').renderToHTMLOrFlight(
|
||||
...args
|
||||
)
|
||||
}
|
||||
|
||||
export async function generatePrefetchRsc(
|
||||
req: MockedRequest,
|
||||
path: string,
|
||||
res: MockedResponse,
|
||||
pathname: string,
|
||||
query: NextParsedUrlQuery,
|
||||
htmlFilepath: string,
|
||||
renderOpts: RenderOpts
|
||||
) {
|
||||
req.headers[RSC.toLowerCase()] = '1'
|
||||
req.headers[NEXT_URL.toLowerCase()] = path
|
||||
req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()] = '1'
|
||||
|
||||
renderOpts.supportsDynamicHTML = true
|
||||
delete renderOpts.isRevalidate
|
||||
|
||||
const prefetchRenderResult = await render(
|
||||
req,
|
||||
res,
|
||||
pathname,
|
||||
query,
|
||||
renderOpts
|
||||
)
|
||||
|
||||
prefetchRenderResult.pipe(res)
|
||||
await res.hasStreamed
|
||||
|
||||
const prefetchRscData = Buffer.concat(res.buffers)
|
||||
|
||||
await fs.writeFile(
|
||||
htmlFilepath.replace(/\.html$/, '.prefetch.rsc'),
|
||||
prefetchRscData
|
||||
)
|
||||
}
|
||||
|
||||
export async function exportAppPage(
|
||||
req: MockedRequest,
|
||||
res: MockedResponse,
|
||||
page: string,
|
||||
path: string,
|
||||
pathname: string,
|
||||
query: NextParsedUrlQuery,
|
||||
renderOpts: RenderOpts,
|
||||
htmlFilepath: string,
|
||||
debugOutput: boolean,
|
||||
isDynamicError: boolean,
|
||||
isAppPrefetch: boolean
|
||||
): Promise<ExportPageResult> {
|
||||
// If the page is `/_not-found`, then we should update the page to be `/404`.
|
||||
if (page === '/_not-found') {
|
||||
pathname = '/404'
|
||||
}
|
||||
|
||||
try {
|
||||
if (isAppPrefetch) {
|
||||
await generatePrefetchRsc(
|
||||
req,
|
||||
path,
|
||||
res,
|
||||
pathname,
|
||||
query,
|
||||
htmlFilepath,
|
||||
renderOpts
|
||||
)
|
||||
|
||||
return { fromBuildExportRevalidate: 0 }
|
||||
}
|
||||
|
||||
const result = await render(req, res, pathname, query, renderOpts)
|
||||
const html = result.toUnchunkedString()
|
||||
const { metadata } = result
|
||||
const flightData = metadata.pageData
|
||||
const revalidate = metadata.revalidate
|
||||
|
||||
if (revalidate === 0) {
|
||||
if (isDynamicError) {
|
||||
throw new Error(
|
||||
`Page with dynamic = "error" encountered dynamic data method on ${path}.`
|
||||
)
|
||||
}
|
||||
|
||||
await generatePrefetchRsc(
|
||||
req,
|
||||
path,
|
||||
res,
|
||||
pathname,
|
||||
query,
|
||||
htmlFilepath,
|
||||
renderOpts
|
||||
)
|
||||
|
||||
const { staticBailoutInfo = {} } = metadata
|
||||
|
||||
if (revalidate === 0 && debugOutput && staticBailoutInfo?.description) {
|
||||
const err = new Error(
|
||||
`Static generation failed due to dynamic usage on ${path}, reason: ${staticBailoutInfo.description}`
|
||||
)
|
||||
|
||||
// Update the stack if it was provided via the bailout info.
|
||||
const { stack } = staticBailoutInfo
|
||||
if (stack) {
|
||||
err.stack = err.message + stack.substring(stack.indexOf('\n'))
|
||||
}
|
||||
|
||||
console.warn(err)
|
||||
}
|
||||
|
||||
return { fromBuildExportRevalidate: 0 }
|
||||
}
|
||||
|
||||
let headers: OutgoingHttpHeaders | undefined
|
||||
if (metadata.fetchTags) {
|
||||
headers = { [NEXT_CACHE_TAGS_HEADER]: metadata.fetchTags }
|
||||
}
|
||||
|
||||
// Writing static HTML to a file.
|
||||
await fs.writeFile(htmlFilepath, html ?? '', 'utf8')
|
||||
|
||||
// Writing the request metadata to a file.
|
||||
const meta = { headers }
|
||||
await fs.writeFile(
|
||||
htmlFilepath.replace(/\.html$/, '.meta'),
|
||||
JSON.stringify(meta)
|
||||
)
|
||||
|
||||
// Writing the RSC payload to a file.
|
||||
await fs.writeFile(htmlFilepath.replace(/\.html$/, '.rsc'), flightData)
|
||||
|
||||
return {
|
||||
fromBuildExportRevalidate: revalidate,
|
||||
// Only include the metadata if the environment has next support.
|
||||
fromBuildExportMeta: hasNextSupport ? meta : undefined,
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!isDynamicUsageError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
return { fromBuildExportRevalidate: 0 }
|
||||
}
|
||||
}
|
117
packages/next/src/export/routes/app-route.ts
Normal file
117
packages/next/src/export/routes/app-route.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import type { ExportPageResult } from '../types'
|
||||
import type AppRouteRouteModule from '../../server/future/route-modules/app-route/module'
|
||||
import type { AppRouteRouteHandlerContext } from '../../server/future/route-modules/app-route/module'
|
||||
import type { IncrementalCache } from '../../server/lib/incremental-cache'
|
||||
|
||||
import fs from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { NEXT_CACHE_TAGS_HEADER } from '../../lib/constants'
|
||||
import { NodeNextRequest } from '../../server/base-http/node'
|
||||
import { RouteModuleLoader } from '../../server/future/helpers/module-loader/route-module-loader'
|
||||
import {
|
||||
NextRequestAdapter,
|
||||
signalFromNodeResponse,
|
||||
} from '../../server/web/spec-extension/adapters/next-request'
|
||||
import { toNodeOutgoingHttpHeaders } from '../../server/web/utils'
|
||||
import { MockedRequest, MockedResponse } from '../../server/lib/mock-request'
|
||||
import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error'
|
||||
import { SERVER_DIRECTORY } from '../../shared/lib/constants'
|
||||
import { hasNextSupport } from '../../telemetry/ci-info'
|
||||
|
||||
export async function exportAppRoute(
|
||||
req: MockedRequest,
|
||||
res: MockedResponse,
|
||||
params: { [key: string]: string | string[] } | undefined,
|
||||
page: string,
|
||||
incrementalCache: IncrementalCache | undefined,
|
||||
distDir: string,
|
||||
htmlFilepath: string
|
||||
): Promise<ExportPageResult> {
|
||||
// Ensure that the URL is absolute.
|
||||
req.url = `http://localhost:3000${req.url}`
|
||||
|
||||
// Adapt the request and response to the Next.js request and response.
|
||||
const request = NextRequestAdapter.fromNodeNextRequest(
|
||||
new NodeNextRequest(req),
|
||||
signalFromNodeResponse(res)
|
||||
)
|
||||
|
||||
// Create the context for the handler. This contains the params from
|
||||
// the route and the context for the request.
|
||||
const context: AppRouteRouteHandlerContext = {
|
||||
params,
|
||||
prerenderManifest: {
|
||||
version: 4,
|
||||
routes: {},
|
||||
dynamicRoutes: {},
|
||||
preview: {
|
||||
previewModeEncryptionKey: '',
|
||||
previewModeId: '',
|
||||
previewModeSigningKey: '',
|
||||
},
|
||||
notFoundRoutes: [],
|
||||
},
|
||||
staticGenerationContext: {
|
||||
originalPathname: page,
|
||||
nextExport: true,
|
||||
supportsDynamicHTML: false,
|
||||
incrementalCache,
|
||||
},
|
||||
}
|
||||
|
||||
if (hasNextSupport) {
|
||||
context.staticGenerationContext.isRevalidate = true
|
||||
}
|
||||
|
||||
// This is a route handler, which means it has it's handler in the
|
||||
// bundled file already, we should just use that.
|
||||
const filename = join(distDir, SERVER_DIRECTORY, 'app', page)
|
||||
|
||||
try {
|
||||
// Route module loading and handling.
|
||||
const module = await RouteModuleLoader.load<AppRouteRouteModule>(filename)
|
||||
const response = await module.handle(request, context)
|
||||
|
||||
const isValidStatus = response.status < 400 || response.status === 404
|
||||
if (!isValidStatus) {
|
||||
return { fromBuildExportRevalidate: 0 }
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const revalidate =
|
||||
context.staticGenerationContext.store?.revalidate || false
|
||||
|
||||
const headers = toNodeOutgoingHttpHeaders(response.headers)
|
||||
const cacheTags = (context.staticGenerationContext as any).fetchTags
|
||||
|
||||
if (cacheTags) {
|
||||
headers[NEXT_CACHE_TAGS_HEADER] = cacheTags
|
||||
}
|
||||
|
||||
if (!headers['content-type'] && blob.type) {
|
||||
headers['content-type'] = blob.type
|
||||
}
|
||||
|
||||
// Writing response body to a file.
|
||||
const body = Buffer.from(await blob.arrayBuffer())
|
||||
await fs.writeFile(htmlFilepath.replace(/\.html$/, '.body'), body, 'utf8')
|
||||
|
||||
// Write the request metadata to a file.
|
||||
const meta = { status: response.status, headers }
|
||||
await fs.writeFile(
|
||||
htmlFilepath.replace(/\.html$/, '.meta'),
|
||||
JSON.stringify(meta)
|
||||
)
|
||||
|
||||
return {
|
||||
fromBuildExportRevalidate: revalidate,
|
||||
fromBuildExportMeta: meta,
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isDynamicUsageError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
return { fromBuildExportRevalidate: 0 }
|
||||
}
|
||||
}
|
203
packages/next/src/export/routes/pages.ts
Normal file
203
packages/next/src/export/routes/pages.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
import type { ExportPageResult } from '../types'
|
||||
import type { PagesRender, RenderOpts } from '../../server/render'
|
||||
import type { LoadComponentsReturnType } from '../../server/load-components'
|
||||
import type { AmpValidation } from '../types'
|
||||
import type { NextParsedUrlQuery } from '../../server/request-meta'
|
||||
|
||||
import fs from 'fs/promises'
|
||||
import RenderResult from '../../server/render-result'
|
||||
import { dirname, join } from 'path'
|
||||
import { MockedRequest, MockedResponse } from '../../server/lib/mock-request'
|
||||
import { isInAmpMode } from '../../shared/lib/amp-mode'
|
||||
import { SERVER_PROPS_EXPORT_ERROR } from '../../lib/constants'
|
||||
import { NEXT_DYNAMIC_NO_SSR_CODE } from '../../shared/lib/lazy-dynamic/no-ssr-error'
|
||||
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
|
||||
import { FileType, fileExists } from '../../lib/file-exists'
|
||||
|
||||
/**
|
||||
* Lazily loads and runs the app page render function.
|
||||
*/
|
||||
const render: PagesRender = (...args) => {
|
||||
return require('../../server/future/route-modules/pages/module.compiled').renderToHTML(
|
||||
...args
|
||||
)
|
||||
}
|
||||
|
||||
export async function exportPages(
|
||||
req: MockedRequest,
|
||||
res: MockedResponse,
|
||||
path: string,
|
||||
page: string,
|
||||
query: NextParsedUrlQuery,
|
||||
htmlFilepath: string,
|
||||
htmlFilename: string,
|
||||
ampPath: string,
|
||||
subFolders: boolean,
|
||||
outDir: string,
|
||||
ampValidatorPath: string | undefined,
|
||||
pagesDataDir: string,
|
||||
buildExport: boolean,
|
||||
isDynamic: boolean,
|
||||
hasOrigQueryValues: boolean,
|
||||
renderOpts: RenderOpts,
|
||||
components: LoadComponentsReturnType
|
||||
): Promise<ExportPageResult> {
|
||||
const ampState = {
|
||||
ampFirst: components.pageConfig?.amp === true,
|
||||
hasQuery: Boolean(query.amp),
|
||||
hybrid: components.pageConfig?.amp === 'hybrid',
|
||||
}
|
||||
|
||||
const inAmpMode = isInAmpMode(ampState)
|
||||
const 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 (!buildExport && components.getStaticProps && !isDynamic) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (components.getStaticProps && !htmlFilepath.endsWith('.html')) {
|
||||
// make sure it ends with .html if the name contains a dot
|
||||
htmlFilepath += '.html'
|
||||
htmlFilename += '.html'
|
||||
}
|
||||
|
||||
let renderResult: RenderResult | undefined
|
||||
|
||||
if (typeof components.Component === 'string') {
|
||||
renderResult = RenderResult.fromStatic(components.Component)
|
||||
|
||||
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`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* This sets environment variable to be used at the time of static export by head.tsx.
|
||||
* Using this from process.env allows targeting SSR by calling
|
||||
* `process.env.__NEXT_OPTIMIZE_FONTS`.
|
||||
* TODO(prateekbh@): Remove this when experimental.optimizeFonts are being cleaned up.
|
||||
*/
|
||||
if (renderOpts.optimizeFonts) {
|
||||
process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(
|
||||
renderOpts.optimizeFonts
|
||||
)
|
||||
}
|
||||
if (renderOpts.optimizeCss) {
|
||||
process.env.__NEXT_OPTIMIZE_CSS = JSON.stringify(true)
|
||||
}
|
||||
try {
|
||||
renderResult = await render(req, res, page, query, renderOpts)
|
||||
} catch (err: any) {
|
||||
if (err.digest !== NEXT_DYNAMIC_NO_SSR_CODE) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ssgNotFound = renderResult?.metadata.isNotFound
|
||||
|
||||
const ampValidations: AmpValidation[] = []
|
||||
|
||||
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) {
|
||||
ampValidations.push({
|
||||
page: ampPageName,
|
||||
result: {
|
||||
errors,
|
||||
warnings,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const html =
|
||||
renderResult && !renderResult.isNull ? renderResult.toUnchunkedString() : ''
|
||||
|
||||
let ampRenderResult: RenderResult | undefined
|
||||
|
||||
if (inAmpMode && !renderOpts.ampSkipValidation) {
|
||||
if (!ssgNotFound) {
|
||||
await validateAmp(html, path, ampValidatorPath)
|
||||
}
|
||||
} else if (hybridAmp) {
|
||||
const ampHtmlFilename = subFolders
|
||||
? join(ampPath, 'index.html')
|
||||
: `${ampPath}.html`
|
||||
|
||||
const ampBaseDir = join(outDir, dirname(ampHtmlFilename))
|
||||
const ampHtmlFilepath = join(outDir, ampHtmlFilename)
|
||||
|
||||
const exists = await fileExists(ampHtmlFilepath, FileType.File)
|
||||
if (!exists) {
|
||||
try {
|
||||
ampRenderResult = await render(
|
||||
req,
|
||||
res,
|
||||
page,
|
||||
{ ...query, amp: '1' },
|
||||
renderOpts
|
||||
)
|
||||
} catch (err: any) {
|
||||
if (err.digest !== NEXT_DYNAMIC_NO_SSR_CODE) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const ampHtml =
|
||||
ampRenderResult && !ampRenderResult.isNull
|
||||
? ampRenderResult.toUnchunkedString()
|
||||
: ''
|
||||
if (!renderOpts.ampSkipValidation) {
|
||||
await validateAmp(ampHtml, page + '?amp=1')
|
||||
}
|
||||
await fs.mkdir(ampBaseDir, { recursive: true })
|
||||
await fs.writeFile(ampHtmlFilepath, ampHtml, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = renderResult?.metadata || ampRenderResult?.metadata || {}
|
||||
if (metadata.pageData) {
|
||||
const dataFile = join(
|
||||
pagesDataDir,
|
||||
htmlFilename.replace(/\.html$/, '.json')
|
||||
)
|
||||
|
||||
await fs.mkdir(dirname(dataFile), { recursive: true })
|
||||
await fs.writeFile(dataFile, JSON.stringify(metadata.pageData), 'utf8')
|
||||
|
||||
if (hybridAmp) {
|
||||
await fs.writeFile(
|
||||
dataFile.replace(/\.json$/, '.amp.json'),
|
||||
JSON.stringify(metadata.pageData),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ssgNotFound) {
|
||||
// don't attempt writing to disk if getStaticProps returned not found
|
||||
await fs.writeFile(htmlFilepath, html, 'utf8')
|
||||
}
|
||||
|
||||
return {
|
||||
ampValidations,
|
||||
fromBuildExportRevalidate: metadata.revalidate,
|
||||
ssgNotFound,
|
||||
}
|
||||
}
|
30
packages/next/src/export/types.ts
Normal file
30
packages/next/src/export/types.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import type { RenderOptsPartial as AppRenderOptsPartial } from '../server/app-render/types'
|
||||
import type { RenderOptsPartial as PagesRenderOptsPartial } from '../server/render'
|
||||
import type { LoadComponentsReturnType } from '../server/load-components'
|
||||
import type { OutgoingHttpHeaders } from 'http'
|
||||
import type AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
|
||||
|
||||
export interface AmpValidation {
|
||||
page: string
|
||||
result: {
|
||||
errors: AmpHtmlValidator.ValidationError[]
|
||||
warnings: AmpHtmlValidator.ValidationError[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportPageResult {
|
||||
ampValidations?: AmpValidation[]
|
||||
fromBuildExportRevalidate?: number | false
|
||||
fromBuildExportMeta?: {
|
||||
status?: number
|
||||
headers?: OutgoingHttpHeaders
|
||||
}
|
||||
error?: boolean
|
||||
ssgNotFound?: boolean
|
||||
}
|
||||
|
||||
export type WorkerRenderOptsPartial = PagesRenderOptsPartial &
|
||||
AppRenderOptsPartial
|
||||
|
||||
export type WorkerRenderOpts = WorkerRenderOptsPartial &
|
||||
LoadComponentsReturnType
|
File diff suppressed because it is too large
Load diff
|
@ -18,7 +18,6 @@ import type { RequestAsyncStorage } from '../../client/components/request-async-
|
|||
import React from 'react'
|
||||
import { createServerComponentRenderer } from './create-server-components-renderer'
|
||||
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
import { NextParsedUrlQuery } from '../request-meta'
|
||||
import RenderResult, { type RenderResultMetadata } from '../render-result'
|
||||
import {
|
||||
|
@ -156,13 +155,21 @@ function hasLoadingComponentInTree(tree: LoaderTree): boolean {
|
|||
) as boolean
|
||||
}
|
||||
|
||||
export async function renderToHTMLOrFlight(
|
||||
export type AppPageRender = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pagePath: string,
|
||||
query: NextParsedUrlQuery,
|
||||
renderOpts: RenderOpts
|
||||
): Promise<RenderResult> {
|
||||
) => Promise<RenderResult>
|
||||
|
||||
export const renderToHTMLOrFlight: AppPageRender = (
|
||||
req,
|
||||
res,
|
||||
pagePath,
|
||||
query,
|
||||
renderOpts
|
||||
) => {
|
||||
const isFlight = req.headers[RSC.toLowerCase()] !== undefined
|
||||
const pathname = validateURL(req.url)
|
||||
|
||||
|
@ -335,7 +342,7 @@ export async function renderToHTMLOrFlight(
|
|||
/**
|
||||
* Dynamic parameters. E.g. when you visit `/dashboard/vercel` which is rendered by `/dashboard/[slug]` the value will be {"slug": "vercel"}.
|
||||
*/
|
||||
const pathParams = (renderOpts as any).params as ParsedUrlQuery
|
||||
const params = renderOpts.params ?? {}
|
||||
|
||||
/**
|
||||
* Parse the dynamic segment and return the associated value.
|
||||
|
@ -351,7 +358,7 @@ export async function renderToHTMLOrFlight(
|
|||
|
||||
const key = segmentParam.param
|
||||
|
||||
let value = pathParams[key]
|
||||
let value = params[key]
|
||||
|
||||
// this is a special marker that will be present for interception routes
|
||||
if (value === '__NEXT_EMPTY_PARAM__') {
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { ServerRuntime, SizeLimit } from '../../../types'
|
|||
import { NextConfigComplete } from '../../server/config-shared'
|
||||
import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin'
|
||||
import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin'
|
||||
import type { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import zod from 'zod'
|
||||
|
||||
|
@ -149,6 +150,7 @@ export type RenderOptsPartial = {
|
|||
silent?: boolean
|
||||
) => Promise<NextConfigComplete>
|
||||
serverActionsBodySizeLimit?: SizeLimit
|
||||
params?: ParsedUrlQuery
|
||||
}
|
||||
|
||||
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
|
||||
|
|
|
@ -139,7 +139,7 @@ export interface MockedResponseOptions {
|
|||
statusCode?: number
|
||||
socket?: Socket | null
|
||||
headers?: OutgoingHttpHeaders
|
||||
resWriter?: (chunk: Buffer | string) => boolean
|
||||
resWriter?: (chunk: Uint8Array | Buffer | string) => boolean
|
||||
}
|
||||
|
||||
export class MockedResponse extends Stream.Writable implements ServerResponse {
|
||||
|
@ -232,7 +232,7 @@ export class MockedResponse extends Stream.Writable implements ServerResponse {
|
|||
return this.socket
|
||||
}
|
||||
|
||||
public write(chunk: Buffer | string) {
|
||||
public write(chunk: Uint8Array | Buffer | string) {
|
||||
if (this.resWriter) {
|
||||
return this.resWriter(chunk)
|
||||
}
|
||||
|
@ -436,7 +436,7 @@ interface RequestResponseMockerOptions {
|
|||
headers?: IncomingHttpHeaders
|
||||
method?: string
|
||||
bodyReadable?: Stream.Readable
|
||||
resWriter?: (chunk: Buffer | string) => boolean
|
||||
resWriter?: (chunk: Uint8Array | Buffer | string) => boolean
|
||||
socket?: Socket | null
|
||||
}
|
||||
|
||||
|
|
|
@ -6,11 +6,11 @@ export function isAbortError(e: any): e is Error & { name: 'AbortError' } {
|
|||
* This is a minimal implementation of a Writable with just enough
|
||||
* functionality to handle stream cancellation.
|
||||
*/
|
||||
export interface PipeTarget {
|
||||
export interface PipeTarget<R = any> {
|
||||
/**
|
||||
* Called when new data is read from readable source.
|
||||
*/
|
||||
write: (chunk: Uint8Array) => unknown
|
||||
write: (chunk: R) => unknown
|
||||
|
||||
/**
|
||||
* Always called once we read all data (if the writable isn't already
|
||||
|
@ -39,8 +39,8 @@ export interface PipeTarget {
|
|||
}
|
||||
|
||||
export async function pipeReadable(
|
||||
readable: ReadableStream,
|
||||
writable: PipeTarget
|
||||
readable: ReadableStream<Uint8Array>,
|
||||
writable: PipeTarget<Uint8Array>
|
||||
) {
|
||||
const reader = readable.getReader()
|
||||
let readerDone = false
|
||||
|
@ -73,7 +73,7 @@ export async function pipeReadable(
|
|||
}
|
||||
|
||||
if (value) {
|
||||
writable.write(Buffer.from(value))
|
||||
writable.write(value)
|
||||
writable.flush?.()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ export type RenderResultMetadata = {
|
|||
fetchTags?: string
|
||||
}
|
||||
|
||||
type RenderResultResponse = string | ReadableStream<Uint8Array> | null
|
||||
type RenderResultResponse = ReadableStream<Uint8Array> | string | null
|
||||
|
||||
export default class RenderResult {
|
||||
/**
|
||||
|
@ -97,7 +97,7 @@ export default class RenderResult {
|
|||
return this.response
|
||||
}
|
||||
|
||||
public async pipe(res: PipeTarget): Promise<void> {
|
||||
public async pipe(res: PipeTarget<Uint8Array>): Promise<void> {
|
||||
if (this.response === null) {
|
||||
throw new Error('Invariant: response is null. This is a bug in Next.js')
|
||||
}
|
||||
|
|
|
@ -245,7 +245,7 @@ export type RenderOptsPartial = {
|
|||
ampOptimizerConfig?: { [key: string]: any }
|
||||
isDataReq?: boolean
|
||||
params?: ParsedUrlQuery
|
||||
previewProps: __ApiPreviewProps
|
||||
previewProps: __ApiPreviewProps | undefined
|
||||
basePath: string
|
||||
unstable_runtimeJS?: false
|
||||
unstable_JsPreload?: false
|
||||
|
@ -283,6 +283,11 @@ export type RenderOptsPartial = {
|
|||
|
||||
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
|
||||
|
||||
/**
|
||||
* RenderOptsExtra is being used to split away functionality that's within the
|
||||
* renderOpts. Eventually we can have more explicit render options for each
|
||||
* route kind.
|
||||
*/
|
||||
export type RenderOptsExtra = {
|
||||
App: AppType
|
||||
Document: DocumentType
|
||||
|
@ -602,7 +607,8 @@ export async function renderToHTMLImpl(
|
|||
if (
|
||||
(isSSG || getServerSideProps) &&
|
||||
!isFallback &&
|
||||
process.env.NEXT_RUNTIME !== 'edge'
|
||||
process.env.NEXT_RUNTIME !== 'edge' &&
|
||||
previewProps
|
||||
) {
|
||||
// Reads of this are cached on the `req` object, so this should resolve
|
||||
// instantly. There's no need to pass this data down from a previous
|
||||
|
@ -1536,12 +1542,20 @@ export async function renderToHTMLImpl(
|
|||
return new RenderResult(optimizedHtml, renderResultMeta)
|
||||
}
|
||||
|
||||
export async function renderToHTML(
|
||||
export type PagesRender = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathname: string,
|
||||
query: NextParsedUrlQuery,
|
||||
renderOpts: RenderOpts
|
||||
): Promise<RenderResult> {
|
||||
) => Promise<RenderResult>
|
||||
|
||||
export const renderToHTML: PagesRender = (
|
||||
req,
|
||||
res,
|
||||
pathname,
|
||||
query,
|
||||
renderOpts
|
||||
) => {
|
||||
return renderToHTMLImpl(req, res, pathname, query, renderOpts, renderOpts)
|
||||
}
|
||||
|
|
|
@ -22,7 +22,10 @@ function test(context: ReturnType<typeof createContext>) {
|
|||
await waitFor(200)
|
||||
await check(
|
||||
() => stripAnsi(context.output),
|
||||
new RegExp(`The first argument must be of type string`, 'm')
|
||||
new RegExp(
|
||||
`The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received type boolean`,
|
||||
'm'
|
||||
)
|
||||
)
|
||||
expect(stripAnsi(context.output)).not.toContain('webpack-internal:')
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue