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:
Wyatt Johnson 2023-09-20 15:25:21 -06:00 committed by GitHub
parent f0ffa1c79e
commit a88e9953b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 981 additions and 764 deletions

View 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
}

View 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
}

View 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)

View file

@ -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)

View 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 }
}
}

View 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 }
}
}

View 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,
}
}

View 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

View file

@ -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__') {

View file

@ -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

View file

@ -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
}

View file

@ -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?.()
}
}

View file

@ -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')
}

View file

@ -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)
}

View file

@ -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:')
}