3e00a81ede
Adds base http classes, along with Node + Web (partial) implementations Removes usage of IncomingMessage and ServerResponse from base server Co-authored-by: Shu Ding <3676859+shuding@users.noreply.github.com>
572 lines
15 KiB
TypeScript
572 lines
15 KiB
TypeScript
import type { Params, Route } from './router'
|
|
import type { CacheFs } from '../shared/lib/utils'
|
|
import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta'
|
|
import type RenderResult from './render-result'
|
|
|
|
import fs from 'fs'
|
|
import { join, relative } from 'path'
|
|
import { IncomingMessage, ServerResponse } from 'http'
|
|
|
|
import { PAGES_MANIFEST, BUILD_ID_FILE } from '../shared/lib/constants'
|
|
import { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
|
|
import { recursiveReadDirSync } from './lib/recursive-readdir-sync'
|
|
import { format as formatUrl, UrlWithParsedQuery } from 'url'
|
|
import compression from 'next/dist/compiled/compression'
|
|
import Proxy from 'next/dist/compiled/http-proxy'
|
|
import { route } from './router'
|
|
|
|
import {
|
|
BaseNextRequest,
|
|
BaseNextResponse,
|
|
NodeNextRequest,
|
|
NodeNextResponse,
|
|
} from './base-http'
|
|
import { PayloadOptions, sendRenderResult } from './send-payload'
|
|
import { serveStatic } from './serve-static'
|
|
import { ParsedUrlQuery } from 'querystring'
|
|
import { apiResolver } from './api-utils'
|
|
import { RenderOpts, renderToHTML } from './render'
|
|
import { ParsedUrl } from '../shared/lib/router/utils/parse-url'
|
|
|
|
import BaseServer, {
|
|
FindComponentsResult,
|
|
prepareServerlessUrl,
|
|
stringifyQuery,
|
|
} from './base-server'
|
|
import { getMiddlewareInfo, getPagePath, requireFontManifest } from './require'
|
|
import { normalizePagePath } from './normalize-page-path'
|
|
import { loadComponents } from './load-components'
|
|
import isError from '../lib/is-error'
|
|
import { FontManifest } from './font-utils'
|
|
|
|
export * from './base-server'
|
|
|
|
type ExpressMiddleware = (
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
next: (err?: Error) => void
|
|
) => void
|
|
|
|
export interface NodeRequestHandler {
|
|
(
|
|
req: IncomingMessage | BaseNextRequest,
|
|
res: ServerResponse | BaseNextResponse,
|
|
parsedUrl?: NextUrlWithParsedQuery | undefined
|
|
): Promise<void>
|
|
}
|
|
|
|
export default class NextNodeServer extends BaseServer {
|
|
private compression =
|
|
this.nextConfig.compress && this.nextConfig.target === 'server'
|
|
? (compression() as ExpressMiddleware)
|
|
: undefined
|
|
|
|
protected getHasStaticDir(): boolean {
|
|
return fs.existsSync(join(this.dir, 'static'))
|
|
}
|
|
|
|
protected getPagesManifest(): PagesManifest | undefined {
|
|
const pagesManifestPath = join(this.serverBuildDir, PAGES_MANIFEST)
|
|
return require(pagesManifestPath)
|
|
}
|
|
|
|
protected getBuildId(): string {
|
|
const buildIdFile = join(this.distDir, BUILD_ID_FILE)
|
|
try {
|
|
return fs.readFileSync(buildIdFile, 'utf8').trim()
|
|
} catch (err) {
|
|
if (!fs.existsSync(buildIdFile)) {
|
|
throw new Error(
|
|
`Could not find a production build in the '${this.distDir}' directory. Try building your app with 'next build' before starting the production server. https://nextjs.org/docs/messages/production-start-no-build-id`
|
|
)
|
|
}
|
|
|
|
throw err
|
|
}
|
|
}
|
|
|
|
protected generateImageRoutes(): Route[] {
|
|
return [
|
|
{
|
|
match: route('/_next/image'),
|
|
type: 'route',
|
|
name: '_next/image catchall',
|
|
fn: (req, res, _params, parsedUrl) => {
|
|
if (this.minimalMode) {
|
|
res.statusCode = 400
|
|
res.body('Bad Request').send()
|
|
return {
|
|
finished: true,
|
|
}
|
|
}
|
|
|
|
return this.imageOptimizer(
|
|
req as NodeNextRequest,
|
|
res as NodeNextResponse,
|
|
parsedUrl
|
|
)
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
protected generatePublicRoutes(): Route[] {
|
|
if (!fs.existsSync(this.publicDir)) return []
|
|
|
|
const publicFiles = new Set(
|
|
recursiveReadDirSync(this.publicDir).map((p) =>
|
|
encodeURI(p.replace(/\\/g, '/'))
|
|
)
|
|
)
|
|
|
|
return [
|
|
{
|
|
match: route('/:path*'),
|
|
name: 'public folder catchall',
|
|
fn: async (req, res, params, parsedUrl) => {
|
|
const pathParts: string[] = params.path || []
|
|
const { basePath } = this.nextConfig
|
|
|
|
// if basePath is defined require it be present
|
|
if (basePath) {
|
|
const basePathParts = basePath.split('/')
|
|
// remove first empty value
|
|
basePathParts.shift()
|
|
|
|
if (
|
|
!basePathParts.every((part: string, idx: number) => {
|
|
return part === pathParts[idx]
|
|
})
|
|
) {
|
|
return { finished: false }
|
|
}
|
|
|
|
pathParts.splice(0, basePathParts.length)
|
|
}
|
|
|
|
let path = `/${pathParts.join('/')}`
|
|
|
|
if (!publicFiles.has(path)) {
|
|
// In `next-dev-server.ts`, we ensure encoded paths match
|
|
// decoded paths on the filesystem. So we need do the
|
|
// opposite here: make sure decoded paths match encoded.
|
|
path = encodeURI(path)
|
|
}
|
|
|
|
if (publicFiles.has(path)) {
|
|
await this.serveStatic(
|
|
req,
|
|
res,
|
|
join(this.publicDir, ...pathParts),
|
|
parsedUrl
|
|
)
|
|
return {
|
|
finished: true,
|
|
}
|
|
}
|
|
return {
|
|
finished: false,
|
|
}
|
|
},
|
|
} as Route,
|
|
]
|
|
}
|
|
|
|
private _validFilesystemPathSet: Set<string> | null = null
|
|
protected getFilesystemPaths(): Set<string> {
|
|
if (this._validFilesystemPathSet) {
|
|
return this._validFilesystemPathSet
|
|
}
|
|
|
|
const pathUserFilesStatic = join(this.dir, 'static')
|
|
let userFilesStatic: string[] = []
|
|
if (this.hasStaticDir && fs.existsSync(pathUserFilesStatic)) {
|
|
userFilesStatic = recursiveReadDirSync(pathUserFilesStatic).map((f) =>
|
|
join('.', 'static', f)
|
|
)
|
|
}
|
|
|
|
let userFilesPublic: string[] = []
|
|
if (this.publicDir && fs.existsSync(this.publicDir)) {
|
|
userFilesPublic = recursiveReadDirSync(this.publicDir).map((f) =>
|
|
join('.', 'public', f)
|
|
)
|
|
}
|
|
|
|
let nextFilesStatic: string[] = []
|
|
|
|
nextFilesStatic =
|
|
!this.minimalMode && fs.existsSync(join(this.distDir, 'static'))
|
|
? recursiveReadDirSync(join(this.distDir, 'static')).map((f) =>
|
|
join('.', relative(this.dir, this.distDir), 'static', f)
|
|
)
|
|
: []
|
|
|
|
return (this._validFilesystemPathSet = new Set<string>([
|
|
...nextFilesStatic,
|
|
...userFilesPublic,
|
|
...userFilesStatic,
|
|
]))
|
|
}
|
|
|
|
protected sendRenderResult(
|
|
req: NodeNextRequest,
|
|
res: NodeNextResponse,
|
|
options: {
|
|
result: RenderResult
|
|
type: 'html' | 'json'
|
|
generateEtags: boolean
|
|
poweredByHeader: boolean
|
|
options?: PayloadOptions | undefined
|
|
}
|
|
): Promise<void> {
|
|
return sendRenderResult({
|
|
req: req.originalRequest,
|
|
res: res.originalResponse,
|
|
...options,
|
|
})
|
|
}
|
|
|
|
protected sendStatic(
|
|
req: NodeNextRequest,
|
|
res: NodeNextResponse,
|
|
path: string
|
|
): Promise<void> {
|
|
return serveStatic(req.originalRequest, res.originalResponse, path)
|
|
}
|
|
|
|
protected handleCompression(
|
|
req: NodeNextRequest,
|
|
res: NodeNextResponse
|
|
): void {
|
|
if (this.compression) {
|
|
this.compression(req.originalRequest, res.originalResponse, () => {})
|
|
}
|
|
}
|
|
|
|
protected async proxyRequest(
|
|
req: NodeNextRequest,
|
|
res: NodeNextResponse,
|
|
parsedUrl: ParsedUrl
|
|
) {
|
|
const { query } = parsedUrl
|
|
delete (parsedUrl as any).query
|
|
parsedUrl.search = stringifyQuery(req, query)
|
|
|
|
const target = formatUrl(parsedUrl)
|
|
const proxy = new Proxy({
|
|
target,
|
|
changeOrigin: true,
|
|
ignorePath: true,
|
|
xfwd: true,
|
|
proxyTimeout: 30_000, // limit proxying to 30 seconds
|
|
})
|
|
|
|
await new Promise((proxyResolve, proxyReject) => {
|
|
let finished = false
|
|
|
|
proxy.on('proxyReq', (proxyReq) => {
|
|
proxyReq.on('close', () => {
|
|
if (!finished) {
|
|
finished = true
|
|
proxyResolve(true)
|
|
}
|
|
})
|
|
})
|
|
proxy.on('error', (err) => {
|
|
if (!finished) {
|
|
finished = true
|
|
proxyReject(err)
|
|
}
|
|
})
|
|
proxy.web(req.originalRequest, res.originalResponse)
|
|
})
|
|
|
|
return {
|
|
finished: true,
|
|
}
|
|
}
|
|
|
|
protected async runApi(
|
|
req: NodeNextRequest,
|
|
res: NodeNextResponse,
|
|
query: ParsedUrlQuery,
|
|
params: Params | false,
|
|
page: string,
|
|
builtPagePath: string
|
|
): Promise<boolean> {
|
|
const pageModule = await require(builtPagePath)
|
|
query = { ...query, ...params }
|
|
|
|
delete query.__nextLocale
|
|
delete query.__nextDefaultLocale
|
|
|
|
if (!this.renderOpts.dev && this._isLikeServerless) {
|
|
if (typeof pageModule.default === 'function') {
|
|
prepareServerlessUrl(req, query)
|
|
await pageModule.default(req, res)
|
|
return true
|
|
}
|
|
}
|
|
|
|
await apiResolver(
|
|
req.originalRequest,
|
|
res.originalResponse,
|
|
query,
|
|
pageModule,
|
|
this.renderOpts.previewProps,
|
|
this.minimalMode,
|
|
this.renderOpts.dev,
|
|
page
|
|
)
|
|
return true
|
|
}
|
|
|
|
protected async renderHTML(
|
|
req: NodeNextRequest,
|
|
res: NodeNextResponse,
|
|
pathname: string,
|
|
query: NextParsedUrlQuery,
|
|
renderOpts: RenderOpts
|
|
): Promise<RenderResult | null> {
|
|
return renderToHTML(
|
|
req.originalRequest,
|
|
res.originalResponse,
|
|
pathname,
|
|
query,
|
|
renderOpts
|
|
)
|
|
}
|
|
|
|
protected streamResponseChunk(res: NodeNextResponse, chunk: any) {
|
|
res.originalResponse.write(chunk)
|
|
}
|
|
|
|
protected async imageOptimizer(
|
|
req: NodeNextRequest,
|
|
res: NodeNextResponse,
|
|
parsedUrl: UrlWithParsedQuery
|
|
): Promise<{ finished: boolean }> {
|
|
const { imageOptimizer } =
|
|
require('./image-optimizer') as typeof import('./image-optimizer')
|
|
|
|
return imageOptimizer(
|
|
req.originalRequest,
|
|
res.originalResponse,
|
|
parsedUrl,
|
|
this.nextConfig,
|
|
this.distDir,
|
|
() => this.render404(req, res, parsedUrl),
|
|
(newReq, newRes, newParsedUrl) =>
|
|
this.getRequestHandler()(
|
|
new NodeNextRequest(newReq),
|
|
new NodeNextResponse(newRes),
|
|
newParsedUrl
|
|
),
|
|
this.renderOpts.dev
|
|
)
|
|
}
|
|
|
|
protected getPagePath(pathname: string, locales?: string[]): string {
|
|
return getPagePath(
|
|
pathname,
|
|
this.distDir,
|
|
this._isLikeServerless,
|
|
this.renderOpts.dev,
|
|
locales
|
|
)
|
|
}
|
|
|
|
protected async findPageComponents(
|
|
pathname: string,
|
|
query: NextParsedUrlQuery = {},
|
|
params: Params | null = null
|
|
): Promise<FindComponentsResult | null> {
|
|
let paths = [
|
|
// try serving a static AMP version first
|
|
query.amp ? normalizePagePath(pathname) + '.amp' : null,
|
|
pathname,
|
|
].filter(Boolean)
|
|
|
|
if (query.__nextLocale) {
|
|
paths = [
|
|
...paths.map(
|
|
(path) => `/${query.__nextLocale}${path === '/' ? '' : path}`
|
|
),
|
|
...paths,
|
|
]
|
|
}
|
|
|
|
for (const pagePath of paths) {
|
|
try {
|
|
const components = await loadComponents(
|
|
this.distDir,
|
|
pagePath!,
|
|
!this.renderOpts.dev && this._isLikeServerless
|
|
)
|
|
|
|
if (
|
|
query.__nextLocale &&
|
|
typeof components.Component === 'string' &&
|
|
!pagePath?.startsWith(`/${query.__nextLocale}`)
|
|
) {
|
|
// if loading an static HTML file the locale is required
|
|
// to be present since all HTML files are output under their locale
|
|
continue
|
|
}
|
|
|
|
return {
|
|
components,
|
|
query: {
|
|
...(components.getStaticProps
|
|
? ({
|
|
amp: query.amp,
|
|
_nextDataReq: query._nextDataReq,
|
|
__nextLocale: query.__nextLocale,
|
|
__nextDefaultLocale: query.__nextDefaultLocale,
|
|
} as NextParsedUrlQuery)
|
|
: query),
|
|
...(params || {}),
|
|
},
|
|
}
|
|
} catch (err) {
|
|
if (isError(err) && err.code !== 'ENOENT') throw err
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
protected getFontManifest(): FontManifest {
|
|
return requireFontManifest(this.distDir, this._isLikeServerless)
|
|
}
|
|
|
|
protected getCacheFilesystem(): CacheFs {
|
|
return {
|
|
readFile: (f) => fs.promises.readFile(f, 'utf8'),
|
|
readFileSync: (f) => fs.readFileSync(f, 'utf8'),
|
|
writeFile: (f, d) => fs.promises.writeFile(f, d, 'utf8'),
|
|
mkdir: (dir) => fs.promises.mkdir(dir, { recursive: true }),
|
|
stat: (f) => fs.promises.stat(f),
|
|
}
|
|
}
|
|
|
|
private normalizeReq(
|
|
req: BaseNextRequest | IncomingMessage
|
|
): BaseNextRequest {
|
|
return req instanceof IncomingMessage ? new NodeNextRequest(req) : req
|
|
}
|
|
|
|
private normalizeRes(
|
|
res: BaseNextResponse | ServerResponse
|
|
): BaseNextResponse {
|
|
return res instanceof ServerResponse ? new NodeNextResponse(res) : res
|
|
}
|
|
|
|
public getRequestHandler(): NodeRequestHandler {
|
|
const handler = super.getRequestHandler()
|
|
return async (req, res, parsedUrl) => {
|
|
return handler(this.normalizeReq(req), this.normalizeRes(res), parsedUrl)
|
|
}
|
|
}
|
|
|
|
public async render(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
res: BaseNextResponse | ServerResponse,
|
|
pathname: string,
|
|
query?: NextParsedUrlQuery,
|
|
parsedUrl?: NextUrlWithParsedQuery
|
|
): Promise<void> {
|
|
return super.render(
|
|
this.normalizeReq(req),
|
|
this.normalizeRes(res),
|
|
pathname,
|
|
query,
|
|
parsedUrl
|
|
)
|
|
}
|
|
|
|
public async renderToHTML(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
res: BaseNextResponse | ServerResponse,
|
|
pathname: string,
|
|
query?: ParsedUrlQuery
|
|
): Promise<string | null> {
|
|
return super.renderToHTML(
|
|
this.normalizeReq(req),
|
|
this.normalizeRes(res),
|
|
pathname,
|
|
query
|
|
)
|
|
}
|
|
|
|
public async renderError(
|
|
err: Error | null,
|
|
req: BaseNextRequest | IncomingMessage,
|
|
res: BaseNextResponse | ServerResponse,
|
|
pathname: string,
|
|
query?: NextParsedUrlQuery,
|
|
setHeaders?: boolean
|
|
): Promise<void> {
|
|
return super.renderError(
|
|
err,
|
|
this.normalizeReq(req),
|
|
this.normalizeRes(res),
|
|
pathname,
|
|
query,
|
|
setHeaders
|
|
)
|
|
}
|
|
|
|
public async renderErrorToHTML(
|
|
err: Error | null,
|
|
req: BaseNextRequest | IncomingMessage,
|
|
res: BaseNextResponse | ServerResponse,
|
|
pathname: string,
|
|
query?: ParsedUrlQuery
|
|
): Promise<string | null> {
|
|
return super.renderErrorToHTML(
|
|
err,
|
|
this.normalizeReq(req),
|
|
this.normalizeRes(res),
|
|
pathname,
|
|
query
|
|
)
|
|
}
|
|
|
|
public async render404(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
res: BaseNextResponse | ServerResponse,
|
|
parsedUrl?: NextUrlWithParsedQuery,
|
|
setHeaders?: boolean
|
|
): Promise<void> {
|
|
return super.render404(
|
|
this.normalizeReq(req),
|
|
this.normalizeRes(res),
|
|
parsedUrl,
|
|
setHeaders
|
|
)
|
|
}
|
|
|
|
public async serveStatic(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
res: BaseNextResponse | ServerResponse,
|
|
path: string,
|
|
parsedUrl?: UrlWithParsedQuery
|
|
): Promise<void> {
|
|
return super.serveStatic(
|
|
this.normalizeReq(req),
|
|
this.normalizeRes(res),
|
|
path,
|
|
parsedUrl
|
|
)
|
|
}
|
|
|
|
protected getMiddlewareInfo(params: {
|
|
dev?: boolean
|
|
distDir: string
|
|
page: string
|
|
serverless: boolean
|
|
}) {
|
|
return getMiddlewareInfo(params)
|
|
}
|
|
}
|