rsnext/packages/next/build/webpack/loaders/next-serverless-loader.ts
JJ Kasper bbc1a21c74
Update to have default locale matched on root (#17669)
Follow-up PR to https://github.com/vercel/next.js/pull/17370 when the path is not prefixed with a locale and the default locale is the detected locale it doesn't redirect to locale prefixed variant. If the default locale path is visited and the default locale is visited this also redirects to the root removing the un-necessary locale in the URL. 

This also exposes the `defaultLocale` on the router since the RFC mentions `Setting a defaultLocale is required in every i18n library so it'd be useful for Next.js to provide it to the application.` although doesn't explicitly spec where we want to expose it. If we want to expose it differently this can be updated.
2020-10-08 11:12:17 +00:00

725 lines
23 KiB
TypeScript

import devalue from 'next/dist/compiled/devalue'
import escapeRegexp from 'next/dist/compiled/escape-string-regexp'
import { join } from 'path'
import { parse } from 'querystring'
import { loader } from 'webpack'
import { API_ROUTE } from '../../../lib/constants'
import {
BUILD_MANIFEST,
REACT_LOADABLE_MANIFEST,
ROUTES_MANIFEST,
} from '../../../next-server/lib/constants'
import { isDynamicRoute } from '../../../next-server/lib/router/utils'
import { __ApiPreviewProps } from '../../../next-server/server/api-utils'
export type ServerlessLoaderQuery = {
page: string
distDir: string
absolutePagePath: string
absoluteAppPath: string
absoluteDocumentPath: string
absoluteErrorPath: string
buildId: string
assetPrefix: string
generateEtags: string
poweredByHeader: string
canonicalBase: string
basePath: string
runtimeConfig: string
previewProps: string
loadedEnvFiles: string
i18n: string
}
const vercelHeader = 'x-vercel-id'
const nextServerlessLoader: loader.Loader = function () {
const {
distDir,
absolutePagePath,
page,
buildId,
canonicalBase,
assetPrefix,
absoluteAppPath,
absoluteDocumentPath,
absoluteErrorPath,
generateEtags,
poweredByHeader,
basePath,
runtimeConfig,
previewProps,
loadedEnvFiles,
i18n,
}: ServerlessLoaderQuery =
typeof this.query === 'string' ? parse(this.query.substr(1)) : this.query
const buildManifest = join(distDir, BUILD_MANIFEST).replace(/\\/g, '/')
const reactLoadableManifest = join(distDir, REACT_LOADABLE_MANIFEST).replace(
/\\/g,
'/'
)
const routesManifest = join(distDir, ROUTES_MANIFEST).replace(/\\/g, '/')
const escapedBuildId = escapeRegexp(buildId)
const pageIsDynamicRoute = isDynamicRoute(page)
const encodedPreviewProps = devalue(
JSON.parse(previewProps) as __ApiPreviewProps
)
const i18nEnabled = !!i18n
const defaultRouteRegex = pageIsDynamicRoute
? `
const defaultRouteRegex = getRouteRegex("${page}")
`
: ''
const normalizeDynamicRouteParams = pageIsDynamicRoute
? `
function normalizeDynamicRouteParams(query) {
return Object.keys(defaultRouteRegex.groups)
.reduce((prev, key) => {
let value = query[key]
${
''
// 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 query[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
}, {})
}
`
: ''
const envLoading = `
const { processEnv } = require('@next/env')
processEnv(${Buffer.from(loadedEnvFiles, 'base64').toString()})
`
const runtimeConfigImports = runtimeConfig
? `
const { setConfig } = require('next/config')
`
: ''
const runtimeConfigSetter = runtimeConfig
? `
const runtimeConfig = ${runtimeConfig}
setConfig(runtimeConfig)
`
: 'const runtimeConfig = {}'
const dynamicRouteImports = pageIsDynamicRoute
? `
const { getRouteMatcher } = require('next/dist/next-server/lib/router/utils/route-matcher');
const { getRouteRegex } = require('next/dist/next-server/lib/router/utils/route-regex');
`
: ''
const dynamicRouteMatcher = pageIsDynamicRoute
? `
const dynamicRouteMatcher = getRouteMatcher(getRouteRegex("${page}"))
`
: ''
const rewriteImports = `
const { rewrites } = require('${routesManifest}')
const { pathToRegexp, default: pathMatch } = require('next/dist/next-server/lib/router/utils/path-match')
`
const handleRewrites = `
const getCustomRouteMatcher = pathMatch(true)
const prepareDestination = require('next/dist/next-server/lib/router/utils/prepare-destination').default
function handleRewrites(parsedUrl) {
for (const rewrite of rewrites) {
const matcher = getCustomRouteMatcher(rewrite.source)
const params = matcher(parsedUrl.pathname)
if (params) {
const { parsedDestination } = prepareDestination(
rewrite.destination,
params,
parsedUrl.query,
true,
"${basePath}"
)
Object.assign(parsedUrl.query, parsedDestination.query)
delete parsedDestination.query
Object.assign(parsedUrl, parsedDestination)
if (parsedUrl.pathname === '${page}'){
break
}
${
pageIsDynamicRoute
? `
const dynamicParams = dynamicRouteMatcher(parsedUrl.pathname);\
if (dynamicParams) {
parsedUrl.query = {
...parsedUrl.query,
...dynamicParams
}
break
}
`
: ''
}
}
}
return parsedUrl
}
`
const handleBasePath = basePath
? `
// 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}'), '') || '/'
`
: ''
const handleLocale = i18nEnabled
? `
// get pathname from URL with basePath stripped for locale detection
const i18n = ${i18n}
const accept = require('@hapi/accept')
const { detectLocaleCookie } = require('next/dist/next-server/lib/i18n/detect-locale-cookie')
const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path')
let detectedLocale = detectLocaleCookie(req, i18n.locales)
if (!detectedLocale) {
detectedLocale = accept.language(
req.headers['accept-language'],
i18n.locales
)
}
const denormalizedPagePath = denormalizePagePath(parsedUrl.pathname || '/')
const detectedDefaultLocale = detectedLocale === i18n.defaultLocale
const shouldStripDefaultLocale =
detectedDefaultLocale &&
denormalizedPagePath === \`/\${i18n.defaultLocale}\`
const shouldAddLocalePrefix =
!detectedDefaultLocale && denormalizedPagePath === '/'
detectedLocale = detectedLocale || i18n.defaultLocale
if (
!nextStartMode &&
i18n.localeDetection !== false &&
(shouldAddLocalePrefix || shouldStripDefaultLocale)
) {
res.setHeader(
'Location',
formatUrl({
// make sure to include any query values when redirecting
...parsedUrl,
pathname: shouldStripDefaultLocale ? '/' : \`/\${detectedLocale}\`,
})
)
res.statusCode = 307
res.end()
return
}
// TODO: domain based locales (domain to locale mapping needs to be provided in next.config.js)
const localePathResult = normalizeLocalePath(parsedUrl.pathname, i18n.locales)
if (localePathResult.detectedLocale) {
detectedLocale = localePathResult.detectedLocale
req.url = formatUrl({
...parsedUrl,
pathname: localePathResult.pathname,
})
parsedUrl.pathname = localePathResult.pathname
}
detectedLocale = detectedLocale || i18n.defaultLocale
`
: `
const i18n = {}
const detectedLocale = undefined
`
if (page.match(API_ROUTE)) {
return `
import initServer from 'next-plugin-loader?middleware=on-init-server!'
import onError from 'next-plugin-loader?middleware=on-error-server!'
import 'next/dist/next-server/server/node-polyfill-fetch'
${envLoading}
${runtimeConfigImports}
${
/*
this needs to be called first so its available for any other imports
*/
runtimeConfigSetter
}
${dynamicRouteImports}
const { parse: parseUrl } = require('url')
const { apiResolver } = require('next/dist/next-server/server/api-utils')
${rewriteImports}
${dynamicRouteMatcher}
${defaultRouteRegex}
${normalizeDynamicRouteParams}
${handleRewrites}
export default async (req, res) => {
try {
await initServer()
// We need to trust the dynamic route params from the proxy
// to ensure we are using the correct values
const trustQuery = req.headers['${vercelHeader}']
const parsedUrl = handleRewrites(parseUrl(req.url, true))
${handleBasePath}
const params = ${
pageIsDynamicRoute
? `
normalizeDynamicRouteParams(
trustQuery
? parsedUrl.query
: dynamicRouteMatcher(parsedUrl.pathname)
)
`
: `{}`
}
const resolver = require('${absolutePagePath}')
await apiResolver(
req,
res,
Object.assign({}, parsedUrl.query, params ),
resolver,
${encodedPreviewProps},
true,
onError
)
} catch (err) {
console.error(err)
await onError(err)
// TODO: better error for DECODE_FAILED?
if (err.code === 'DECODE_FAILED') {
res.statusCode = 400
res.end('Bad Request')
} else {
// Throw the error to crash the serverless function
throw err
}
}
}
`
} else {
return `
import initServer from 'next-plugin-loader?middleware=on-init-server!'
import onError from 'next-plugin-loader?middleware=on-error-server!'
import 'next/dist/next-server/server/node-polyfill-fetch'
const {isResSent} = require('next/dist/next-server/lib/utils');
${envLoading}
${runtimeConfigImports}
${
// this needs to be called first so its available for any other imports
runtimeConfigSetter
}
const {parse: parseUrl, format: formatUrl} = require('url')
const {parse: parseQs} = require('querystring')
const { renderToHTML } = require('next/dist/next-server/server/render');
const { tryGetPreviewData } = require('next/dist/next-server/server/api-utils');
const { denormalizePagePath } = require('next/dist/next-server/server/denormalize-page-path')
const { setLazyProp, getCookieParser } = require('next/dist/next-server/server/api-utils')
const {sendPayload} = require('next/dist/next-server/server/send-payload');
const buildManifest = require('${buildManifest}');
const reactLoadableManifest = require('${reactLoadableManifest}');
const Document = require('${absoluteDocumentPath}').default;
const Error = require('${absoluteErrorPath}').default;
const App = require('${absoluteAppPath}').default;
${dynamicRouteImports}
${rewriteImports}
const ComponentInfo = require('${absolutePagePath}')
const Component = ComponentInfo.default
export default Component
export const unstable_getStaticParams = ComponentInfo['unstable_getStaticParam' + 's']
export const getStaticProps = ComponentInfo['getStaticProp' + 's']
export const getStaticPaths = ComponentInfo['getStaticPath' + 's']
export const getServerSideProps = ComponentInfo['getServerSideProp' + 's']
// kept for detecting legacy exports
export const unstable_getStaticProps = ComponentInfo['unstable_getStaticProp' + 's']
export const unstable_getStaticPaths = ComponentInfo['unstable_getStaticPath' + 's']
export const unstable_getServerProps = ComponentInfo['unstable_getServerProp' + 's']
${dynamicRouteMatcher}
${defaultRouteRegex}
${normalizeDynamicRouteParams}
${handleRewrites}
export const config = ComponentInfo['confi' + 'g'] || {}
export const _app = App
export async function renderReqToHTML(req, res, renderMode, _renderOpts, _params) {
const fromExport = renderMode === 'export' || renderMode === true;
const nextStartMode = renderMode === 'passthrough'
setLazyProp({ req }, 'cookies', getCookieParser(req))
const options = {
App,
Document,
buildManifest,
getStaticProps,
getServerSideProps,
getStaticPaths,
reactLoadableManifest,
canonicalBase: "${canonicalBase}",
buildId: "${buildId}",
assetPrefix: "${assetPrefix}",
runtimeConfig: runtimeConfig.publicRuntimeConfig || {},
previewProps: ${encodedPreviewProps},
env: process.env,
basePath: "${basePath}",
..._renderOpts
}
let _nextData = false
let parsedUrl
try {
// We need to trust the dynamic route params from the proxy
// to ensure we are using the correct values
const trustQuery = !getStaticProps && req.headers['${vercelHeader}']
let parsedUrl = parseUrl(req.url, true)
let routeNoAssetPath = parsedUrl.pathname${
basePath ? `.replace(new RegExp('^${basePath}'), '') || '/'` : ''
}
const origQuery = Object.assign({}, parsedUrl.query)
parsedUrl = handleRewrites(parsedUrl)
${handleBasePath}
if (parsedUrl.pathname.match(/_next\\/data/)) {
const {
default: getrouteNoAssetPath,
} = require('next/dist/next-server/lib/router/utils/get-route-from-asset-path');
_nextData = true;
parsedUrl.pathname = getrouteNoAssetPath(
parsedUrl.pathname.replace(
new RegExp('/_next/data/${escapedBuildId}/'),
'/'
),
'.json'
);
routeNoAssetPath = parsedUrl.pathname
}
${handleLocale}
const renderOpts = Object.assign(
{
Component,
pageConfig: config,
nextExport: fromExport,
isDataReq: _nextData,
locale: detectedLocale,
locales: i18n.locales,
defaultLocale: i18n.defaultLocale,
},
options,
)
${
page === '/_error'
? `
if (!res.statusCode) {
res.statusCode = 404
}
`
: ''
}
${
pageIsDynamicRoute
? `
const params = (
fromExport
) ? {}
: normalizeDynamicRouteParams(
trustQuery
? parsedUrl.query
: dynamicRouteMatcher(parsedUrl.pathname)
)
`
: `const params = {};`
}
${
// Temporary work around: `x-now-route-matches` is a platform header
// _only_ set for `Prerender` requests. We should move this logic
// into our builder to ensure we're decoupled. However, this entails
// removing reliance on `req.url` and using `req.query` instead
// (which is needed for "custom routes" anyway).
pageIsDynamicRoute
? `const nowParams = req.headers && req.headers["x-now-route-matches"]
? getRouteMatcher(
(function() {
const { re, groups } = getRouteRegex("${page}");
return {
re: {
// Simulate a RegExp match from the \`req.url\` input
exec: str => {
const obj = parseQs(str);
return Object.keys(obj).reduce(
(prev, key) =>
Object.assign(prev, {
[key]: obj[key]
}),
{}
);
}
},
groups
};
})()
)(req.headers["x-now-route-matches"])
: null;
`
: `const nowParams = null;`
}
// make sure to set renderOpts to the correct params e.g. _params
// if provided from worker or params if we're parsing them here
renderOpts.params = _params || params
// make sure to normalize req.url on Vercel to strip dynamic params
// from the query which are added during routing
${
pageIsDynamicRoute
? `
if (trustQuery) {
const _parsedUrl = parseUrl(req.url, true)
delete _parsedUrl.search
for (const param of Object.keys(defaultRouteRegex.groups)) {
delete _parsedUrl.query[param]
}
req.url = formatUrl(_parsedUrl)
}
`
: ''
}
// normalize request URL/asPath for fallback/revalidate pages since the
// proxy sets the request URL to the output's path for fallback pages
${
pageIsDynamicRoute
? `
if (nowParams) {
const _parsedUrl = parseUrl(req.url)
for (const param of Object.keys(defaultRouteRegex.groups)) {
const paramIdx = _parsedUrl.pathname.indexOf(\`[\${param}]\`)
if (paramIdx > -1) {
_parsedUrl.pathname = _parsedUrl.pathname.substr(0, paramIdx) +
encodeURI(nowParams[param]) +
_parsedUrl.pathname.substr(paramIdx + param.length + 2)
}
}
parsedUrl.pathname = _parsedUrl.pathname
req.url = formatUrl(_parsedUrl)
}
`
: ``
}
// make sure to normalize asPath for revalidate and _next/data requests
// since the asPath should match what is shown on the client
if (
!fromExport &&
(getStaticProps || getServerSideProps)
) {
${
pageIsDynamicRoute
? `
// don't include dynamic route params in query while normalizing
// asPath
if (trustQuery) {
delete parsedUrl.search
for (const param of Object.keys(defaultRouteRegex.groups)) {
delete origQuery[param]
}
}
`
: ``
}
parsedUrl.pathname = denormalizePagePath(parsedUrl.pathname)
renderOpts.resolvedUrl = formatUrl({
...parsedUrl,
query: origQuery
})
// For getServerSideProps we need to ensure we use the original URL
// and not the resolved URL to prevent a hydration mismatch on asPath
renderOpts.resolvedAsPath = getServerSideProps
? formatUrl({
...parsedUrl,
pathname: routeNoAssetPath,
query: origQuery,
})
: renderOpts.resolvedUrl
}
const isFallback = parsedUrl.query.__nextFallback
const previewData = tryGetPreviewData(req, res, options.previewProps)
const isPreviewMode = previewData !== false
if (process.env.__NEXT_OPTIMIZE_FONTS) {
renderOpts.optimizeFonts = true
/**
* __webpack_require__.__NEXT_FONT_MANIFEST__ is added by
* font-stylesheet-gathering-plugin
*/
renderOpts.fontManifest = __webpack_require__.__NEXT_FONT_MANIFEST__;
process.env['__NEXT_OPTIMIZE_FONT'+'S'] = true
}
let result = await renderToHTML(req, res, "${page}", Object.assign({}, getStaticProps ? { ...(parsedUrl.query.amp ? { amp: '1' } : {}) } : parsedUrl.query, nowParams ? nowParams : params, _params, isFallback ? { __nextFallback: 'true' } : {}), renderOpts)
if (!renderMode) {
if (_nextData || getStaticProps || getServerSideProps) {
sendPayload(req, res, _nextData ? JSON.stringify(renderOpts.pageData) : result, _nextData ? 'json' : 'html', ${
generateEtags === 'true' ? true : false
}, {
private: isPreviewMode,
stateful: !!getServerSideProps,
revalidate: renderOpts.revalidate,
})
return null
}
} else if (isPreviewMode) {
res.setHeader(
'Cache-Control',
'private, no-cache, no-store, max-age=0, must-revalidate'
)
}
if (renderMode) return { html: result, renderOpts }
return result
} catch (err) {
if (!parsedUrl) {
parsedUrl = parseUrl(req.url, true)
}
if (err.code === 'ENOENT') {
res.statusCode = 404
} else if (err.code === 'DECODE_FAILED') {
// TODO: better error?
res.statusCode = 400
} else {
console.error('Unhandled error during request:', err)
// Backwards compat (call getInitialProps in custom error):
try {
await renderToHTML(req, res, "/_error", parsedUrl.query, Object.assign({}, options, {
getStaticProps: undefined,
getStaticPaths: undefined,
getServerSideProps: undefined,
Component: Error,
err: err,
// Short-circuit rendering:
isDataReq: true
}))
} catch (underErrorErr) {
console.error('Failed call /_error subroutine, continuing to crash function:', underErrorErr)
}
// Throw the error to crash the serverless function
if (isResSent(res)) {
console.error('!!! WARNING !!!')
console.error(
'Your function crashed, but closed the response before allowing the function to exit.\\n' +
'This may cause unexpected behavior for the next request.'
)
console.error('!!! WARNING !!!')
}
throw err
}
const result = await renderToHTML(req, res, "/_error", parsedUrl.query, Object.assign({}, options, {
getStaticProps: undefined,
getStaticPaths: undefined,
getServerSideProps: undefined,
Component: Error,
err: res.statusCode === 404 ? undefined : err
}))
return result
}
}
export async function render (req, res) {
try {
await initServer()
const html = await renderReqToHTML(req, res)
if (html) {
sendPayload(req, res, html, 'html', {generateEtags: ${JSON.stringify(
generateEtags === 'true'
)}, poweredByHeader: ${JSON.stringify(poweredByHeader === 'true')}})
}
} catch(err) {
console.error(err)
await onError(err)
// Throw the error to crash the serverless function
throw err
}
}
`
}
}
export default nextServerlessLoader