0de109baab
This PR brings some significant refactoring in preparation for upcoming middleware changes. Each commit can be reviewed independently, here is a summary of what each one does and the reasoning behind it: - [Move pagesDir to next-dev-server](f2fe154c00
) simply moves the `pagesDir` property to the dev server which is the only place where it is needed. Having it for every server is misleading. - [Move (de)normalize page path utils to a file page-path-utils.ts](27cedf0871
) Moves the functions to normalize and denormalize page paths to a single file that is intended to hold every utility function that transforms page paths. Since those are complementary it makes sense to have them together. I also added explanatory comments on why they are not idempotent and examples for input -> output that I find very useful. - [Extract removePagePathTail](6b121332aa
) This extracts a function to remove the tail on a page path (absolute or relative). I'm sure there will be other contexts where we can use it. - [Extract getPagePaths and refactor findPageFile](cf2c7b842e
) This extracts a function `getPagePaths` that is used to generate an array of paths to inspect when looking for a page file from `findPageFile`. Then it refactors such function to use it parallelizing lookups. This will allow us to print every path we look at when looking for a file which can be useful for debugging. It also adds a `flatten` helper. - [Refactor onDemandEntryHandler](4be685c37e
) I've found this one quite difficult to understand so it is refactored to use some of the previously mentioned functions and make it easier to read. - [Extract absolutePagePath util](3bc0783474
) Extracts yet another util from the `next-dev-server` that transforms an absolute path into a page name. Of course it adds comments, parameters and examples. - [Refactor MiddlewarePlugin](c595a2cc62
) This is the most significant change. The logic here was very hard to understand so it is totally redistributed with comments. This also removes a global variable `ssrEntries` that was deprecated in favour of module metadata added to Webpack from loaders keeping less dependencies. It also adds types and makes a clear distinction between phases where we statically analyze the code, find metadata and generate the manifest file cc @shuding @huozhi EDIT: - [Split page path utils](158fb002d0
) After seeing one of the utils was being used by the client while it was defined originally in the server, with this PR we are splitting the util into multiple files and moving it to `shared/lib` in order to make explicit that those can be also imported from client.
559 lines
17 KiB
TypeScript
559 lines
17 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'http'
|
|
import type { Rewrite } from '../../../../lib/load-custom-routes'
|
|
import type { BuildManifest } from '../../../../server/get-page-files'
|
|
import type { NextConfig } from '../../../../server/config'
|
|
import type {
|
|
GetServerSideProps,
|
|
GetStaticPaths,
|
|
GetStaticProps,
|
|
} from '../../../../types'
|
|
import type { BaseNextRequest } from '../../../../server/base-http'
|
|
|
|
import { format as formatUrl, UrlWithParsedQuery, parse as parseUrl } from 'url'
|
|
import { parse as parseQs, ParsedUrlQuery } from 'querystring'
|
|
import { normalizeLocalePath } from '../../../../shared/lib/i18n/normalize-locale-path'
|
|
import { getPathMatch } from '../../../../shared/lib/router/utils/path-match'
|
|
import { getRouteRegex } from '../../../../shared/lib/router/utils/route-regex'
|
|
import { getRouteMatcher } from '../../../../shared/lib/router/utils/route-matcher'
|
|
import {
|
|
matchHas,
|
|
prepareDestination,
|
|
} from '../../../../shared/lib/router/utils/prepare-destination'
|
|
import { __ApiPreviewProps } from '../../../../server/api-utils'
|
|
import { acceptLanguage } from '../../../../server/accept-header'
|
|
import { detectLocaleCookie } from '../../../../shared/lib/i18n/detect-locale-cookie'
|
|
import { detectDomainLocale } from '../../../../shared/lib/i18n/detect-domain-locale'
|
|
import { denormalizePagePath } from '../../../../shared/lib/page-path/denormalize-page-path'
|
|
import cookie from 'next/dist/compiled/cookie'
|
|
import { TEMPORARY_REDIRECT_STATUS } from '../../../../shared/lib/constants'
|
|
import { addRequestMeta } from '../../../../server/request-meta'
|
|
|
|
export const vercelHeader = 'x-vercel-id'
|
|
|
|
export type ServerlessHandlerCtx = {
|
|
page: string
|
|
|
|
pageModule: any
|
|
pageComponent?: any
|
|
pageConfig?: any
|
|
pageGetStaticProps?: GetStaticProps
|
|
pageGetStaticPaths?: GetStaticPaths
|
|
pageGetServerSideProps?: GetServerSideProps
|
|
|
|
appModule?: any
|
|
errorModule?: any
|
|
documentModule?: any
|
|
notFoundModule?: any
|
|
|
|
runtimeConfig: any
|
|
buildManifest?: BuildManifest
|
|
reactLoadableManifest?: any
|
|
basePath: string
|
|
rewrites: {
|
|
fallback?: Rewrite[]
|
|
afterFiles?: Rewrite[]
|
|
beforeFiles?: Rewrite[]
|
|
}
|
|
pageIsDynamic: boolean
|
|
generateEtags: boolean
|
|
distDir: string
|
|
buildId: string
|
|
escapedBuildId: string
|
|
assetPrefix: string
|
|
poweredByHeader: boolean
|
|
canonicalBase: string
|
|
encodedPreviewProps: __ApiPreviewProps
|
|
i18n?: NextConfig['i18n']
|
|
}
|
|
|
|
export function getUtils({
|
|
page,
|
|
i18n,
|
|
basePath,
|
|
rewrites,
|
|
pageIsDynamic,
|
|
}: {
|
|
page: ServerlessHandlerCtx['page']
|
|
i18n?: ServerlessHandlerCtx['i18n']
|
|
basePath: ServerlessHandlerCtx['basePath']
|
|
rewrites: ServerlessHandlerCtx['rewrites']
|
|
pageIsDynamic: ServerlessHandlerCtx['pageIsDynamic']
|
|
}) {
|
|
let defaultRouteRegex: ReturnType<typeof getRouteRegex> | undefined
|
|
let dynamicRouteMatcher: ReturnType<typeof getRouteMatcher> | undefined
|
|
let defaultRouteMatches: ParsedUrlQuery | undefined
|
|
|
|
if (pageIsDynamic) {
|
|
defaultRouteRegex = getRouteRegex(page)
|
|
dynamicRouteMatcher = getRouteMatcher(defaultRouteRegex)
|
|
defaultRouteMatches = dynamicRouteMatcher(page) as ParsedUrlQuery
|
|
}
|
|
|
|
function handleRewrites(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
parsedUrl: UrlWithParsedQuery
|
|
) {
|
|
const rewriteParams = {}
|
|
let fsPathname = parsedUrl.pathname
|
|
|
|
const matchesPage = () => {
|
|
return fsPathname === page || dynamicRouteMatcher?.(fsPathname)
|
|
}
|
|
|
|
const checkRewrite = (rewrite: Rewrite): boolean => {
|
|
const matcher = getPathMatch(rewrite.source, {
|
|
removeUnnamedParams: true,
|
|
strict: true,
|
|
})
|
|
let params = matcher(parsedUrl.pathname)
|
|
|
|
if (rewrite.has && params) {
|
|
const hasParams = matchHas(req, rewrite.has, parsedUrl.query)
|
|
|
|
if (hasParams) {
|
|
Object.assign(params, hasParams)
|
|
} else {
|
|
params = false
|
|
}
|
|
}
|
|
|
|
if (params) {
|
|
const { parsedDestination, destQuery } = prepareDestination({
|
|
appendParamsToQuery: true,
|
|
destination: rewrite.destination,
|
|
params: params,
|
|
query: parsedUrl.query,
|
|
})
|
|
|
|
// if the rewrite destination is external break rewrite chain
|
|
if (parsedDestination.protocol) {
|
|
return true
|
|
}
|
|
|
|
Object.assign(rewriteParams, destQuery, params)
|
|
Object.assign(parsedUrl.query, parsedDestination.query)
|
|
delete (parsedDestination as any).query
|
|
|
|
Object.assign(parsedUrl, parsedDestination)
|
|
|
|
fsPathname = parsedUrl.pathname
|
|
|
|
if (basePath) {
|
|
fsPathname =
|
|
fsPathname!.replace(new RegExp(`^${basePath}`), '') || '/'
|
|
}
|
|
|
|
if (i18n) {
|
|
const destLocalePathResult = normalizeLocalePath(
|
|
fsPathname!,
|
|
i18n.locales
|
|
)
|
|
fsPathname = destLocalePathResult.pathname
|
|
parsedUrl.query.nextInternalLocale =
|
|
destLocalePathResult.detectedLocale || params.nextInternalLocale
|
|
}
|
|
|
|
if (fsPathname === page) {
|
|
return true
|
|
}
|
|
|
|
if (pageIsDynamic && dynamicRouteMatcher) {
|
|
const dynamicParams = dynamicRouteMatcher(fsPathname)
|
|
if (dynamicParams) {
|
|
parsedUrl.query = {
|
|
...parsedUrl.query,
|
|
...dynamicParams,
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
for (const rewrite of rewrites.beforeFiles || []) {
|
|
checkRewrite(rewrite)
|
|
}
|
|
|
|
if (fsPathname !== page) {
|
|
let finished = false
|
|
|
|
for (const rewrite of rewrites.afterFiles || []) {
|
|
finished = checkRewrite(rewrite)
|
|
if (finished) break
|
|
}
|
|
|
|
if (!finished && !matchesPage()) {
|
|
for (const rewrite of rewrites.fallback || []) {
|
|
finished = checkRewrite(rewrite)
|
|
if (finished) break
|
|
}
|
|
}
|
|
}
|
|
return rewriteParams
|
|
}
|
|
|
|
function handleBasePath(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
parsedUrl: UrlWithParsedQuery
|
|
) {
|
|
// always strip the basePath if configured since it is required
|
|
req.url = req.url!.replace(new RegExp(`^${basePath}`), '') || '/'
|
|
parsedUrl.pathname =
|
|
parsedUrl.pathname!.replace(new RegExp(`^${basePath}`), '') || '/'
|
|
}
|
|
|
|
function getParamsFromRouteMatches(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
renderOpts?: any,
|
|
detectedLocale?: string
|
|
) {
|
|
return getRouteMatcher(
|
|
(function () {
|
|
const { groups, routeKeys } = defaultRouteRegex!
|
|
|
|
return {
|
|
re: {
|
|
// Simulate a RegExp match from the \`req.url\` input
|
|
exec: (str: string) => {
|
|
const obj = parseQs(str)
|
|
const matchesHasLocale =
|
|
i18n && detectedLocale && obj['1'] === detectedLocale
|
|
|
|
// favor named matches if available
|
|
const routeKeyNames = Object.keys(routeKeys || {})
|
|
const filterLocaleItem = (val: string | string[]) => {
|
|
if (i18n) {
|
|
// locale items can be included in route-matches
|
|
// for fallback SSG pages so ensure they are
|
|
// filtered
|
|
const isCatchAll = Array.isArray(val)
|
|
const _val = isCatchAll ? val[0] : val
|
|
|
|
if (
|
|
typeof _val === 'string' &&
|
|
i18n.locales.some((item) => {
|
|
if (item.toLowerCase() === _val.toLowerCase()) {
|
|
detectedLocale = item
|
|
renderOpts.locale = detectedLocale
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
) {
|
|
// remove the locale item from the match
|
|
if (isCatchAll) {
|
|
;(val as string[]).splice(0, 1)
|
|
}
|
|
|
|
// the value is only a locale item and
|
|
// shouldn't be added
|
|
return isCatchAll ? val.length === 0 : true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if (routeKeyNames.every((name) => obj[name])) {
|
|
return routeKeyNames.reduce((prev, keyName) => {
|
|
const paramName = routeKeys?.[keyName]
|
|
|
|
if (paramName && !filterLocaleItem(obj[keyName])) {
|
|
prev[groups[paramName].pos] = obj[keyName]
|
|
}
|
|
return prev
|
|
}, {} as any)
|
|
}
|
|
|
|
return Object.keys(obj).reduce((prev, key) => {
|
|
if (!filterLocaleItem(obj[key])) {
|
|
let normalizedKey = key
|
|
|
|
if (matchesHasLocale) {
|
|
normalizedKey = parseInt(key, 10) - 1 + ''
|
|
}
|
|
return Object.assign(prev, {
|
|
[normalizedKey]: obj[key],
|
|
})
|
|
}
|
|
return prev
|
|
}, {})
|
|
},
|
|
},
|
|
groups,
|
|
}
|
|
})() as any
|
|
)(req.headers['x-now-route-matches'] as string) as ParsedUrlQuery
|
|
}
|
|
|
|
function interpolateDynamicPath(pathname: string, params: ParsedUrlQuery) {
|
|
if (!defaultRouteRegex) return pathname
|
|
|
|
for (const param of Object.keys(defaultRouteRegex.groups)) {
|
|
const { optional, repeat } = defaultRouteRegex.groups[param]
|
|
let builtParam = `[${repeat ? '...' : ''}${param}]`
|
|
|
|
if (optional) {
|
|
builtParam = `[${builtParam}]`
|
|
}
|
|
|
|
const paramIdx = pathname!.indexOf(builtParam)
|
|
|
|
if (paramIdx > -1) {
|
|
let paramValue: string
|
|
|
|
if (Array.isArray(params[param])) {
|
|
paramValue = (params[param] as string[])
|
|
.map((v) => v && encodeURIComponent(v))
|
|
.join('/')
|
|
} else {
|
|
paramValue =
|
|
params[param] && encodeURIComponent(params[param] as string)
|
|
}
|
|
|
|
pathname =
|
|
pathname.slice(0, paramIdx) +
|
|
(paramValue || '') +
|
|
pathname.slice(paramIdx + builtParam.length)
|
|
}
|
|
}
|
|
|
|
return pathname
|
|
}
|
|
|
|
function normalizeVercelUrl(
|
|
req: BaseNextRequest | IncomingMessage,
|
|
trustQuery: boolean,
|
|
paramKeys?: string[]
|
|
) {
|
|
// make sure to normalize req.url on Vercel to strip dynamic params
|
|
// from the query which are added during routing
|
|
if (pageIsDynamic && trustQuery && defaultRouteRegex) {
|
|
const _parsedUrl = parseUrl(req.url!, true)
|
|
delete (_parsedUrl as any).search
|
|
|
|
for (const param of paramKeys || Object.keys(defaultRouteRegex.groups)) {
|
|
delete _parsedUrl.query[param]
|
|
}
|
|
req.url = formatUrl(_parsedUrl)
|
|
}
|
|
}
|
|
|
|
function normalizeDynamicRouteParams(params: ParsedUrlQuery) {
|
|
let hasValidParams = true
|
|
if (!defaultRouteRegex) return { params, hasValidParams: false }
|
|
|
|
params = Object.keys(defaultRouteRegex.groups).reduce((prev, key) => {
|
|
let value: string | string[] | undefined = params[key]
|
|
|
|
// if the value matches the default value we can't rely
|
|
// on the parsed params, this is used to signal if we need
|
|
// to parse x-now-route-matches or not
|
|
const defaultValue = defaultRouteMatches![key]
|
|
|
|
const isDefaultValue = Array.isArray(defaultValue)
|
|
? defaultValue.some((defaultVal) => {
|
|
return Array.isArray(value)
|
|
? value.some((val) => val.includes(defaultVal))
|
|
: value?.includes(defaultVal)
|
|
})
|
|
: value?.includes(defaultValue as string)
|
|
|
|
if (isDefaultValue || typeof value === 'undefined') {
|
|
hasValidParams = false
|
|
}
|
|
|
|
// non-provided optional values should be undefined so normalize
|
|
// them to undefined
|
|
if (
|
|
defaultRouteRegex!.groups[key].optional &&
|
|
(!value ||
|
|
(Array.isArray(value) &&
|
|
value.length === 1 &&
|
|
// fallback optional catch-all SSG pages have
|
|
// [[...paramName]] for the root path on Vercel
|
|
(value[0] === 'index' || value[0] === `[[...${key}]]`)))
|
|
) {
|
|
value = undefined
|
|
delete params[key]
|
|
}
|
|
|
|
// query values from the proxy aren't already split into arrays
|
|
// so make sure to normalize catch-all values
|
|
if (
|
|
value &&
|
|
typeof value === 'string' &&
|
|
defaultRouteRegex!.groups[key].repeat
|
|
) {
|
|
value = value.split('/')
|
|
}
|
|
|
|
if (value) {
|
|
prev[key] = value
|
|
}
|
|
return prev
|
|
}, {} as ParsedUrlQuery)
|
|
|
|
return {
|
|
params,
|
|
hasValidParams,
|
|
}
|
|
}
|
|
|
|
function handleLocale(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
parsedUrl: UrlWithParsedQuery,
|
|
routeNoAssetPath: string,
|
|
shouldNotRedirect: boolean
|
|
) {
|
|
if (!i18n) return
|
|
const pathname = parsedUrl.pathname || '/'
|
|
|
|
let defaultLocale = i18n.defaultLocale
|
|
let detectedLocale = detectLocaleCookie(req, i18n.locales)
|
|
let acceptPreferredLocale
|
|
try {
|
|
acceptPreferredLocale =
|
|
i18n.localeDetection !== false
|
|
? acceptLanguage(req.headers['accept-language'], i18n.locales)
|
|
: detectedLocale
|
|
} catch (_) {
|
|
acceptPreferredLocale = detectedLocale
|
|
}
|
|
|
|
const { host } = req.headers || {}
|
|
// remove port from host and remove port if present
|
|
const hostname = host && host.split(':')[0].toLowerCase()
|
|
|
|
const detectedDomain = detectDomainLocale(i18n.domains, hostname)
|
|
if (detectedDomain) {
|
|
defaultLocale = detectedDomain.defaultLocale
|
|
detectedLocale = defaultLocale
|
|
addRequestMeta(req as any, '__nextIsLocaleDomain', true)
|
|
}
|
|
|
|
// if not domain specific locale use accept-language preferred
|
|
detectedLocale = detectedLocale || acceptPreferredLocale
|
|
|
|
let localeDomainRedirect
|
|
const localePathResult = normalizeLocalePath(pathname, i18n.locales)
|
|
|
|
routeNoAssetPath = normalizeLocalePath(
|
|
routeNoAssetPath,
|
|
i18n.locales
|
|
).pathname
|
|
|
|
if (localePathResult.detectedLocale) {
|
|
detectedLocale = localePathResult.detectedLocale
|
|
req.url = formatUrl({
|
|
...parsedUrl,
|
|
pathname: localePathResult.pathname,
|
|
})
|
|
addRequestMeta(req as any, '__nextStrippedLocale', true)
|
|
parsedUrl.pathname = localePathResult.pathname
|
|
}
|
|
|
|
// If a detected locale is a domain specific locale and we aren't already
|
|
// on that domain and path prefix redirect to it to prevent duplicate
|
|
// content from multiple domains
|
|
if (detectedDomain) {
|
|
const localeToCheck = localePathResult.detectedLocale
|
|
? detectedLocale
|
|
: acceptPreferredLocale
|
|
|
|
const matchedDomain = detectDomainLocale(
|
|
i18n.domains,
|
|
undefined,
|
|
localeToCheck
|
|
)
|
|
|
|
if (matchedDomain && matchedDomain.domain !== detectedDomain.domain) {
|
|
localeDomainRedirect = `http${matchedDomain.http ? '' : 's'}://${
|
|
matchedDomain.domain
|
|
}/${localeToCheck === matchedDomain.defaultLocale ? '' : localeToCheck}`
|
|
}
|
|
}
|
|
|
|
const denormalizedPagePath = denormalizePagePath(pathname)
|
|
const detectedDefaultLocale =
|
|
!detectedLocale ||
|
|
detectedLocale.toLowerCase() === defaultLocale.toLowerCase()
|
|
const shouldStripDefaultLocale = false
|
|
// detectedDefaultLocale &&
|
|
// denormalizedPagePath.toLowerCase() === \`/\${i18n.defaultLocale.toLowerCase()}\`
|
|
|
|
const shouldAddLocalePrefix =
|
|
!detectedDefaultLocale && denormalizedPagePath === '/'
|
|
|
|
detectedLocale = detectedLocale || i18n.defaultLocale
|
|
|
|
if (
|
|
!shouldNotRedirect &&
|
|
!req.headers[vercelHeader] &&
|
|
i18n.localeDetection !== false &&
|
|
(localeDomainRedirect ||
|
|
shouldAddLocalePrefix ||
|
|
shouldStripDefaultLocale)
|
|
) {
|
|
// set the NEXT_LOCALE cookie when a user visits the default locale
|
|
// with the locale prefix so that they aren't redirected back to
|
|
// their accept-language preferred locale
|
|
if (shouldStripDefaultLocale && acceptPreferredLocale !== defaultLocale) {
|
|
const previous = res.getHeader('set-cookie')
|
|
|
|
res.setHeader('set-cookie', [
|
|
...(typeof previous === 'string'
|
|
? [previous]
|
|
: Array.isArray(previous)
|
|
? previous
|
|
: []),
|
|
cookie.serialize('NEXT_LOCALE', defaultLocale, {
|
|
httpOnly: true,
|
|
path: '/',
|
|
}),
|
|
])
|
|
}
|
|
|
|
res.setHeader(
|
|
'Location',
|
|
formatUrl({
|
|
// make sure to include any query values when redirecting
|
|
...parsedUrl,
|
|
pathname: localeDomainRedirect
|
|
? localeDomainRedirect
|
|
: shouldStripDefaultLocale
|
|
? basePath || '/'
|
|
: `${basePath}/${detectedLocale}`,
|
|
})
|
|
)
|
|
res.statusCode = TEMPORARY_REDIRECT_STATUS
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
detectedLocale =
|
|
localePathResult.detectedLocale ||
|
|
(detectedDomain && detectedDomain.defaultLocale) ||
|
|
defaultLocale
|
|
|
|
return {
|
|
defaultLocale,
|
|
detectedLocale,
|
|
routeNoAssetPath,
|
|
}
|
|
}
|
|
|
|
return {
|
|
handleLocale,
|
|
handleRewrites,
|
|
handleBasePath,
|
|
defaultRouteRegex,
|
|
normalizeVercelUrl,
|
|
dynamicRouteMatcher,
|
|
defaultRouteMatches,
|
|
interpolateDynamicPath,
|
|
getParamsFromRouteMatches,
|
|
normalizeDynamicRouteParams,
|
|
}
|
|
}
|