99d4d6c5a4
(#31506 for context) This PR implements the minimum viable web server on top of the Next.js base server, and integrates it into our middleware (edge) SSR runtime to handle all the requests. This also addresses problems like missing dynamic routes support in our current handler. Note that this is the initial implementation with the assumption that the web server is running under minimal mode. Also later we can refactor the `__server_context` environment to properly passing the context via the constructor or methods.
1844 lines
55 KiB
TypeScript
1844 lines
55 KiB
TypeScript
import type { __ApiPreviewProps } from './api-utils'
|
|
import type { CustomRoutes } from '../lib/load-custom-routes'
|
|
import type { DomainLocale } from './config'
|
|
import type { DynamicRoutes, PageChecker, Params, Route } from './router'
|
|
import type { FontManifest } from './font-utils'
|
|
import type { LoadComponentsReturnType } from './load-components'
|
|
import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin'
|
|
import type { NextConfig, NextConfigComplete } from './config-shared'
|
|
import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta'
|
|
import type { ParsedUrlQuery } from 'querystring'
|
|
import type { Rewrite } from '../lib/load-custom-routes'
|
|
import type { RenderOpts, RenderOptsPartial } from './render'
|
|
import type { ResponseCacheEntry, ResponseCacheValue } from './response-cache'
|
|
import type { UrlWithParsedQuery } from 'url'
|
|
import type { CacheFs } from '../shared/lib/utils'
|
|
import type { PreviewData } from 'next/types'
|
|
import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
|
|
import type { BaseNextRequest, BaseNextResponse } from './base-http'
|
|
|
|
import { join, resolve } from 'path'
|
|
import { parse as parseQs } from 'querystring'
|
|
import { format as formatUrl, parse as parseUrl } from 'url'
|
|
import { getRedirectStatus } from '../lib/load-custom-routes'
|
|
import {
|
|
SERVERLESS_DIRECTORY,
|
|
SERVER_DIRECTORY,
|
|
STATIC_STATUS_PAGES,
|
|
TEMPORARY_REDIRECT_STATUS,
|
|
} from '../shared/lib/constants'
|
|
import {
|
|
getRouteMatcher,
|
|
getRouteRegex,
|
|
getSortedRoutes,
|
|
isDynamicRoute,
|
|
} from '../shared/lib/router/utils'
|
|
import * as envConfig from '../shared/lib/runtime-config'
|
|
import { DecodeError, normalizeRepeatedSlashes } from '../shared/lib/utils'
|
|
import { setLazyProp, getCookieParser, tryGetPreviewData } from './api-utils'
|
|
import { isTargetLikeServerless } from './utils'
|
|
import Router, { replaceBasePath, route } from './router'
|
|
import { PayloadOptions, setRevalidateHeaders } from './send-payload'
|
|
import { IncrementalCache } from './incremental-cache'
|
|
import { execOnce } from '../shared/lib/utils'
|
|
import { isBlockedPage, isBot } from './utils'
|
|
import RenderResult from './render-result'
|
|
import { removePathTrailingSlash } from '../client/normalize-trailing-slash'
|
|
import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path'
|
|
import { denormalizePagePath } from './denormalize-page-path'
|
|
import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
|
|
import * as Log from '../build/output/log'
|
|
import { detectDomainLocale } from '../shared/lib/i18n/detect-domain-locale'
|
|
import escapePathDelimiters from '../shared/lib/router/utils/escape-path-delimiters'
|
|
import { getUtils } from '../build/webpack/loaders/next-serverless-loader/utils'
|
|
import ResponseCache from './response-cache'
|
|
import { parseNextUrl } from '../shared/lib/router/utils/parse-next-url'
|
|
import isError, { getProperError } from '../lib/is-error'
|
|
import { MIDDLEWARE_ROUTE } from '../lib/constants'
|
|
import { addRequestMeta, getRequestMeta } from './request-meta'
|
|
import { createHeaderRoute, createRedirectRoute } from './server-route-utils'
|
|
import { PrerenderManifest } from '../build'
|
|
|
|
export type FindComponentsResult = {
|
|
components: LoadComponentsReturnType
|
|
query: NextParsedUrlQuery
|
|
}
|
|
|
|
interface RoutingItem {
|
|
page: string
|
|
match: ReturnType<typeof getRouteMatcher>
|
|
ssr?: boolean
|
|
}
|
|
|
|
export interface Options {
|
|
/**
|
|
* Object containing the configuration next.config.js
|
|
*/
|
|
conf: NextConfig
|
|
/**
|
|
* Set to false when the server was created by Next.js
|
|
*/
|
|
customServer?: boolean
|
|
/**
|
|
* Tells if Next.js is running in dev mode
|
|
*/
|
|
dev?: boolean
|
|
/**
|
|
* Where the Next project is located
|
|
*/
|
|
dir?: string
|
|
/**
|
|
* Tells if Next.js is running in a Serverless platform
|
|
*/
|
|
minimalMode?: boolean
|
|
/**
|
|
* Hide error messages containing server information
|
|
*/
|
|
quiet?: boolean
|
|
/**
|
|
* The hostname the server is running behind
|
|
*/
|
|
hostname?: string
|
|
/**
|
|
* The port the server is running behind
|
|
*/
|
|
port?: number
|
|
}
|
|
|
|
export interface BaseRequestHandler {
|
|
(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
parsedUrl?: NextUrlWithParsedQuery | undefined
|
|
): Promise<void>
|
|
}
|
|
|
|
type RequestContext = {
|
|
req: BaseNextRequest
|
|
res: BaseNextResponse
|
|
pathname: string
|
|
query: NextParsedUrlQuery
|
|
renderOpts: RenderOptsPartial
|
|
}
|
|
|
|
export default abstract class Server {
|
|
protected dir: string
|
|
protected quiet: boolean
|
|
protected nextConfig: NextConfigComplete
|
|
protected distDir: string
|
|
protected pagesDir?: string
|
|
protected publicDir: string
|
|
protected hasStaticDir: boolean
|
|
protected pagesManifest?: PagesManifest
|
|
protected buildId: string
|
|
protected minimalMode: boolean
|
|
protected renderOpts: {
|
|
poweredByHeader: boolean
|
|
buildId: string
|
|
generateEtags: boolean
|
|
runtimeConfig?: { [key: string]: any }
|
|
assetPrefix?: string
|
|
canonicalBase: string
|
|
dev?: boolean
|
|
previewProps: __ApiPreviewProps
|
|
customServer?: boolean
|
|
ampOptimizerConfig?: { [key: string]: any }
|
|
basePath: string
|
|
optimizeFonts: boolean
|
|
images: string
|
|
fontManifest?: FontManifest
|
|
optimizeImages: boolean
|
|
disableOptimizedLoading?: boolean
|
|
optimizeCss: any
|
|
locale?: string
|
|
locales?: string[]
|
|
defaultLocale?: string
|
|
domainLocales?: DomainLocale[]
|
|
distDir: string
|
|
concurrentFeatures?: boolean
|
|
serverComponents?: boolean
|
|
crossOrigin?: string
|
|
}
|
|
private incrementalCache: IncrementalCache
|
|
private responseCache: ResponseCache
|
|
protected router: Router
|
|
protected dynamicRoutes?: DynamicRoutes
|
|
protected customRoutes: CustomRoutes
|
|
protected middlewareManifest?: MiddlewareManifest
|
|
protected middleware?: RoutingItem[]
|
|
public readonly hostname?: string
|
|
public readonly port?: number
|
|
|
|
protected abstract getPublicDir(): string
|
|
protected abstract getHasStaticDir(): boolean
|
|
protected abstract getPagesManifest(): PagesManifest | undefined
|
|
protected abstract getBuildId(): string
|
|
protected abstract generatePublicRoutes(): Route[]
|
|
protected abstract generateImageRoutes(): Route[]
|
|
protected abstract generateStaticRotes(): Route[]
|
|
protected abstract generateFsStaticRoutes(): Route[]
|
|
protected abstract generateCatchAllMiddlewareRoute(): Route | undefined
|
|
protected abstract generateRewrites({
|
|
restrictedRedirectPaths,
|
|
}: {
|
|
restrictedRedirectPaths: string[]
|
|
}): {
|
|
beforeFiles: Route[]
|
|
afterFiles: Route[]
|
|
fallback: Route[]
|
|
}
|
|
protected abstract getFilesystemPaths(): Set<string>
|
|
protected abstract getMiddleware(): {
|
|
match: (pathname: string | null | undefined) =>
|
|
| false
|
|
| {
|
|
[paramName: string]: string | string[]
|
|
}
|
|
page: string
|
|
}[]
|
|
protected abstract findPageComponents(
|
|
pathname: string,
|
|
query?: NextParsedUrlQuery,
|
|
params?: Params | null
|
|
): Promise<FindComponentsResult | null>
|
|
protected abstract hasMiddleware(
|
|
pathname: string,
|
|
_isSSR?: boolean
|
|
): Promise<boolean>
|
|
protected abstract getPagePath(pathname: string, locales?: string[]): string
|
|
protected abstract getFontManifest(): FontManifest | undefined
|
|
protected abstract getMiddlewareManifest(): MiddlewareManifest | undefined
|
|
protected abstract getRoutesManifest(): CustomRoutes
|
|
protected abstract getPrerenderManifest(): PrerenderManifest
|
|
|
|
protected abstract sendRenderResult(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
options: {
|
|
result: RenderResult
|
|
type: 'html' | 'json'
|
|
generateEtags: boolean
|
|
poweredByHeader: boolean
|
|
options?: PayloadOptions
|
|
}
|
|
): Promise<void>
|
|
|
|
protected abstract runApi(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
query: ParsedUrlQuery,
|
|
params: Params | boolean,
|
|
page: string,
|
|
builtPagePath: string
|
|
): Promise<boolean>
|
|
|
|
protected abstract renderHTML(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
pathname: string,
|
|
query: NextParsedUrlQuery,
|
|
renderOpts: RenderOpts
|
|
): Promise<RenderResult | null>
|
|
|
|
protected abstract handleCompression(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse
|
|
): void
|
|
|
|
protected abstract loadEnvConfig(params: { dev: boolean }): void
|
|
|
|
public constructor({
|
|
dir = '.',
|
|
quiet = false,
|
|
conf,
|
|
dev = false,
|
|
minimalMode = false,
|
|
customServer = true,
|
|
hostname,
|
|
port,
|
|
}: Options) {
|
|
this.dir = resolve(dir)
|
|
this.quiet = quiet
|
|
this.loadEnvConfig({ dev })
|
|
|
|
// TODO: should conf be normalized to prevent missing
|
|
// values from causing issues as this can be user provided
|
|
this.nextConfig = conf as NextConfigComplete
|
|
this.hostname = hostname
|
|
this.port = port
|
|
this.distDir = join(this.dir, this.nextConfig.distDir)
|
|
this.publicDir = this.getPublicDir()
|
|
this.hasStaticDir = !minimalMode && this.getHasStaticDir()
|
|
|
|
// Only serverRuntimeConfig needs the default
|
|
// publicRuntimeConfig gets it's default in client/index.js
|
|
const {
|
|
serverRuntimeConfig = {},
|
|
publicRuntimeConfig,
|
|
assetPrefix,
|
|
generateEtags,
|
|
} = this.nextConfig
|
|
|
|
this.buildId = this.getBuildId()
|
|
this.minimalMode = minimalMode
|
|
|
|
this.renderOpts = {
|
|
poweredByHeader: this.nextConfig.poweredByHeader,
|
|
canonicalBase: this.nextConfig.amp.canonicalBase || '',
|
|
buildId: this.buildId,
|
|
generateEtags,
|
|
previewProps: this.getPreviewProps(),
|
|
customServer: customServer === true ? true : undefined,
|
|
ampOptimizerConfig: this.nextConfig.experimental.amp?.optimizer,
|
|
basePath: this.nextConfig.basePath,
|
|
images: JSON.stringify(this.nextConfig.images),
|
|
optimizeFonts: !!this.nextConfig.optimizeFonts && !dev,
|
|
fontManifest:
|
|
this.nextConfig.optimizeFonts && !dev
|
|
? this.getFontManifest()
|
|
: undefined,
|
|
optimizeImages: !!this.nextConfig.experimental.optimizeImages,
|
|
optimizeCss: this.nextConfig.experimental.optimizeCss,
|
|
disableOptimizedLoading:
|
|
this.nextConfig.experimental.disableOptimizedLoading,
|
|
domainLocales: this.nextConfig.i18n?.domains,
|
|
distDir: this.distDir,
|
|
concurrentFeatures: this.nextConfig.experimental.concurrentFeatures,
|
|
serverComponents: this.nextConfig.experimental.serverComponents,
|
|
crossOrigin: this.nextConfig.crossOrigin
|
|
? this.nextConfig.crossOrigin
|
|
: undefined,
|
|
}
|
|
|
|
// Only the `publicRuntimeConfig` key is exposed to the client side
|
|
// It'll be rendered as part of __NEXT_DATA__ on the client side
|
|
if (Object.keys(publicRuntimeConfig).length > 0) {
|
|
this.renderOpts.runtimeConfig = publicRuntimeConfig
|
|
}
|
|
|
|
// Initialize next/config with the environment configuration
|
|
envConfig.setConfig({
|
|
serverRuntimeConfig,
|
|
publicRuntimeConfig,
|
|
})
|
|
|
|
this.pagesManifest = this.getPagesManifest()
|
|
this.middlewareManifest = this.getMiddlewareManifest()
|
|
|
|
this.customRoutes = this.getCustomRoutes()
|
|
this.router = new Router(this.generateRoutes())
|
|
this.setAssetPrefix(assetPrefix)
|
|
|
|
this.incrementalCache = new IncrementalCache({
|
|
fs: this.getCacheFilesystem(),
|
|
dev,
|
|
distDir: this.distDir,
|
|
pagesDir: join(
|
|
this.distDir,
|
|
this._isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY,
|
|
'pages'
|
|
),
|
|
locales: this.nextConfig.i18n?.locales,
|
|
max: this.nextConfig.experimental.isrMemoryCacheSize,
|
|
flushToDisk: !minimalMode && this.nextConfig.experimental.isrFlushToDisk,
|
|
getPrerenderManifest: () => {
|
|
if (dev) {
|
|
return {
|
|
version: -1 as any, // letting us know this doesn't conform to spec
|
|
routes: {},
|
|
dynamicRoutes: {},
|
|
notFoundRoutes: [],
|
|
preview: null as any, // `preview` is special case read in next-dev-server
|
|
}
|
|
} else {
|
|
return this.getPrerenderManifest()
|
|
}
|
|
},
|
|
})
|
|
this.responseCache = new ResponseCache(this.incrementalCache)
|
|
}
|
|
|
|
public logError(err: Error): void {
|
|
if (this.quiet) return
|
|
console.error(err)
|
|
}
|
|
|
|
private async handleRequest(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
parsedUrl?: NextUrlWithParsedQuery
|
|
): Promise<void> {
|
|
try {
|
|
const urlParts = (req.url || '').split('?')
|
|
const urlNoQuery = urlParts[0]
|
|
|
|
if (urlNoQuery?.match(/(\\|\/\/)/)) {
|
|
const cleanUrl = normalizeRepeatedSlashes(req.url!)
|
|
res.redirect(cleanUrl, 308).body(cleanUrl).send()
|
|
return
|
|
}
|
|
|
|
setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers))
|
|
|
|
// Parse url if parsedUrl not provided
|
|
if (!parsedUrl || typeof parsedUrl !== 'object') {
|
|
parsedUrl = parseUrl(req.url!, true)
|
|
}
|
|
|
|
// Parse the querystring ourselves if the user doesn't handle querystring parsing
|
|
if (typeof parsedUrl.query === 'string') {
|
|
parsedUrl.query = parseQs(parsedUrl.query)
|
|
}
|
|
|
|
// When there are hostname and port we build an absolute URL
|
|
const initUrl =
|
|
this.hostname && this.port
|
|
? `http://${this.hostname}:${this.port}${req.url}`
|
|
: req.url
|
|
|
|
addRequestMeta(req, '__NEXT_INIT_URL', initUrl)
|
|
addRequestMeta(req, '__NEXT_INIT_QUERY', { ...parsedUrl.query })
|
|
|
|
const url = parseNextUrl({
|
|
headers: req.headers,
|
|
nextConfig: this.nextConfig,
|
|
url: req.url?.replace(/^\/+/, '/'),
|
|
})
|
|
|
|
if (url.basePath) {
|
|
req.url = replaceBasePath(req.url!, this.nextConfig.basePath)
|
|
addRequestMeta(req, '_nextHadBasePath', true)
|
|
}
|
|
|
|
if (
|
|
this.minimalMode &&
|
|
req.headers['x-matched-path'] &&
|
|
typeof req.headers['x-matched-path'] === 'string'
|
|
) {
|
|
const reqUrlIsDataUrl = req.url?.includes('/_next/data')
|
|
const matchedPathIsDataUrl =
|
|
req.headers['x-matched-path']?.includes('/_next/data')
|
|
const isDataUrl = reqUrlIsDataUrl || matchedPathIsDataUrl
|
|
|
|
let parsedPath = parseUrl(
|
|
isDataUrl ? req.url! : (req.headers['x-matched-path'] as string),
|
|
true
|
|
)
|
|
|
|
let matchedPathname = parsedPath.pathname!
|
|
|
|
let matchedPathnameNoExt = isDataUrl
|
|
? matchedPathname.replace(/\.json$/, '')
|
|
: matchedPathname
|
|
|
|
if (this.nextConfig.i18n) {
|
|
const localePathResult = normalizeLocalePath(
|
|
matchedPathname || '/',
|
|
this.nextConfig.i18n.locales
|
|
)
|
|
|
|
if (localePathResult.detectedLocale) {
|
|
parsedUrl.query.__nextLocale = localePathResult.detectedLocale
|
|
}
|
|
}
|
|
|
|
if (isDataUrl) {
|
|
matchedPathname = denormalizePagePath(matchedPathname)
|
|
matchedPathnameNoExt = denormalizePagePath(matchedPathnameNoExt)
|
|
}
|
|
|
|
const pageIsDynamic = isDynamicRoute(matchedPathnameNoExt)
|
|
const combinedRewrites: Rewrite[] = []
|
|
|
|
combinedRewrites.push(...this.customRoutes.rewrites.beforeFiles)
|
|
combinedRewrites.push(...this.customRoutes.rewrites.afterFiles)
|
|
combinedRewrites.push(...this.customRoutes.rewrites.fallback)
|
|
|
|
const utils = getUtils({
|
|
pageIsDynamic,
|
|
page: matchedPathnameNoExt,
|
|
i18n: this.nextConfig.i18n,
|
|
basePath: this.nextConfig.basePath,
|
|
rewrites: combinedRewrites,
|
|
})
|
|
|
|
try {
|
|
// ensure parsedUrl.pathname includes URL before processing
|
|
// rewrites or they won't match correctly
|
|
if (this.nextConfig.i18n && !url.locale?.path.detectedLocale) {
|
|
parsedUrl.pathname = `/${url.locale?.locale}${parsedUrl.pathname}`
|
|
}
|
|
utils.handleRewrites(req, parsedUrl)
|
|
|
|
// interpolate dynamic params and normalize URL if needed
|
|
if (pageIsDynamic) {
|
|
let params: ParsedUrlQuery | false = {}
|
|
|
|
Object.assign(parsedUrl.query, parsedPath.query)
|
|
const paramsResult = utils.normalizeDynamicRouteParams(
|
|
parsedUrl.query
|
|
)
|
|
|
|
if (paramsResult.hasValidParams) {
|
|
params = paramsResult.params
|
|
} else if (req.headers['x-now-route-matches']) {
|
|
const opts: Record<string, string> = {}
|
|
params = utils.getParamsFromRouteMatches(
|
|
req,
|
|
opts,
|
|
parsedUrl.query.__nextLocale || ''
|
|
)
|
|
|
|
if (opts.locale) {
|
|
parsedUrl.query.__nextLocale = opts.locale
|
|
}
|
|
} else {
|
|
params = utils.dynamicRouteMatcher!(matchedPathnameNoExt)
|
|
}
|
|
|
|
if (params) {
|
|
if (!paramsResult.hasValidParams) {
|
|
params = utils.normalizeDynamicRouteParams(params).params
|
|
}
|
|
|
|
matchedPathname = utils.interpolateDynamicPath(
|
|
matchedPathname,
|
|
params
|
|
)
|
|
req.url = utils.interpolateDynamicPath(req.url!, params)
|
|
}
|
|
|
|
if (reqUrlIsDataUrl && matchedPathIsDataUrl) {
|
|
req.url = formatUrl({
|
|
...parsedPath,
|
|
pathname: matchedPathname,
|
|
})
|
|
}
|
|
|
|
Object.assign(parsedUrl.query, params)
|
|
utils.normalizeVercelUrl(req, true)
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DecodeError) {
|
|
res.statusCode = 400
|
|
return this.renderError(null, req, res, '/_error', {})
|
|
}
|
|
throw err
|
|
}
|
|
|
|
parsedUrl.pathname = `${this.nextConfig.basePath || ''}${
|
|
matchedPathname === '/' && this.nextConfig.basePath
|
|
? ''
|
|
: matchedPathname
|
|
}`
|
|
url.pathname = parsedUrl.pathname
|
|
}
|
|
|
|
addRequestMeta(req, '__nextHadTrailingSlash', url.locale?.trailingSlash)
|
|
if (url.locale?.domain) {
|
|
addRequestMeta(req, '__nextIsLocaleDomain', true)
|
|
}
|
|
|
|
if (url.locale?.path.detectedLocale) {
|
|
req.url = formatUrl(url)
|
|
addRequestMeta(req, '__nextStrippedLocale', true)
|
|
if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
|
|
return this.render404(req, res, parsedUrl)
|
|
}
|
|
}
|
|
|
|
if (!this.minimalMode || !parsedUrl.query.__nextLocale) {
|
|
if (url?.locale?.locale) {
|
|
parsedUrl.query.__nextLocale = url.locale.locale
|
|
}
|
|
}
|
|
|
|
if (url?.locale?.defaultLocale) {
|
|
parsedUrl.query.__nextDefaultLocale = url.locale.defaultLocale
|
|
}
|
|
|
|
if (url.locale?.redirect) {
|
|
res
|
|
.redirect(url.locale.redirect, TEMPORARY_REDIRECT_STATUS)
|
|
.body(url.locale.redirect)
|
|
.send()
|
|
return
|
|
}
|
|
|
|
res.statusCode = 200
|
|
return await this.run(req, res, parsedUrl)
|
|
} catch (err: any) {
|
|
if (
|
|
(err && typeof err === 'object' && err.code === 'ERR_INVALID_URL') ||
|
|
err instanceof DecodeError
|
|
) {
|
|
res.statusCode = 400
|
|
return this.renderError(null, req, res, '/_error', {})
|
|
}
|
|
|
|
if (this.minimalMode || this.renderOpts.dev) {
|
|
throw err
|
|
}
|
|
this.logError(getProperError(err))
|
|
res.statusCode = 500
|
|
res.body('Internal Server Error').send()
|
|
}
|
|
}
|
|
|
|
public getRequestHandler(): BaseRequestHandler {
|
|
return this.handleRequest.bind(this)
|
|
}
|
|
|
|
public setAssetPrefix(prefix?: string): void {
|
|
this.renderOpts.assetPrefix = prefix ? prefix.replace(/\/$/, '') : ''
|
|
}
|
|
|
|
// Backwards compatibility
|
|
public async prepare(): Promise<void> {}
|
|
|
|
// Backwards compatibility
|
|
protected async close(): Promise<void> {}
|
|
|
|
protected getCustomRoutes(): CustomRoutes {
|
|
const customRoutes = this.getRoutesManifest()
|
|
let rewrites: CustomRoutes['rewrites']
|
|
|
|
// rewrites can be stored as an array when an array is
|
|
// returned in next.config.js so massage them into
|
|
// the expected object format
|
|
if (Array.isArray(customRoutes.rewrites)) {
|
|
rewrites = {
|
|
beforeFiles: [],
|
|
afterFiles: customRoutes.rewrites,
|
|
fallback: [],
|
|
}
|
|
} else {
|
|
rewrites = customRoutes.rewrites
|
|
}
|
|
return Object.assign(customRoutes, { rewrites })
|
|
}
|
|
|
|
protected getPreviewProps(): __ApiPreviewProps {
|
|
return this.getPrerenderManifest().preview
|
|
}
|
|
|
|
protected async ensureMiddleware(_pathname: string, _isSSR?: boolean) {}
|
|
|
|
protected generateRoutes(): {
|
|
basePath: string
|
|
headers: Route[]
|
|
rewrites: {
|
|
beforeFiles: Route[]
|
|
afterFiles: Route[]
|
|
fallback: Route[]
|
|
}
|
|
fsRoutes: Route[]
|
|
redirects: Route[]
|
|
catchAllRoute: Route
|
|
catchAllMiddleware?: Route
|
|
pageChecker: PageChecker
|
|
useFileSystemPublicRoutes: boolean
|
|
dynamicRoutes: DynamicRoutes | undefined
|
|
locales: string[]
|
|
} {
|
|
const publicRoutes = this.generatePublicRoutes()
|
|
const imageRoutes = this.generateImageRoutes()
|
|
const staticFilesRoutes = this.generateStaticRotes()
|
|
|
|
const fsRoutes: Route[] = [
|
|
...this.generateFsStaticRoutes(),
|
|
{
|
|
match: route('/_next/data/:path*'),
|
|
type: 'route',
|
|
name: '_next/data catchall',
|
|
fn: async (req, res, params, _parsedUrl) => {
|
|
// Make sure to 404 for /_next/data/ itself and
|
|
// we also want to 404 if the buildId isn't correct
|
|
if (!params.path || params.path[0] !== this.buildId) {
|
|
await this.render404(req, res, _parsedUrl)
|
|
return {
|
|
finished: true,
|
|
}
|
|
}
|
|
// remove buildId from URL
|
|
params.path.shift()
|
|
|
|
const lastParam = params.path[params.path.length - 1]
|
|
|
|
// show 404 if it doesn't end with .json
|
|
if (typeof lastParam !== 'string' || !lastParam.endsWith('.json')) {
|
|
await this.render404(req, res, _parsedUrl)
|
|
return {
|
|
finished: true,
|
|
}
|
|
}
|
|
|
|
// re-create page's pathname
|
|
let pathname = `/${params.path.join('/')}`
|
|
pathname = getRouteFromAssetPath(pathname, '.json')
|
|
|
|
if (this.nextConfig.i18n) {
|
|
const { host } = req?.headers || {}
|
|
// remove port from host and remove port if present
|
|
const hostname = host?.split(':')[0].toLowerCase()
|
|
const localePathResult = normalizeLocalePath(
|
|
pathname,
|
|
this.nextConfig.i18n.locales
|
|
)
|
|
const { defaultLocale } =
|
|
detectDomainLocale(this.nextConfig.i18n.domains, hostname) || {}
|
|
|
|
let detectedLocale = ''
|
|
|
|
if (localePathResult.detectedLocale) {
|
|
pathname = localePathResult.pathname
|
|
detectedLocale = localePathResult.detectedLocale
|
|
}
|
|
|
|
_parsedUrl.query.__nextLocale = detectedLocale
|
|
_parsedUrl.query.__nextDefaultLocale =
|
|
defaultLocale || this.nextConfig.i18n.defaultLocale
|
|
|
|
if (!detectedLocale) {
|
|
_parsedUrl.query.__nextLocale =
|
|
_parsedUrl.query.__nextDefaultLocale
|
|
await this.render404(req, res, _parsedUrl)
|
|
return { finished: true }
|
|
}
|
|
}
|
|
|
|
const parsedUrl = parseUrl(pathname, true)
|
|
|
|
await this.render(
|
|
req,
|
|
res,
|
|
pathname,
|
|
{ ..._parsedUrl.query, _nextDataReq: '1' },
|
|
parsedUrl,
|
|
true
|
|
)
|
|
return {
|
|
finished: true,
|
|
}
|
|
},
|
|
},
|
|
...imageRoutes,
|
|
{
|
|
match: route('/_next/:path*'),
|
|
type: 'route',
|
|
name: '_next catchall',
|
|
// This path is needed because `render()` does a check for `/_next` and the calls the routing again
|
|
fn: async (req, res, _params, parsedUrl) => {
|
|
await this.render404(req, res, parsedUrl)
|
|
return {
|
|
finished: true,
|
|
}
|
|
},
|
|
},
|
|
...publicRoutes,
|
|
...staticFilesRoutes,
|
|
]
|
|
|
|
const restrictedRedirectPaths = this.nextConfig.basePath
|
|
? [`${this.nextConfig.basePath}/_next`]
|
|
: ['/_next']
|
|
|
|
// Headers come very first
|
|
const headers = this.minimalMode
|
|
? []
|
|
: this.customRoutes.headers.map((rule) =>
|
|
createHeaderRoute({ rule, restrictedRedirectPaths })
|
|
)
|
|
|
|
const redirects = this.minimalMode
|
|
? []
|
|
: this.customRoutes.redirects.map((rule) =>
|
|
createRedirectRoute({ rule, restrictedRedirectPaths })
|
|
)
|
|
|
|
const rewrites = this.generateRewrites({ restrictedRedirectPaths })
|
|
const catchAllMiddleware = this.generateCatchAllMiddlewareRoute()
|
|
|
|
const catchAllRoute: Route = {
|
|
match: route('/:path*'),
|
|
type: 'route',
|
|
name: 'Catchall render',
|
|
fn: async (req, res, _params, parsedUrl) => {
|
|
let { pathname, query } = parsedUrl
|
|
if (!pathname) {
|
|
throw new Error('pathname is undefined')
|
|
}
|
|
|
|
// next.js core assumes page path without trailing slash
|
|
pathname = removePathTrailingSlash(pathname)
|
|
|
|
if (this.nextConfig.i18n) {
|
|
const localePathResult = normalizeLocalePath(
|
|
pathname,
|
|
this.nextConfig.i18n?.locales
|
|
)
|
|
|
|
if (localePathResult.detectedLocale) {
|
|
pathname = localePathResult.pathname
|
|
parsedUrl.query.__nextLocale = localePathResult.detectedLocale
|
|
}
|
|
}
|
|
const bubbleNoFallback = !!query._nextBubbleNoFallback
|
|
|
|
if (pathname.match(MIDDLEWARE_ROUTE)) {
|
|
await this.render404(req, res, parsedUrl)
|
|
return {
|
|
finished: true,
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
delete query._nextBubbleNoFallback
|
|
|
|
const handled = await this.handleApiRequest(req, res, pathname, query)
|
|
if (handled) {
|
|
return { finished: true }
|
|
}
|
|
}
|
|
|
|
try {
|
|
await this.render(req, res, pathname, query, parsedUrl, true)
|
|
|
|
return {
|
|
finished: true,
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof NoFallbackError && bubbleNoFallback) {
|
|
return {
|
|
finished: false,
|
|
}
|
|
}
|
|
throw err
|
|
}
|
|
},
|
|
}
|
|
|
|
const { useFileSystemPublicRoutes } = this.nextConfig
|
|
|
|
if (useFileSystemPublicRoutes) {
|
|
this.dynamicRoutes = this.getDynamicRoutes()
|
|
if (!this.minimalMode) {
|
|
this.middleware = this.getMiddleware()
|
|
}
|
|
}
|
|
|
|
return {
|
|
headers,
|
|
fsRoutes,
|
|
rewrites,
|
|
redirects,
|
|
catchAllRoute,
|
|
catchAllMiddleware,
|
|
useFileSystemPublicRoutes,
|
|
dynamicRoutes: this.dynamicRoutes,
|
|
basePath: this.nextConfig.basePath,
|
|
pageChecker: this.hasPage.bind(this),
|
|
locales: this.nextConfig.i18n?.locales || [],
|
|
}
|
|
}
|
|
|
|
protected async hasPage(pathname: string): Promise<boolean> {
|
|
let found = false
|
|
try {
|
|
found = !!this.getPagePath(pathname, this.nextConfig.i18n?.locales)
|
|
} catch (_) {}
|
|
|
|
return found
|
|
}
|
|
|
|
protected async _beforeCatchAllRender(
|
|
_req: BaseNextRequest,
|
|
_res: BaseNextResponse,
|
|
_params: Params,
|
|
_parsedUrl: UrlWithParsedQuery
|
|
): Promise<boolean> {
|
|
return false
|
|
}
|
|
|
|
// Used to build API page in development
|
|
protected async ensureApiPage(_pathname: string): Promise<void> {}
|
|
|
|
/**
|
|
* Resolves `API` request, in development builds on demand
|
|
* @param req http request
|
|
* @param res http response
|
|
* @param pathname path of request
|
|
*/
|
|
private async handleApiRequest(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
pathname: string,
|
|
query: ParsedUrlQuery
|
|
): Promise<boolean> {
|
|
let page = pathname
|
|
let params: Params | false = false
|
|
let pageFound = await this.hasPage(page)
|
|
|
|
if (!pageFound && this.dynamicRoutes) {
|
|
for (const dynamicRoute of this.dynamicRoutes) {
|
|
params = dynamicRoute.match(pathname)
|
|
if (dynamicRoute.page.startsWith('/api') && params) {
|
|
page = dynamicRoute.page
|
|
pageFound = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!pageFound) {
|
|
return false
|
|
}
|
|
// Make sure the page is built before getting the path
|
|
// or else it won't be in the manifest yet
|
|
await this.ensureApiPage(page)
|
|
|
|
let builtPagePath
|
|
try {
|
|
builtPagePath = this.getPagePath(page)
|
|
} catch (err) {
|
|
if (isError(err) && err.code === 'ENOENT') {
|
|
return false
|
|
}
|
|
throw err
|
|
}
|
|
|
|
return this.runApi(req, res, query, params, page, builtPagePath)
|
|
}
|
|
|
|
protected getDynamicRoutes(): Array<RoutingItem> {
|
|
const addedPages = new Set<string>()
|
|
|
|
return getSortedRoutes(
|
|
Object.keys(this.pagesManifest!).map(
|
|
(page) =>
|
|
normalizeLocalePath(page, this.nextConfig.i18n?.locales).pathname
|
|
)
|
|
)
|
|
.map((page) => {
|
|
if (addedPages.has(page) || !isDynamicRoute(page)) return null
|
|
addedPages.add(page)
|
|
return {
|
|
page,
|
|
match: getRouteMatcher(getRouteRegex(page)),
|
|
}
|
|
})
|
|
.filter((item): item is RoutingItem => Boolean(item))
|
|
}
|
|
|
|
protected async run(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
parsedUrl: UrlWithParsedQuery
|
|
): Promise<void> {
|
|
this.handleCompression(req, res)
|
|
|
|
try {
|
|
const matched = await this.router.execute(req, res, parsedUrl)
|
|
if (matched) {
|
|
return
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DecodeError) {
|
|
res.statusCode = 400
|
|
return this.renderError(null, req, res, '/_error', {})
|
|
}
|
|
throw err
|
|
}
|
|
|
|
await this.render404(req, res, parsedUrl)
|
|
}
|
|
|
|
private async pipe(
|
|
fn: (ctx: RequestContext) => Promise<ResponsePayload | null>,
|
|
partialContext: {
|
|
req: BaseNextRequest
|
|
res: BaseNextResponse
|
|
pathname: string
|
|
query: NextParsedUrlQuery
|
|
}
|
|
): Promise<void> {
|
|
const userAgent = partialContext.req.headers['user-agent']
|
|
const ctx = {
|
|
...partialContext,
|
|
renderOpts: {
|
|
...this.renderOpts,
|
|
supportsDynamicHTML: userAgent ? !isBot(userAgent) : false,
|
|
},
|
|
} as const
|
|
const payload = await fn(ctx)
|
|
if (payload === null) {
|
|
return
|
|
}
|
|
const { req, res } = ctx
|
|
const { body, type, revalidateOptions } = payload
|
|
if (!res.sent) {
|
|
const { generateEtags, poweredByHeader, dev } = this.renderOpts
|
|
if (dev) {
|
|
// In dev, we should not cache pages for any reason.
|
|
res.setHeader('Cache-Control', 'no-store, must-revalidate')
|
|
}
|
|
return this.sendRenderResult(req, res, {
|
|
result: body,
|
|
type,
|
|
generateEtags,
|
|
poweredByHeader,
|
|
options: revalidateOptions,
|
|
})
|
|
}
|
|
}
|
|
|
|
private async getStaticHTML(
|
|
fn: (ctx: RequestContext) => Promise<ResponsePayload | null>,
|
|
partialContext: {
|
|
req: BaseNextRequest
|
|
res: BaseNextResponse
|
|
pathname: string
|
|
query: ParsedUrlQuery
|
|
}
|
|
): Promise<string | null> {
|
|
const payload = await fn({
|
|
...partialContext,
|
|
renderOpts: {
|
|
...this.renderOpts,
|
|
supportsDynamicHTML: false,
|
|
},
|
|
})
|
|
if (payload === null) {
|
|
return null
|
|
}
|
|
return payload.body.toUnchunkedString()
|
|
}
|
|
|
|
public async render(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
pathname: string,
|
|
query: NextParsedUrlQuery = {},
|
|
parsedUrl?: NextUrlWithParsedQuery,
|
|
internalRender = false
|
|
): Promise<void> {
|
|
if (!pathname.startsWith('/')) {
|
|
console.warn(
|
|
`Cannot render page with path "${pathname}", did you mean "/${pathname}"?. See more info here: https://nextjs.org/docs/messages/render-no-starting-slash`
|
|
)
|
|
}
|
|
|
|
if (
|
|
this.renderOpts.customServer &&
|
|
pathname === '/index' &&
|
|
!(await this.hasPage('/index'))
|
|
) {
|
|
// maintain backwards compatibility for custom server
|
|
// (see custom-server integration tests)
|
|
pathname = '/'
|
|
}
|
|
|
|
// we allow custom servers to call render for all URLs
|
|
// so check if we need to serve a static _next file or not.
|
|
// we don't modify the URL for _next/data request but still
|
|
// call render so we special case this to prevent an infinite loop
|
|
if (
|
|
!internalRender &&
|
|
!this.minimalMode &&
|
|
!query._nextDataReq &&
|
|
(req.url?.match(/^\/_next\//) ||
|
|
(this.hasStaticDir && req.url!.match(/^\/static\//)))
|
|
) {
|
|
return this.handleRequest(req, res, parsedUrl)
|
|
}
|
|
|
|
// Custom server users can run `app.render()` which needs compression.
|
|
if (this.renderOpts.customServer) {
|
|
this.handleCompression(req, res)
|
|
}
|
|
|
|
if (isBlockedPage(pathname)) {
|
|
return this.render404(req, res, parsedUrl)
|
|
}
|
|
|
|
return this.pipe((ctx) => this.renderToResponse(ctx), {
|
|
req,
|
|
res,
|
|
pathname,
|
|
query,
|
|
})
|
|
}
|
|
|
|
protected async getStaticPaths(pathname: string): Promise<{
|
|
staticPaths: string[] | undefined
|
|
fallbackMode: 'static' | 'blocking' | false
|
|
}> {
|
|
// `staticPaths` is intentionally set to `undefined` as it should've
|
|
// been caught when checking disk data.
|
|
const staticPaths = undefined
|
|
|
|
// Read whether or not fallback should exist from the manifest.
|
|
const fallbackField =
|
|
this.getPrerenderManifest().dynamicRoutes[pathname].fallback
|
|
|
|
return {
|
|
staticPaths,
|
|
fallbackMode:
|
|
typeof fallbackField === 'string'
|
|
? 'static'
|
|
: fallbackField === null
|
|
? 'blocking'
|
|
: false,
|
|
}
|
|
}
|
|
|
|
private async renderToResponseWithComponents(
|
|
{ req, res, pathname, renderOpts: opts }: RequestContext,
|
|
{ components, query }: FindComponentsResult
|
|
): Promise<ResponsePayload | null> {
|
|
const is404Page = pathname === '/404'
|
|
const is500Page = pathname === '/500'
|
|
|
|
const isLikeServerless =
|
|
typeof components.ComponentMod === 'object' &&
|
|
typeof (components.ComponentMod as any).renderReqToHTML === 'function'
|
|
const isSSG = !!components.getStaticProps
|
|
const hasServerProps = !!components.getServerSideProps
|
|
const hasStaticPaths = !!components.getStaticPaths
|
|
const hasGetInitialProps = !!(components.Component as any).getInitialProps
|
|
|
|
// Toggle whether or not this is a Data request
|
|
const isDataReq = !!query._nextDataReq && (isSSG || hasServerProps)
|
|
delete query._nextDataReq
|
|
|
|
// we need to ensure the status code if /404 is visited directly
|
|
if (is404Page && !isDataReq) {
|
|
res.statusCode = 404
|
|
}
|
|
|
|
// ensure correct status is set when visiting a status page
|
|
// directly e.g. /500
|
|
if (STATIC_STATUS_PAGES.includes(pathname)) {
|
|
res.statusCode = parseInt(pathname.substr(1), 10)
|
|
}
|
|
|
|
// handle static page
|
|
if (typeof components.Component === 'string') {
|
|
return {
|
|
type: 'html',
|
|
// TODO: Static pages should be serialized as RenderResult
|
|
body: RenderResult.fromStatic(components.Component),
|
|
}
|
|
}
|
|
|
|
if (!query.amp) {
|
|
delete query.amp
|
|
}
|
|
|
|
if (opts.supportsDynamicHTML === true) {
|
|
// Disable dynamic HTML in cases that we know it won't be generated,
|
|
// so that we can continue generating a cache key when possible.
|
|
opts.supportsDynamicHTML =
|
|
!isSSG &&
|
|
!isLikeServerless &&
|
|
!query.amp &&
|
|
!this.minimalMode &&
|
|
typeof components.Document?.getInitialProps !== 'function'
|
|
}
|
|
|
|
const defaultLocale = isSSG
|
|
? this.nextConfig.i18n?.defaultLocale
|
|
: query.__nextDefaultLocale
|
|
|
|
const locale = query.__nextLocale
|
|
const locales = this.nextConfig.i18n?.locales
|
|
|
|
let previewData: PreviewData
|
|
let isPreviewMode = false
|
|
|
|
if (hasServerProps || isSSG) {
|
|
previewData = tryGetPreviewData(req, res, this.renderOpts.previewProps)
|
|
isPreviewMode = previewData !== false
|
|
}
|
|
|
|
// Compute the iSSG cache key. We use the rewroteUrl since
|
|
// pages with fallback: false are allowed to be rewritten to
|
|
// and we need to look up the path by the rewritten path
|
|
let urlPathname = parseUrl(req.url || '').pathname || '/'
|
|
|
|
let resolvedUrlPathname =
|
|
getRequestMeta(req, '_nextRewroteUrl') || urlPathname
|
|
|
|
urlPathname = removePathTrailingSlash(urlPathname)
|
|
resolvedUrlPathname = normalizeLocalePath(
|
|
removePathTrailingSlash(resolvedUrlPathname),
|
|
this.nextConfig.i18n?.locales
|
|
).pathname
|
|
|
|
const stripNextDataPath = (path: string) => {
|
|
if (path.includes(this.buildId)) {
|
|
const splitPath = path.substring(
|
|
path.indexOf(this.buildId) + this.buildId.length
|
|
)
|
|
|
|
path = denormalizePagePath(splitPath.replace(/\.json$/, ''))
|
|
}
|
|
|
|
if (this.nextConfig.i18n) {
|
|
return normalizeLocalePath(path, locales).pathname
|
|
}
|
|
return path
|
|
}
|
|
|
|
const handleRedirect = (pageData: any) => {
|
|
const redirect = {
|
|
destination: pageData.pageProps.__N_REDIRECT,
|
|
statusCode: pageData.pageProps.__N_REDIRECT_STATUS,
|
|
basePath: pageData.pageProps.__N_REDIRECT_BASE_PATH,
|
|
}
|
|
const statusCode = getRedirectStatus(redirect)
|
|
const { basePath } = this.nextConfig
|
|
|
|
if (
|
|
basePath &&
|
|
redirect.basePath !== false &&
|
|
redirect.destination.startsWith('/')
|
|
) {
|
|
redirect.destination = `${basePath}${redirect.destination}`
|
|
}
|
|
|
|
if (redirect.destination.startsWith('/')) {
|
|
redirect.destination = normalizeRepeatedSlashes(redirect.destination)
|
|
}
|
|
|
|
res
|
|
.redirect(redirect.destination, statusCode)
|
|
.body(redirect.destination)
|
|
.send()
|
|
}
|
|
|
|
// remove /_next/data prefix from urlPathname so it matches
|
|
// for direct page visit and /_next/data visit
|
|
if (isDataReq) {
|
|
resolvedUrlPathname = stripNextDataPath(resolvedUrlPathname)
|
|
urlPathname = stripNextDataPath(urlPathname)
|
|
}
|
|
|
|
let ssgCacheKey =
|
|
isPreviewMode || !isSSG || this.minimalMode || opts.supportsDynamicHTML
|
|
? null // Preview mode bypasses the cache
|
|
: `${locale ? `/${locale}` : ''}${
|
|
(pathname === '/' || resolvedUrlPathname === '/') && locale
|
|
? ''
|
|
: resolvedUrlPathname
|
|
}${query.amp ? '.amp' : ''}`
|
|
|
|
if ((is404Page || is500Page) && isSSG) {
|
|
ssgCacheKey = `${locale ? `/${locale}` : ''}${pathname}${
|
|
query.amp ? '.amp' : ''
|
|
}`
|
|
}
|
|
|
|
if (ssgCacheKey) {
|
|
// we only encode path delimiters for path segments from
|
|
// getStaticPaths so we need to attempt decoding the URL
|
|
// to match against and only escape the path delimiters
|
|
// this allows non-ascii values to be handled e.g. Japanese characters
|
|
|
|
// TODO: investigate adding this handling for non-SSG pages so
|
|
// non-ascii names work there also
|
|
ssgCacheKey = ssgCacheKey
|
|
.split('/')
|
|
.map((seg) => {
|
|
try {
|
|
seg = escapePathDelimiters(decodeURIComponent(seg), true)
|
|
} catch (_) {
|
|
// An improperly encoded URL was provided
|
|
throw new DecodeError('failed to decode param')
|
|
}
|
|
return seg
|
|
})
|
|
.join('/')
|
|
}
|
|
|
|
const doRender: () => Promise<ResponseCacheEntry | null> = async () => {
|
|
let pageData: any
|
|
let body: RenderResult | null
|
|
let sprRevalidate: number | false
|
|
let isNotFound: boolean | undefined
|
|
let isRedirect: boolean | undefined
|
|
|
|
// handle serverless
|
|
if (isLikeServerless) {
|
|
const renderResult = await (
|
|
components.ComponentMod as any
|
|
).renderReqToHTML(req, res, 'passthrough', {
|
|
locale,
|
|
locales,
|
|
defaultLocale,
|
|
optimizeCss: this.renderOpts.optimizeCss,
|
|
distDir: this.distDir,
|
|
fontManifest: this.renderOpts.fontManifest,
|
|
domainLocales: this.renderOpts.domainLocales,
|
|
})
|
|
|
|
body = renderResult.html
|
|
pageData = renderResult.renderOpts.pageData
|
|
sprRevalidate = renderResult.renderOpts.revalidate
|
|
isNotFound = renderResult.renderOpts.isNotFound
|
|
isRedirect = renderResult.renderOpts.isRedirect
|
|
} else {
|
|
const origQuery = parseUrl(req.url || '', true).query
|
|
const hadTrailingSlash =
|
|
urlPathname !== '/' && this.nextConfig.trailingSlash
|
|
|
|
const resolvedUrl = formatUrl({
|
|
pathname: `${resolvedUrlPathname}${hadTrailingSlash ? '/' : ''}`,
|
|
// make sure to only add query values from original URL
|
|
query: origQuery,
|
|
})
|
|
|
|
const renderOpts: RenderOpts = {
|
|
...components,
|
|
...opts,
|
|
isDataReq,
|
|
resolvedUrl,
|
|
locale,
|
|
locales,
|
|
defaultLocale,
|
|
// For getServerSideProps and getInitialProps we need to ensure we use the original URL
|
|
// and not the resolved URL to prevent a hydration mismatch on
|
|
// asPath
|
|
resolvedAsPath:
|
|
hasServerProps || hasGetInitialProps
|
|
? formatUrl({
|
|
// we use the original URL pathname less the _next/data prefix if
|
|
// present
|
|
pathname: `${urlPathname}${hadTrailingSlash ? '/' : ''}`,
|
|
query: origQuery,
|
|
})
|
|
: resolvedUrl,
|
|
}
|
|
|
|
const renderResult = await this.renderHTML(
|
|
req,
|
|
res,
|
|
pathname,
|
|
query,
|
|
renderOpts
|
|
)
|
|
|
|
body = renderResult
|
|
// TODO: change this to a different passing mechanism
|
|
pageData = (renderOpts as any).pageData
|
|
sprRevalidate = (renderOpts as any).revalidate
|
|
isNotFound = (renderOpts as any).isNotFound
|
|
isRedirect = (renderOpts as any).isRedirect
|
|
}
|
|
|
|
let value: ResponseCacheValue | null
|
|
if (isNotFound) {
|
|
value = null
|
|
} else if (isRedirect) {
|
|
value = { kind: 'REDIRECT', props: pageData }
|
|
} else {
|
|
if (!body) {
|
|
return null
|
|
}
|
|
value = { kind: 'PAGE', html: body, pageData }
|
|
}
|
|
return { revalidate: sprRevalidate, value }
|
|
}
|
|
|
|
const cacheEntry = await this.responseCache.get(
|
|
ssgCacheKey,
|
|
async (hasResolved) => {
|
|
const isProduction = !this.renderOpts.dev
|
|
const isDynamicPathname = isDynamicRoute(pathname)
|
|
const didRespond = hasResolved || res.sent
|
|
|
|
let { staticPaths, fallbackMode } = hasStaticPaths
|
|
? await this.getStaticPaths(pathname)
|
|
: { staticPaths: undefined, fallbackMode: false }
|
|
|
|
if (
|
|
fallbackMode === 'static' &&
|
|
isBot(req.headers['user-agent'] || '')
|
|
) {
|
|
fallbackMode = 'blocking'
|
|
}
|
|
|
|
// When we did not respond from cache, we need to choose to block on
|
|
// rendering or return a skeleton.
|
|
//
|
|
// * Data requests always block.
|
|
//
|
|
// * Blocking mode fallback always blocks.
|
|
//
|
|
// * Preview mode toggles all pages to be resolved in a blocking manner.
|
|
//
|
|
// * Non-dynamic pages should block (though this is an impossible
|
|
// case in production).
|
|
//
|
|
// * Dynamic pages should return their skeleton if not defined in
|
|
// getStaticPaths, then finish the data request on the client-side.
|
|
//
|
|
if (
|
|
this.minimalMode !== true &&
|
|
fallbackMode !== 'blocking' &&
|
|
ssgCacheKey &&
|
|
!didRespond &&
|
|
!isPreviewMode &&
|
|
isDynamicPathname &&
|
|
// Development should trigger fallback when the path is not in
|
|
// `getStaticPaths`
|
|
(isProduction ||
|
|
!staticPaths ||
|
|
!staticPaths.includes(
|
|
// we use ssgCacheKey here as it is normalized to match the
|
|
// encoding from getStaticPaths along with including the locale
|
|
query.amp ? ssgCacheKey.replace(/\.amp$/, '') : ssgCacheKey
|
|
))
|
|
) {
|
|
if (
|
|
// In development, fall through to render to handle missing
|
|
// getStaticPaths.
|
|
(isProduction || staticPaths) &&
|
|
// When fallback isn't present, abort this render so we 404
|
|
fallbackMode !== 'static'
|
|
) {
|
|
throw new NoFallbackError()
|
|
}
|
|
|
|
if (!isDataReq) {
|
|
// Production already emitted the fallback as static HTML.
|
|
if (isProduction) {
|
|
const html = await this.incrementalCache.getFallback(
|
|
locale ? `/${locale}${pathname}` : pathname
|
|
)
|
|
return {
|
|
value: {
|
|
kind: 'PAGE',
|
|
html: RenderResult.fromStatic(html),
|
|
pageData: {},
|
|
},
|
|
}
|
|
}
|
|
// We need to generate the fallback on-demand for development.
|
|
else {
|
|
query.__nextFallback = 'true'
|
|
if (isLikeServerless) {
|
|
prepareServerlessUrl(req, query)
|
|
}
|
|
const result = await doRender()
|
|
if (!result) {
|
|
return null
|
|
}
|
|
// Prevent caching this result
|
|
delete result.revalidate
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = await doRender()
|
|
if (!result) {
|
|
return null
|
|
}
|
|
return {
|
|
...result,
|
|
revalidate:
|
|
result.revalidate !== undefined
|
|
? result.revalidate
|
|
: /* default to minimum revalidate (this should be an invariant) */ 1,
|
|
}
|
|
}
|
|
)
|
|
|
|
if (!cacheEntry) {
|
|
if (ssgCacheKey) {
|
|
// A cache entry might not be generated if a response is written
|
|
// in `getInitialProps` or `getServerSideProps`, but those shouldn't
|
|
// have a cache key. If we do have a cache key but we don't end up
|
|
// with a cache entry, then either Next.js or the application has a
|
|
// bug that needs fixing.
|
|
throw new Error('invariant: cache entry required but not generated')
|
|
}
|
|
return null
|
|
}
|
|
|
|
const { revalidate, value: cachedData } = cacheEntry
|
|
const revalidateOptions: any =
|
|
typeof revalidate !== 'undefined' &&
|
|
(!this.renderOpts.dev || (hasServerProps && !isDataReq))
|
|
? {
|
|
// When the page is 404 cache-control should not be added unless
|
|
// we are rendering the 404 page for notFound: true which should
|
|
// cache according to revalidate correctly
|
|
private: isPreviewMode || (is404Page && cachedData),
|
|
stateful: !isSSG,
|
|
revalidate,
|
|
}
|
|
: undefined
|
|
|
|
if (!cachedData) {
|
|
if (revalidateOptions) {
|
|
setRevalidateHeaders(res, revalidateOptions)
|
|
}
|
|
if (isDataReq) {
|
|
res.statusCode = 404
|
|
res.body('{"notFound":true}').send()
|
|
return null
|
|
} else {
|
|
await this.render404(
|
|
req,
|
|
res,
|
|
{
|
|
pathname,
|
|
query,
|
|
} as UrlWithParsedQuery,
|
|
false
|
|
)
|
|
return null
|
|
}
|
|
} else if (cachedData.kind === 'REDIRECT') {
|
|
if (isDataReq) {
|
|
return {
|
|
type: 'json',
|
|
body: RenderResult.fromStatic(JSON.stringify(cachedData.props)),
|
|
revalidateOptions,
|
|
}
|
|
} else {
|
|
await handleRedirect(cachedData.props)
|
|
return null
|
|
}
|
|
} else {
|
|
return {
|
|
type: isDataReq ? 'json' : 'html',
|
|
body: isDataReq
|
|
? RenderResult.fromStatic(JSON.stringify(cachedData.pageData))
|
|
: cachedData.html,
|
|
revalidateOptions,
|
|
}
|
|
}
|
|
}
|
|
|
|
private async renderToResponse(
|
|
ctx: RequestContext
|
|
): Promise<ResponsePayload | null> {
|
|
const { res, query, pathname } = ctx
|
|
let page = pathname
|
|
const bubbleNoFallback = !!query._nextBubbleNoFallback
|
|
delete query._nextBubbleNoFallback
|
|
|
|
try {
|
|
const result = await this.findPageComponents(pathname, query)
|
|
if (result) {
|
|
try {
|
|
return await this.renderToResponseWithComponents(ctx, result)
|
|
} catch (err) {
|
|
const isNoFallbackError = err instanceof NoFallbackError
|
|
|
|
if (!isNoFallbackError || (isNoFallbackError && bubbleNoFallback)) {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.dynamicRoutes) {
|
|
for (const dynamicRoute of this.dynamicRoutes) {
|
|
const params = dynamicRoute.match(pathname)
|
|
if (!params) {
|
|
continue
|
|
}
|
|
|
|
const dynamicRouteResult = await this.findPageComponents(
|
|
dynamicRoute.page,
|
|
query,
|
|
params
|
|
)
|
|
if (dynamicRouteResult) {
|
|
try {
|
|
page = dynamicRoute.page
|
|
return await this.renderToResponseWithComponents(
|
|
{
|
|
...ctx,
|
|
pathname: dynamicRoute.page,
|
|
renderOpts: {
|
|
...ctx.renderOpts,
|
|
params,
|
|
},
|
|
},
|
|
dynamicRouteResult
|
|
)
|
|
} catch (err) {
|
|
const isNoFallbackError = err instanceof NoFallbackError
|
|
|
|
if (
|
|
!isNoFallbackError ||
|
|
(isNoFallbackError && bubbleNoFallback)
|
|
) {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const err = getProperError(error)
|
|
if (err instanceof NoFallbackError && bubbleNoFallback) {
|
|
throw err
|
|
}
|
|
if (err instanceof DecodeError) {
|
|
res.statusCode = 400
|
|
return await this.renderErrorToResponse(ctx, err)
|
|
}
|
|
|
|
res.statusCode = 500
|
|
const isWrappedError = err instanceof WrappedBuildError
|
|
const response = await this.renderErrorToResponse(
|
|
ctx,
|
|
isWrappedError ? (err as WrappedBuildError).innerError : err
|
|
)
|
|
|
|
if (!isWrappedError) {
|
|
if ((this.minimalMode && !process.browser) || this.renderOpts.dev) {
|
|
if (isError(err)) err.page = page
|
|
throw err
|
|
}
|
|
this.logError(getProperError(err))
|
|
}
|
|
return response
|
|
}
|
|
res.statusCode = 404
|
|
return this.renderErrorToResponse(ctx, null)
|
|
}
|
|
|
|
public async renderToHTML(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
pathname: string,
|
|
query: ParsedUrlQuery = {}
|
|
): Promise<string | null> {
|
|
return this.getStaticHTML((ctx) => this.renderToResponse(ctx), {
|
|
req,
|
|
res,
|
|
pathname,
|
|
query,
|
|
})
|
|
}
|
|
|
|
public async renderError(
|
|
err: Error | null,
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
pathname: string,
|
|
query: NextParsedUrlQuery = {},
|
|
setHeaders = true
|
|
): Promise<void> {
|
|
if (setHeaders) {
|
|
res.setHeader(
|
|
'Cache-Control',
|
|
'no-cache, no-store, max-age=0, must-revalidate'
|
|
)
|
|
}
|
|
|
|
return this.pipe(
|
|
async (ctx) => {
|
|
const response = await this.renderErrorToResponse(ctx, err)
|
|
if (this.minimalMode && res.statusCode === 500) {
|
|
throw err
|
|
}
|
|
return response
|
|
},
|
|
{ req, res, pathname, query }
|
|
)
|
|
}
|
|
|
|
private customErrorNo404Warn = execOnce(() => {
|
|
Log.warn(
|
|
`You have added a custom /_error page without a custom /404 page. This prevents the 404 page from being auto statically optimized.\nSee here for info: https://nextjs.org/docs/messages/custom-error-no-custom-404`
|
|
)
|
|
})
|
|
|
|
private async renderErrorToResponse(
|
|
ctx: RequestContext,
|
|
err: Error | null
|
|
): Promise<ResponsePayload | null> {
|
|
const { res, query } = ctx
|
|
try {
|
|
let result: null | FindComponentsResult = null
|
|
|
|
const is404 = res.statusCode === 404
|
|
let using404Page = false
|
|
|
|
// use static 404 page if available and is 404 response
|
|
if (is404) {
|
|
result = await this.findPageComponents('/404', query)
|
|
using404Page = result !== null
|
|
}
|
|
let statusPage = `/${res.statusCode}`
|
|
|
|
if (!result && STATIC_STATUS_PAGES.includes(statusPage)) {
|
|
result = await this.findPageComponents(statusPage, query)
|
|
}
|
|
|
|
if (!result) {
|
|
result = await this.findPageComponents('/_error', query)
|
|
statusPage = '/_error'
|
|
}
|
|
|
|
if (
|
|
process.env.NODE_ENV !== 'production' &&
|
|
!using404Page &&
|
|
(await this.hasPage('/_error')) &&
|
|
!(await this.hasPage('/404'))
|
|
) {
|
|
this.customErrorNo404Warn()
|
|
}
|
|
|
|
try {
|
|
return await this.renderToResponseWithComponents(
|
|
{
|
|
...ctx,
|
|
pathname: statusPage,
|
|
renderOpts: {
|
|
...ctx.renderOpts,
|
|
err,
|
|
},
|
|
},
|
|
result!
|
|
)
|
|
} catch (maybeFallbackError) {
|
|
if (maybeFallbackError instanceof NoFallbackError) {
|
|
throw new Error('invariant: failed to render error page')
|
|
}
|
|
throw maybeFallbackError
|
|
}
|
|
} catch (error) {
|
|
const renderToHtmlError = getProperError(error)
|
|
const isWrappedError = renderToHtmlError instanceof WrappedBuildError
|
|
if (!isWrappedError) {
|
|
this.logError(renderToHtmlError)
|
|
}
|
|
res.statusCode = 500
|
|
const fallbackComponents = await this.getFallbackErrorComponents()
|
|
|
|
if (fallbackComponents) {
|
|
return this.renderToResponseWithComponents(
|
|
{
|
|
...ctx,
|
|
pathname: '/_error',
|
|
renderOpts: {
|
|
...ctx.renderOpts,
|
|
// We render `renderToHtmlError` here because `err` is
|
|
// already captured in the stacktrace.
|
|
err: isWrappedError
|
|
? renderToHtmlError.innerError
|
|
: renderToHtmlError,
|
|
},
|
|
},
|
|
{
|
|
query,
|
|
components: fallbackComponents,
|
|
}
|
|
)
|
|
}
|
|
return {
|
|
type: 'html',
|
|
body: RenderResult.fromStatic('Internal Server Error'),
|
|
}
|
|
}
|
|
}
|
|
|
|
public async renderErrorToHTML(
|
|
err: Error | null,
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
pathname: string,
|
|
query: ParsedUrlQuery = {}
|
|
): Promise<string | null> {
|
|
return this.getStaticHTML((ctx) => this.renderErrorToResponse(ctx, err), {
|
|
req,
|
|
res,
|
|
pathname,
|
|
query,
|
|
})
|
|
}
|
|
|
|
protected getCacheFilesystem(): CacheFs {
|
|
return {
|
|
readFile: () => Promise.resolve(''),
|
|
readFileSync: () => '',
|
|
writeFile: () => Promise.resolve(),
|
|
mkdir: () => Promise.resolve(),
|
|
stat: () => Promise.resolve({ mtime: new Date() }),
|
|
}
|
|
}
|
|
|
|
protected async getFallbackErrorComponents(): Promise<LoadComponentsReturnType | null> {
|
|
// The development server will provide an implementation for this
|
|
return null
|
|
}
|
|
|
|
public async render404(
|
|
req: BaseNextRequest,
|
|
res: BaseNextResponse,
|
|
parsedUrl?: NextUrlWithParsedQuery,
|
|
setHeaders = true
|
|
): Promise<void> {
|
|
const { pathname, query }: NextUrlWithParsedQuery = parsedUrl
|
|
? parsedUrl
|
|
: parseUrl(req.url!, true)
|
|
|
|
if (this.nextConfig.i18n) {
|
|
query.__nextLocale =
|
|
query.__nextLocale || this.nextConfig.i18n.defaultLocale
|
|
query.__nextDefaultLocale =
|
|
query.__nextDefaultLocale || this.nextConfig.i18n.defaultLocale
|
|
}
|
|
|
|
res.statusCode = 404
|
|
return this.renderError(null, req, res, pathname!, query, setHeaders)
|
|
}
|
|
|
|
protected get _isLikeServerless(): boolean {
|
|
return isTargetLikeServerless(this.nextConfig.target)
|
|
}
|
|
}
|
|
|
|
export function prepareServerlessUrl(
|
|
req: BaseNextRequest,
|
|
query: ParsedUrlQuery
|
|
): void {
|
|
const curUrl = parseUrl(req.url!, true)
|
|
req.url = formatUrl({
|
|
...curUrl,
|
|
search: undefined,
|
|
query: {
|
|
...curUrl.query,
|
|
...query,
|
|
},
|
|
})
|
|
}
|
|
|
|
export { stringifyQuery } from './server-route-utils'
|
|
|
|
class NoFallbackError extends Error {}
|
|
|
|
// Internal wrapper around build errors at development
|
|
// time, to prevent us from propagating or logging them
|
|
export class WrappedBuildError extends Error {
|
|
innerError: Error
|
|
|
|
constructor(innerError: Error) {
|
|
super()
|
|
this.innerError = innerError
|
|
}
|
|
}
|
|
|
|
type ResponsePayload = {
|
|
type: 'html' | 'json'
|
|
body: RenderResult
|
|
revalidateOptions?: any
|
|
}
|