Add initial handling for dynamic route href resolving and rewrites on the client (#15231)

Co-authored-by: Tim Neutkens <timneutkens@me.com>
This commit is contained in:
JJ Kasper 2020-08-13 07:39:36 -05:00 committed by GitHub
parent ff0571ae14
commit 8a489e24bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 646 additions and 246 deletions

View file

@ -286,7 +286,26 @@ export default async function build(
}
const routesManifestPath = path.join(distDir, ROUTES_MANIFEST)
const routesManifest: any = {
const routesManifest: {
version: number
pages404: boolean
basePath: string
redirects: Array<ReturnType<typeof buildCustomRoute>>
rewrites: Array<ReturnType<typeof buildCustomRoute>>
headers: Array<ReturnType<typeof buildCustomRoute>>
dynamicRoutes: Array<{
page: string
regex: string
namedRegex?: string
routeKeys?: { [key: string]: string }
}>
dataRoutes: Array<{
page: string
routeKeys?: { [key: string]: string }
dataRouteRegex: string
namedDataRouteRegex?: string
}>
} = {
version: 3,
pages404: true,
basePath: config.basePath,
@ -304,6 +323,7 @@ export default async function build(
namedRegex: routeRegex.namedRegex,
}
}),
dataRoutes: [],
}
await promises.mkdir(distDir, { recursive: true })
@ -325,6 +345,7 @@ export default async function build(
target,
pagesDir,
entrypoints: entrypoints.client,
rewrites,
}),
getBaseWebpackConfig(dir, {
tracer,
@ -335,6 +356,7 @@ export default async function build(
target,
pagesDir,
entrypoints: entrypoints.server,
rewrites,
}),
])
@ -654,6 +676,7 @@ export default async function build(
'utf8'
)
}
// Since custom _app.js can wrap the 404 page we have to opt-out of static optimization if it has getInitialProps
// Only export the static 404 when there is no /_error present
const useStatic404 =

View file

@ -54,6 +54,7 @@ import WebpackConformancePlugin, {
ReactSyncScriptsConformanceCheck,
} from './webpack/plugins/webpack-conformance-plugin'
import { WellKnownErrorsPlugin } from './webpack/plugins/wellknown-errors-plugin'
import { Rewrite } from '../lib/load-custom-routes'
type ExcludesFalse = <T>(x: T | false) => x is T
const isWebpack5 = parseInt(webpack.version!) === 5
@ -190,6 +191,7 @@ export default async function getBaseWebpackConfig(
target = 'server',
reactProductionProfiling = false,
entrypoints,
rewrites,
}: {
buildId: string
config: any
@ -200,6 +202,7 @@ export default async function getBaseWebpackConfig(
tracer?: any
reactProductionProfiling?: boolean
entrypoints: WebpackEntrypoints
rewrites: Rewrite[]
}
): Promise<webpack.Configuration> {
const productionBrowserSourceMaps =
@ -207,6 +210,8 @@ export default async function getBaseWebpackConfig(
let plugins: PluginMetaData[] = []
let babelPresetPlugins: { dir: string; config: any }[] = []
const hasRewrites = rewrites.length > 0 || dev
if (config.experimental.plugins) {
plugins = await collectPlugins(dir, config.env, config.plugins)
pluginLoaderOptions.plugins = plugins
@ -326,6 +331,10 @@ export default async function getBaseWebpackConfig(
}
}
const clientResolveRewrites = require.resolve(
'next/dist/next-server/lib/router/utils/resolve-rewrites'
)
const resolveConfig = {
// Disable .mjs for node_modules bundling
extensions: isServer
@ -370,6 +379,9 @@ export default async function getBaseWebpackConfig(
[DOT_NEXT_ALIAS]: distDir,
...getOptimizedAliases(isServer),
...getReactProfilingInProduction(),
[clientResolveRewrites]: hasRewrites
? clientResolveRewrites
: require.resolve('next/dist/client/dev/noop.js'),
},
mainFields: isServer ? ['main', 'module'] : ['browser', 'module', 'main'],
plugins: isWebpack5
@ -938,6 +950,7 @@ export default async function getBaseWebpackConfig(
config.experimental.scrollRestoration
),
'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath),
'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites),
...(isServer
? {
// Fix bad-actors in the npm ecosystem (e.g. `node-formidable`)
@ -1010,6 +1023,7 @@ export default async function getBaseWebpackConfig(
!isServer &&
new BuildManifestPlugin({
buildId,
rewrites,
modern: config.experimental.modern,
}),
tracer &&

View file

@ -147,7 +147,7 @@ const nextServerlessLoader: loader.Loader = function () {
const handleRewrites = `
const getCustomRouteMatcher = pathMatch(true)
const {prepareDestination} = require('next/dist/next-server/server/router')
const prepareDestination = require('next/dist/next-server/lib/router/utils/prepare-destination').default
function handleRewrites(parsedUrl) {
for (const rewrite of rewrites) {

View file

@ -12,6 +12,8 @@ import {
import { BuildManifest } from '../../../next-server/server/get-page-files'
import getRouteFromEntrypoint from '../../../next-server/server/get-route-from-entrypoint'
import { ampFirstEntryNamesMap } from './next-drop-client-page-plugin'
import { Rewrite } from '../../../lib/load-custom-routes'
import { getSortedRoutes } from '../../../next-server/lib/router/utils'
const isWebpack5 = parseInt(webpack.version!) === 5
@ -21,12 +23,19 @@ export type ClientBuildManifest = Record<string, string[]>
// reduced version to send to the client.
function generateClientManifest(
assetMap: BuildManifest,
isModern: boolean
isModern: boolean,
rewrites: Rewrite[]
): string {
const clientManifest: ClientBuildManifest = {}
const clientManifest: ClientBuildManifest = {
// TODO: update manifest type to include rewrites
__rewrites: rewrites as any,
}
const appDependencies = new Set(assetMap.pages['/_app'])
const sortedPageKeys = getSortedRoutes(Object.keys(assetMap.pages))
sortedPageKeys.forEach((page) => {
const dependencies = assetMap.pages[page]
Object.entries(assetMap.pages).forEach(([page, dependencies]) => {
if (page === '/_app') return
// Filter out dependencies in the _app entry, because those will have already
// been loaded by the client prior to a navigation event
@ -41,6 +50,10 @@ function generateClientManifest(
clientManifest[page] = filteredDeps
}
})
// provide the sorted pages as an array so we don't rely on the object's keys
// being in order and we don't slow down look-up time for page assets
clientManifest.sortedPages = sortedPageKeys
return devalue(clientManifest)
}
@ -65,10 +78,25 @@ function getFilesArray(files: any) {
export default class BuildManifestPlugin {
private buildId: string
private modern: boolean
private rewrites: Rewrite[]
constructor(options: { buildId: string; modern: boolean }) {
constructor(options: {
buildId: string
modern: boolean
rewrites: Rewrite[]
}) {
this.buildId = options.buildId
this.modern = options.modern
this.rewrites = options.rewrites.map((r) => {
const rewrite = { ...r }
// omit external rewrite destinations since these aren't
// handled client-side
if (!rewrite.destination.startsWith('/')) {
delete rewrite.destination
}
return rewrite
})
}
createAssets(compilation: any, assets: any) {
@ -185,7 +213,8 @@ export default class BuildManifestPlugin {
assets[clientManifestPath] = new RawSource(
`self.__BUILD_MANIFEST = ${generateClientManifest(
assetMap,
false
false,
this.rewrites
)};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()`
)
@ -195,7 +224,8 @@ export default class BuildManifestPlugin {
assets[modernClientManifestPath] = new RawSource(
`self.__BUILD_MANIFEST = ${generateClientManifest(
assetMap,
true
true,
this.rewrites
)};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()`
)
}

View file

@ -33,9 +33,11 @@ function EventSourceWrapper(options) {
for (var i = 0; i < listeners.length; i++) {
listeners[i](event)
}
if (event.data.indexOf('action') !== -1) {
eventCallbacks.forEach((cb) => cb(event))
}
eventCallbacks.forEach((cb) => {
if (!cb.unfiltered && event.data.indexOf('action') === -1) return
cb(event)
})
}
function handleDisconnect() {

View file

@ -6,6 +6,7 @@ import initWebpackHMR from './dev/webpack-hot-middleware-client'
import initializeBuildWatcher from './dev/dev-build-watcher'
import initializePrerenderIndicator from './dev/prerender-indicator'
import { displayContent } from './dev/fouc'
import { getEventSourceWrapper } from './dev/error-overlay/eventsource'
// Temporary workaround for the issue described here:
// https://github.com/vercel/next.js/issues/3775#issuecomment-407438123
@ -30,6 +31,22 @@ window.next = next
initNext({ webpackHMR })
.then(({ renderCtx, render }) => {
initOnDemandEntries({ assetPrefix: prefix })
function devPagesManifestListener(event) {
if (event.data.indexOf('devPagesManifest') !== -1) {
fetch(`${prefix}/_next/static/development/_devPagesManifest.json`)
.then((res) => res.json())
.then((manifest) => {
window.__DEV_PAGES_MANIFEST = manifest
})
.catch((err) => {
console.log(`Failed to fetch devPagesManifest`, err)
})
}
}
devPagesManifestListener.unfiltered = true
getEventSourceWrapper({}).addMessageListener(devPagesManifestListener)
if (process.env.__NEXT_BUILD_INDICATOR) initializeBuildWatcher()
if (
process.env.__NEXT_PRERENDER_INDICATOR &&

View file

@ -80,6 +80,7 @@ export default class PageLoader {
private loadingRoutes: Record<string, boolean>
private promisedBuildManifest?: Promise<ClientBuildManifest>
private promisedSsgManifest?: Promise<ClientSsgManifest>
private promisedDevPagesManifest?: Promise<any>
constructor(buildId: string, assetPrefix: string, initialPage: string) {
this.buildId = buildId
@ -97,17 +98,16 @@ export default class PageLoader {
this.loadingRoutes[initialPage] = true
}
if (process.env.NODE_ENV === 'production') {
this.promisedBuildManifest = new Promise((resolve) => {
if ((window as any).__BUILD_MANIFEST) {
this.promisedBuildManifest = new Promise((resolve) => {
if ((window as any).__BUILD_MANIFEST) {
resolve((window as any).__BUILD_MANIFEST)
} else {
;(window as any).__BUILD_MANIFEST_CB = () => {
resolve((window as any).__BUILD_MANIFEST)
} else {
;(window as any).__BUILD_MANIFEST_CB = () => {
resolve((window as any).__BUILD_MANIFEST)
}
}
})
}
}
})
/** @type {Promise<Set<string>>} */
this.promisedSsgManifest = new Promise((resolve) => {
if ((window as any).__SSG_MANIFEST) {
@ -120,6 +120,33 @@ export default class PageLoader {
})
}
getPageList() {
if (process.env.NODE_ENV === 'production') {
return this.promisedBuildManifest!.then(
(buildManifest) => buildManifest.sortedPages
)
} else {
if ((window as any).__DEV_PAGES_MANIFEST) {
return (window as any).__DEV_PAGES_MANIFEST.pages
} else {
if (!this.promisedDevPagesManifest) {
this.promisedDevPagesManifest = fetch(
`${this.assetPrefix}/_next/static/development/_devPagesManifest.json`
)
.then((res) => res.json())
.then((manifest) => {
;(window as any).__DEV_PAGES_MANIFEST = manifest
return manifest.pages
})
.catch((err) => {
console.log(`Failed to fetch devPagesManifest`, err)
})
}
return this.promisedDevPagesManifest
}
}
}
// Returns a promise for the dependencies for a particular route
getDependencies(route: string): Promise<string[]> {
return this.promisedBuildManifest!.then((m) => {

View file

@ -8,6 +8,7 @@ export const EXPORT_MARKER = 'export-marker.json'
export const EXPORT_DETAIL = 'export-detail.json'
export const PRERENDER_MANIFEST = 'prerender-manifest.json'
export const ROUTES_MANIFEST = 'routes-manifest.json'
export const DEV_CLIENT_PAGES_MANIFEST = '_devPagesManifest.json'
export const REACT_LOADABLE_MANIFEST = 'react-loadable-manifest.json'
export const FONT_MANIFEST = 'font-manifest.json'
export const SERVER_DIRECTORY = 'server'

View file

@ -23,6 +23,8 @@ import { parseRelativeUrl } from './utils/parse-relative-url'
import { searchParamsToUrlQuery } from './utils/querystring'
import { getRouteMatcher } from './utils/route-matcher'
import { getRouteRegex } from './utils/route-regex'
import { denormalizePagePath } from '../../server/denormalize-page-path'
import resolveRewrites from './utils/resolve-rewrites'
interface TransitionOptions {
shallow?: boolean
@ -465,6 +467,7 @@ export default class Router implements BaseRouter {
if (!(options as any)._h && this.onlyAHashChange(cleanedAs)) {
this.asPath = cleanedAs
Router.events.emit('hashChangeStart', as)
// TODO: do we need the resolved href when only a hash change?
this.changeState(method, url, as, options)
this.scrollToHash(cleanedAs)
this.notify(this.components[this.route])
@ -472,11 +475,25 @@ export default class Router implements BaseRouter {
return true
}
const parsed = tryParseRelativeUrl(url)
// The build manifest needs to be loaded before auto-static dynamic pages
// get their query parameters to allow ensuring they can be parsed properly
// when rewritten to
const pages = await this.pageLoader.getPageList()
const { __rewrites: rewrites } = await this.pageLoader.promisedBuildManifest
let parsed = tryParseRelativeUrl(url)
if (!parsed) return false
let { pathname, searchParams } = parsed
parsed = this._resolveHref(parsed, pages) as typeof parsed
if (parsed.pathname !== pathname) {
pathname = parsed.pathname
url = formatWithValidation(parsed)
}
const query = searchParamsToUrlQuery(searchParams)
// url and as should always be prefixed with basePath by this
@ -498,8 +515,17 @@ export default class Router implements BaseRouter {
const route = removePathTrailingSlash(pathname)
const { shallow = false } = options
// we need to resolve the as value using rewrites for dynamic SSG
// pages to allow building the data URL correctly
let resolvedAs = as
if (process.env.__NEXT_HAS_REWRITES) {
resolvedAs = resolveRewrites(as, pages, basePath, rewrites, query)
}
resolvedAs = delBasePath(resolvedAs)
if (isDynamicRoute(route)) {
const { pathname: asPathname } = parseRelativeUrl(cleanedAs)
const { pathname: asPathname } = parseRelativeUrl(resolvedAs)
const routeRegex = getRouteRegex(route)
const routeMatch = getRouteMatcher(routeRegex)(asPathname)
if (!routeMatch) {
@ -795,6 +821,29 @@ export default class Router implements BaseRouter {
return this.asPath !== asPath
}
_resolveHref(parsedHref: UrlObject, pages: string[]) {
const { pathname } = parsedHref
const cleanPathname = denormalizePagePath(delBasePath(pathname!))
if (cleanPathname === '/404' || cleanPathname === '/_error') {
return parsedHref
}
// handle resolving href for dynamic routes
if (!pages.includes(cleanPathname!)) {
for (let page of pages) {
if (
isDynamicRoute(page) &&
getRouteRegex(page).re.test(cleanPathname!)
) {
parsedHref.pathname = addBasePath(page)
break
}
}
}
return parsedHref
}
/**
* Prefetch page code, you may wait for the data during page rendering.
* This feature only works in production!
@ -806,11 +855,20 @@ export default class Router implements BaseRouter {
asPath: string = url,
options: PrefetchOptions = {}
): Promise<void> {
const parsed = tryParseRelativeUrl(url)
let parsed = tryParseRelativeUrl(url)
if (!parsed) return
const { pathname } = parsed
let { pathname } = parsed
const pages = await this.pageLoader.getPageList()
parsed = this._resolveHref(parsed, pages) as typeof parsed
if (parsed.pathname !== pathname) {
pathname = parsed.pathname
url = formatWithValidation(parsed)
}
// Prefetch is not supported in development mode because it would trigger on-demand-entries
if (process.env.NODE_ENV !== 'production') {

View file

@ -0,0 +1,122 @@
import { ParsedUrlQuery } from 'querystring'
import { searchParamsToUrlQuery } from './querystring'
import { parseRelativeUrl } from './parse-relative-url'
import { compile as compilePathToRegex } from 'next/dist/compiled/path-to-regexp'
type Params = { [param: string]: any }
export default function prepareDestination(
destination: string,
params: Params,
query: ParsedUrlQuery,
appendParamsToQuery: boolean,
basePath: string
) {
let parsedDestination: {
query?: ParsedUrlQuery
protocol?: string
hostname?: string
port?: string
} & ReturnType<typeof parseRelativeUrl> = {} as any
if (destination.startsWith('/')) {
parsedDestination = parseRelativeUrl(destination)
} else {
const {
pathname,
searchParams,
hash,
hostname,
port,
protocol,
search,
href,
} = new URL(destination)
parsedDestination = {
pathname,
searchParams,
hash,
protocol,
hostname,
port,
search,
href,
}
}
parsedDestination.query = searchParamsToUrlQuery(
parsedDestination.searchParams
)
const destQuery = parsedDestination.query
let destinationCompiler = compilePathToRegex(
`${parsedDestination.pathname!}${parsedDestination.hash || ''}`,
// we don't validate while compiling the destination since we should
// have already validated before we got to this point and validating
// breaks compiling destinations with named pattern params from the source
// e.g. /something:hello(.*) -> /another/:hello is broken with validation
// since compile validation is meant for reversing and not for inserting
// params from a separate path-regex into another
{ validate: false }
)
let newUrl
// update any params in query values
for (const [key, strOrArray] of Object.entries(destQuery)) {
let value = Array.isArray(strOrArray) ? strOrArray[0] : strOrArray
if (value) {
// the value needs to start with a forward-slash to be compiled
// correctly
value = `/${value}`
const queryCompiler = compilePathToRegex(value, { validate: false })
value = queryCompiler(params).substr(1)
}
destQuery[key] = value
}
// add path params to query if it's not a redirect and not
// already defined in destination query
if (appendParamsToQuery) {
for (const [name, value] of Object.entries(params)) {
if (!(name in destQuery)) {
destQuery[name] = value
}
}
}
const shouldAddBasePath = destination.startsWith('/') && basePath
try {
newUrl = `${shouldAddBasePath ? basePath : ''}${encodeURI(
destinationCompiler(params)
)}`
const [pathname, hash] = newUrl.split('#')
parsedDestination.pathname = pathname
parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}`
delete parsedDestination.search
delete parsedDestination.searchParams
} catch (err) {
if (err.message.match(/Expected .*? to not repeat, but got an array/)) {
throw new Error(
`To use a multi-match in the destination you must add \`*\` at the end of the param name to signify it should repeat. https://err.sh/vercel/next.js/invalid-multi-match`
)
}
throw err
}
// Query merge order lowest priority to highest
// 1. initial URL query values
// 2. path segment values
// 3. destination specified query values
parsedDestination.query = {
...query,
...parsedDestination.query,
}
return {
newUrl,
parsedDestination,
}
}

View file

@ -0,0 +1,44 @@
import { ParsedUrlQuery } from 'querystring'
import pathMatch from '../../../server/lib/path-match'
import prepareDestination from './prepare-destination'
import { Rewrite } from '../../../../lib/load-custom-routes'
const customRouteMatcher = pathMatch(true)
export default function resolveRewrites(
asPath: string,
pages: string[],
basePath: string,
rewrites: Rewrite[],
query: ParsedUrlQuery
) {
if (!pages.includes(asPath)) {
for (const rewrite of rewrites) {
const matcher = customRouteMatcher(rewrite.source)
const params = matcher(asPath)
if (params) {
if (!rewrite.destination) {
// this is a proxied rewrite which isn't handled on the client
break
}
const destRes = prepareDestination(
rewrite.destination,
params,
query,
true,
rewrite.basePath === false ? '' : basePath
)
asPath = destRes.parsedDestination.pathname!
Object.assign(query, destRes.parsedDestination.query)
if (pages.includes(asPath)) {
// check if we now match a page as this means we are done
// resolving the rewrites
break
}
}
}
}
return asPath
}

View file

@ -0,0 +1,13 @@
export function normalizePathSep(path: string): string {
return path.replace(/\\/g, '/')
}
export function denormalizePagePath(page: string) {
page = normalizePathSep(page)
if (page.startsWith('/index/')) {
page = page.slice(6)
} else if (page === '/index') {
page = '/'
}
return page
}

View file

@ -2,16 +2,25 @@ import * as pathToRegexp from 'next/dist/compiled/path-to-regexp'
export { pathToRegexp }
export const matcherOptions = {
sensitive: false,
delimiter: '/',
decode: decodeParam,
}
export const customRouteMatcherOptions = {
...matcherOptions,
strict: true,
}
export default (customRoute = false) => {
return (path: string) => {
const keys: pathToRegexp.Key[] = []
const matcherOptions = {
sensitive: false,
delimiter: '/',
...(customRoute ? { strict: true } : undefined),
decode: decodeParam,
}
const matcherRegex = pathToRegexp.pathToRegexp(path, keys, matcherOptions)
const matcherRegex = pathToRegexp.pathToRegexp(
path,
keys,
customRoute ? customRouteMatcherOptions : matcherOptions
)
const matcher = pathToRegexp.regexpToFunction(
matcherRegex,
keys,

View file

@ -48,10 +48,10 @@ import Router, {
DynamicRoutes,
PageChecker,
Params,
prepareDestination,
route,
Route,
} from './router'
import prepareDestination from '../lib/router/utils/prepare-destination'
import { sendPayload } from './send-payload'
import { serveStatic } from './serve-static'
import { IncrementalCache } from './incremental-cache'

View file

@ -1,8 +1,6 @@
import { posix } from 'path'
export function normalizePathSep(path: string): string {
return path.replace(/\\/g, '/')
}
export { normalizePathSep, denormalizePagePath } from './denormalize-page-path'
export function normalizePagePath(page: string): string {
// If the page is `/` we need to append `/index`, otherwise the returned directory root will be bundles instead of pages
@ -24,13 +22,3 @@ export function normalizePagePath(page: string): string {
}
return page
}
export function denormalizePagePath(page: string) {
page = normalizePathSep(page)
if (page.startsWith('/index/')) {
page = page.slice(6)
} else if (page === '/index') {
page = '/'
}
return page
}

View file

@ -329,7 +329,6 @@ export async function renderToHTML(
const headTags = (...args: any) => callMiddleware('headTags', args)
const didRewrite = (req as any)._nextDidRewrite
const isFallback = !!query.__nextFallback
delete query.__nextFallback
@ -360,23 +359,6 @@ export async function renderToHTML(
}
}
if (
process.env.NODE_ENV !== 'production' &&
(isAutoExport || isFallback) &&
pageIsDynamic &&
didRewrite
) {
// TODO: If we decide to ship rewrites to the client we could
// solve this by running over the rewrites and getting the params.
throw new Error(
`Rewrites don't support${
isFallback ? ' ' : ' auto-exported '
}dynamic pages${isFallback ? ' with getStaticProps ' : ' '}yet.\n` +
`Using this will cause the page to fail to parse the params on the client\n` +
`See more info: https://err.sh/next.js/rewrite-auto-export-fallback`
)
}
if (hasPageGetInitialProps && isSSG) {
throw new Error(SSG_GET_INITIAL_PROPS_CONFLICT + ` ${pathname}`)
}
@ -690,6 +672,27 @@ export async function renderToHTML(
// the response might be finished on the getInitialProps call
if (isResSent(res) && !isSSG) return null
// we preload the buildManifest for auto-export dynamic pages
// to speed up hydrating query values
let filteredBuildManifest = buildManifest
const additionalFiles: string[] = []
if (isAutoExport && pageIsDynamic) {
filteredBuildManifest = {
...buildManifest,
lowPriorityFiles: buildManifest.lowPriorityFiles.filter((file) => {
if (
file.endsWith('_buildManifest.js') ||
file.endsWith('_buildManifest.module.js')
) {
additionalFiles.push(file)
return false
}
return true
}),
}
}
// AMP First pages do not have client-side JavaScript files
const files = ampState.ampFirst
? []
@ -700,6 +703,7 @@ export async function renderToHTML(
? getPageFiles(buildManifest, pathname)
: []),
]),
...additionalFiles,
]
const renderPage: RenderPage = (
@ -766,6 +770,7 @@ export async function renderToHTML(
let html = renderDocument(Document, {
...renderOpts,
buildManifest: filteredBuildManifest,
// Only enabled in production as development mode has features relying on HMR (style injection for example)
unstable_runtimeJS:
process.env.NODE_ENV === 'production'

View file

@ -1,7 +1,6 @@
import { IncomingMessage, ServerResponse } from 'http'
import { parse as parseUrl, UrlWithParsedQuery } from 'url'
import { ParsedUrlQuery } from 'querystring'
import { compile as compilePathToRegex } from 'next/dist/compiled/path-to-regexp'
import { UrlWithParsedQuery } from 'url'
import pathMatch from './lib/path-match'
export const route = pathMatch()
@ -37,86 +36,6 @@ export type PageChecker = (pathname: string) => Promise<boolean>
const customRouteTypes = new Set(['rewrite', 'redirect', 'header'])
export const prepareDestination = (
destination: string,
params: Params,
query: ParsedUrlQuery,
appendParamsToQuery: boolean,
basePath: string
) => {
const parsedDestination = parseUrl(destination, true)
const destQuery = parsedDestination.query
let destinationCompiler = compilePathToRegex(
`${parsedDestination.pathname!}${parsedDestination.hash || ''}`,
// we don't validate while compiling the destination since we should
// have already validated before we got to this point and validating
// breaks compiling destinations with named pattern params from the source
// e.g. /something:hello(.*) -> /another/:hello is broken with validation
// since compile validation is meant for reversing and not for inserting
// params from a separate path-regex into another
{ validate: false }
)
let newUrl
// update any params in query values
for (const [key, strOrArray] of Object.entries(destQuery)) {
let value = Array.isArray(strOrArray) ? strOrArray[0] : strOrArray
if (value) {
// the value needs to start with a forward-slash to be compiled
// correctly
value = `/${value}`
const queryCompiler = compilePathToRegex(value, { validate: false })
value = queryCompiler(params).substr(1)
}
destQuery[key] = value
}
// add path params to query if it's not a redirect and not
// already defined in destination query
if (appendParamsToQuery) {
for (const [name, value] of Object.entries(params)) {
if (!(name in destQuery)) {
destQuery[name] = value
}
}
}
const shouldAddBasePath = destination.startsWith('/') && basePath
try {
newUrl = `${shouldAddBasePath ? basePath : ''}${encodeURI(
destinationCompiler(params)
)}`
const [pathname, hash] = newUrl.split('#')
parsedDestination.pathname = pathname
parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}`
parsedDestination.path = `${pathname}${parsedDestination.search}`
delete parsedDestination.search
} catch (err) {
if (err.message.match(/Expected .*? to not repeat, but got an array/)) {
throw new Error(
`To use a multi-match in the destination you must add \`*\` at the end of the param name to signify it should repeat. https://err.sh/vercel/next.js/invalid-multi-match`
)
}
throw err
}
// Query merge order lowest priority to highest
// 1. initial URL query values
// 2. path segment values
// 3. destination specified query values
parsedDestination.query = {
...query,
...parsedDestination.query,
}
return {
newUrl,
parsedDestination,
}
}
function replaceBasePath(basePath: string, pathname: string) {
return pathname!.replace(basePath, '') || '/'
}

View file

@ -549,6 +549,7 @@ export class NextScript extends Component<OriginProps> {
? { type: 'module' }
: { noModule: true }
}
return (
<script
key={file}

View file

@ -31,6 +31,7 @@ import getRouteFromEntrypoint from '../next-server/server/get-route-from-entrypo
import { isWriteable } from '../build/is-writeable'
import { ClientPagesLoaderOptions } from '../build/webpack/loaders/next-client-pages-loader'
import { stringify } from 'querystring'
import { Rewrite } from '../lib/load-custom-routes'
export async function renderScriptError(
res: ServerResponse,
@ -152,6 +153,7 @@ export default class HotReloader {
private onDemandEntries: any
private previewProps: __ApiPreviewProps
private watcher: any
private rewrites: Rewrite[]
constructor(
dir: string,
@ -160,11 +162,13 @@ export default class HotReloader {
pagesDir,
buildId,
previewProps,
rewrites,
}: {
config: object
pagesDir: string
buildId: string
previewProps: __ApiPreviewProps
rewrites: Rewrite[]
}
) {
this.buildId = buildId
@ -178,6 +182,7 @@ export default class HotReloader {
this.config = config
this.previewProps = previewProps
this.rewrites = rewrites
}
public async run(
@ -285,6 +290,7 @@ export default class HotReloader {
config: this.config,
buildId: this.buildId,
pagesDir: this.pagesDir,
rewrites: this.rewrites,
entrypoints: { ...entrypoints.client, ...additionalClientEntrypoints },
}),
getBaseWebpackConfig(this.dir, {
@ -293,6 +299,7 @@ export default class HotReloader {
config: this.config,
buildId: this.buildId,
pagesDir: this.pagesDir,
rewrites: this.rewrites,
entrypoints: entrypoints.server,
}),
])
@ -509,7 +516,7 @@ export default class HotReloader {
return []
}
private send(action: string, ...args: any[]): void {
public send(action?: string, ...args: any[]): void {
this.webpackHotMiddleware!.publish({ action, data: args })
}

View file

@ -16,7 +16,11 @@ import { fileExists } from '../lib/file-exists'
import { findPagesDir } from '../lib/find-pages-dir'
import loadCustomRoutes, { CustomRoutes } from '../lib/load-custom-routes'
import { verifyTypeScriptSetup } from '../lib/verifyTypeScriptSetup'
import { PHASE_DEVELOPMENT_SERVER } from '../next-server/lib/constants'
import {
PHASE_DEVELOPMENT_SERVER,
CLIENT_STATIC_FILES_PATH,
DEV_CLIENT_PAGES_MANIFEST,
} from '../next-server/lib/constants'
import {
getRouteMatcher,
getRouteRegex,
@ -46,6 +50,8 @@ export default class DevServer extends Server {
private webpackWatcher?: Watchpack | null
private hotReloader?: HotReloader
private isCustomServer: boolean
protected sortedRoutes?: string[]
protected staticPathsWorker: import('jest-worker').default & {
loadStaticPaths: typeof import('./static-paths-worker').loadStaticPaths
}
@ -206,8 +212,22 @@ export default class DevServer extends Server {
routedPages.push(pageName)
}
try {
this.dynamicRoutes = getSortedRoutes(routedPages)
// we serve a separate manifest with all pages for the client in
// dev mode so that we can match a page after a rewrite on the client
// before it has been built and is populated in the _buildManifest
const sortedRoutes = getSortedRoutes(routedPages)
if (
!this.sortedRoutes?.every((val, idx) => val === sortedRoutes[idx])
) {
// emit the change so clients fetch the update
this.hotReloader!.send(undefined, { devPagesManifest: true })
}
this.sortedRoutes = sortedRoutes
this.dynamicRoutes = this.sortedRoutes
.filter(isDynamicRoute)
.map((page) => ({
page,
@ -257,6 +277,7 @@ export default class DevServer extends Server {
config: this.nextConfig,
previewProps: this.getPreviewProps(),
buildId: this.buildId,
rewrites: this.customRoutes.rewrites,
})
await super.prepare()
await this.addExportPathMapRoutes()
@ -411,6 +432,26 @@ export default class DevServer extends Server {
},
})
fsRoutes.unshift({
match: route(
`/_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_CLIENT_PAGES_MANIFEST}`
),
type: 'route',
name: `_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_CLIENT_PAGES_MANIFEST}`,
fn: async (_req, res) => {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(
JSON.stringify({
pages: this.sortedRoutes,
})
)
return {
finished: true,
}
},
})
fsRoutes.push({
match: route('/:path*'),
type: 'route',

View file

@ -98,13 +98,13 @@ describe('Build Output', () => {
expect(parseFloat(indexFirstLoad) - 60).toBeLessThanOrEqual(0)
expect(indexFirstLoad.endsWith('kB')).toBe(true)
expect(parseFloat(err404Size) - 3.6).toBeLessThanOrEqual(0)
expect(parseFloat(err404Size) - 3.8).toBeLessThanOrEqual(0)
expect(err404Size.endsWith('kB')).toBe(true)
expect(parseFloat(err404FirstLoad) - 63).toBeLessThanOrEqual(0)
expect(err404FirstLoad.endsWith('kB')).toBe(true)
expect(parseFloat(sharedByAll) - 59.4).toBeLessThanOrEqual(0)
expect(parseFloat(sharedByAll) - 59.6).toBeLessThanOrEqual(0)
expect(sharedByAll.endsWith('kB')).toBe(true)
if (_appSize.endsWith('kB')) {

View file

@ -10,6 +10,10 @@ module.exports = {
},
]
: []),
{
source: '/rewriting-to-auto-export',
destination: '/auto-export/hello',
},
{
source: '/to-another',
destination: '/another/one',

View file

@ -36,6 +36,12 @@ let appPort
let app
const runTests = (isDev = false) => {
it('should parse params correctly for rewrite to auto-export dynamic page', async () => {
const browser = await webdriver(appPort, '/rewriting-to-auto-export')
const text = await browser.eval(() => document.documentElement.innerHTML)
expect(text).toContain('auto-export hello')
})
it('should handle one-to-one rewrite successfully', async () => {
const html = await renderViaHTTP(appPort, '/first')
expect(html).toMatch(/hello/)
@ -512,6 +518,7 @@ const runTests = (isDev = false) => {
version: 3,
pages404: true,
basePath: '',
dataRoutes: [],
redirects: [
{
destination: '/:path+',
@ -804,6 +811,11 @@ const runTests = (isDev = false) => {
},
],
rewrites: [
{
destination: '/auto-export/hello',
regex: normalizeRegEx('^\\/rewriting-to-auto-export$'),
source: '/rewriting-to-auto-export',
},
{
destination: '/another/one',
regex: normalizeRegEx('^\\/to-another$'),
@ -996,13 +1008,6 @@ const runTests = (isDev = false) => {
}
}
})
} else {
it('should show error for dynamic auto export rewrite', async () => {
const html = await renderViaHTTP(appPort, '/to-another')
expect(html).toContain(
`Rewrites don't support auto-exported dynamic pages yet`
)
})
}
}
@ -1033,6 +1038,16 @@ describe('Custom routes', () => {
await fs.writeFile(nextConfigPath, nextConfigRestoreContent)
})
describe('dev mode', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
buildId = 'development'
})
afterAll(() => killApp(app))
runTests(true)
})
describe('no-op rewrite', () => {
beforeAll(async () => {
appPort = await findPort()
@ -1044,26 +1059,13 @@ describe('Custom routes', () => {
})
afterAll(() => killApp(app))
it('should not show error for no-op rewrite and auto export dynamic route', async () => {
it('should not error for no-op rewrite and auto export dynamic route', async () => {
const browser = await webdriver(appPort, '/auto-export/my-slug')
const html = await browser.eval(() => document.documentElement.innerHTML)
expect(html).not.toContain(
`Rewrites don't support auto-exported dynamic pages yet`
)
expect(html).toContain(`auto-export my-slug`)
})
})
describe('dev mode', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
buildId = 'development'
})
afterAll(() => killApp(app))
runTests(true)
})
describe('server mode', () => {
beforeAll(async () => {
const { stdout: buildStdout } = await nextBuild(appDir, [], {

View file

@ -9,6 +9,10 @@ const Page = () => {
<a id="view-post-1">View post 1</a>
</Link>
<br />
<Link href="/post-1">
<a id="view-post-1-no-as">View post 1</a>
</Link>
<br />
<Link href="/[name]/comments" as="/post-1/comments">
<a id="view-post-1-comments">View post 1 comments</a>
</Link>
@ -17,6 +21,14 @@ const Page = () => {
<a id="view-post-1-comment-1">View comment 1 on post 1</a>
</Link>
<br />
<Link href="/post-1/comment-1">
<a id="view-post-1-comment-1-no-as">View comment 1 on post 1</a>
</Link>
<br />
<Link href="/added-later/first">
<a id="added-later-link">/added-later/first</a>
</Link>
<br />
<Link href="/blog/[post]/comment/[id]" as="/blog/321/comment/123">
<a id="view-nested-dynamic-cmnt">View comment 123 on blog post 321</a>
</Link>
@ -49,6 +61,9 @@ const Page = () => {
<Link href="/p1/p2/all-ssg/[...rest]" as="/p1/p2/all-ssg/hello1/hello2">
<a id="ssg-catch-all-multi">Catch-all route (multi)</a>
</Link>
<Link href="/p1/p2/all-ssg/hello1/hello2">
<a id="ssg-catch-all-multi-no-as">Catch-all route (multi)</a>
</Link>
<Link
href="/p1/p2/nested-all-ssg/[...rest]"
as="/p1/p2/nested-all-ssg/hello"

View file

@ -1,7 +1,7 @@
/* eslint-env jest */
import webdriver from 'next-webdriver'
import { join } from 'path'
import { join, dirname } from 'path'
import fs from 'fs-extra'
import {
renderViaHTTP,
@ -13,6 +13,7 @@ import {
nextBuild,
nextStart,
normalizeRegEx,
check,
} from 'next-test-utils'
import cheerio from 'cheerio'
import escapeRegex from 'escape-string-regexp'
@ -86,9 +87,29 @@ function runTests(dev) {
let browser
try {
browser = await webdriver(appPort, '/')
await browser.eval('window.beforeNav = 1')
await browser.elementByCss('#view-post-1').click()
await browser.waitForElementByCss('#asdf')
expect(await browser.eval('window.beforeNav')).toBe(1)
const text = await browser.elementByCss('#asdf').text()
expect(text).toMatch(/this is.*?post-1/i)
} finally {
if (browser) await browser.close()
}
})
it('should navigate to a dynamic page successfully no as', async () => {
let browser
try {
browser = await webdriver(appPort, '/')
await browser.eval('window.beforeNav = 1')
await browser.elementByCss('#view-post-1-no-as').click()
await browser.waitForElementByCss('#asdf')
expect(await browser.eval('window.beforeNav')).toBe(1)
const text = await browser.elementByCss('#asdf').text()
expect(text).toMatch(/this is.*?post-1/i)
} finally {
@ -137,9 +158,29 @@ function runTests(dev) {
let browser
try {
browser = await webdriver(appPort, '/')
await browser.eval('window.beforeNav = 1')
await browser.elementByCss('#view-post-1-comment-1').click()
await browser.waitForElementByCss('#asdf')
expect(await browser.eval('window.beforeNav')).toBe(1)
const text = await browser.elementByCss('#asdf').text()
expect(text).toMatch(/i am.*comment-1.*on.*post-1/i)
} finally {
if (browser) await browser.close()
}
})
it('should navigate to a nested dynamic page successfully no as', async () => {
let browser
try {
browser = await webdriver(appPort, '/')
await browser.eval('window.beforeNav = 1')
await browser.elementByCss('#view-post-1-comment-1-no-as').click()
await browser.waitForElementByCss('#asdf')
expect(await browser.eval('window.beforeNav')).toBe(1)
const text = await browser.elementByCss('#asdf').text()
expect(text).toMatch(/i am.*comment-1.*on.*post-1/i)
} finally {
@ -354,9 +395,29 @@ function runTests(dev) {
let browser
try {
browser = await webdriver(appPort, '/')
await browser.eval('window.beforeNav = 1')
await browser.elementByCss('#ssg-catch-all-multi').click()
await browser.waitForElementByCss('#all-ssg-content')
expect(await browser.eval('window.beforeNav')).toBe(1)
const text = await browser.elementByCss('#all-ssg-content').text()
expect(text).toBe('{"rest":["hello1","hello2"]}')
} finally {
if (browser) await browser.close()
}
})
it('[ssg: catch-all] should pass params in getStaticProps during client navigation (multi) no as', async () => {
let browser
try {
browser = await webdriver(appPort, '/')
await browser.eval('window.beforeNav = 1')
await browser.elementByCss('#ssg-catch-all-multi-no-as').click()
await browser.waitForElementByCss('#all-ssg-content')
expect(await browser.eval('window.beforeNav')).toBe(1)
const text = await browser.elementByCss('#all-ssg-content').text()
expect(text).toBe('{"rest":["hello1","hello2"]}')
} finally {
@ -501,6 +562,39 @@ function runTests(dev) {
})
if (dev) {
it('should resolve dynamic route href for page added later', async () => {
const browser = await webdriver(appPort, '/')
const addLaterPage = join(appDir, 'pages/added-later/[slug].js')
await fs.mkdir(dirname(addLaterPage)).catch(() => {})
await fs.writeFile(
addLaterPage,
`
import { useRouter } from 'next/router'
export default function Page() {
return <p id='added-later'>slug: {useRouter().query.slug}</p>
}
`
)
await check(async () => {
const contents = await renderViaHTTP(
appPort,
'/_next/static/development/_devPagesManifest.json'
)
return contents.includes('added-later') ? 'success' : 'fail'
}, 'success')
await browser.elementByCss('#added-later-link').click()
await browser.waitForElementByCss('#added-later')
const text = await browser.elementByCss('#added-later').text()
await fs.remove(dirname(addLaterPage))
expect(text).toBe('slug: first')
})
it('should work with HMR correctly', async () => {
const browser = await webdriver(appPort, '/post-1/comments')
let text = await browser.eval(`document.documentElement.innerHTML`)

View file

@ -1,6 +1,11 @@
module.exports = {
// target
rewrites() {
return [
{
source: '/some-rewrite/:item',
destination: '/blog/post-:item',
},
{
source: '/about',
destination: '/lang/en/about',

View file

@ -29,15 +29,15 @@ import url from 'url'
jest.setTimeout(1000 * 60 * 2)
const appDir = join(__dirname, '..')
const nextConfig = join(appDir, 'next.config.js')
const nextConfigPath = join(appDir, 'next.config.js')
const indexPage = join(__dirname, '../pages/index.js')
const nextConfig = new File(nextConfigPath)
let app
let appPort
let buildId
let distPagesDir
let exportDir
let stderr
let origConfig
const startServer = async (optEnv = {}) => {
const scriptPath = join(appDir, 'server.js')
@ -796,17 +796,27 @@ const runTests = (dev = false, isEmulatedServerless = false) => {
expect(await browser.eval('window.beforeNav')).toBe('hi')
expect(await browser.elementByCss('#about').text()).toBe('About: en')
})
it('should not error when rewriting to fallback dynamic SSG page', async () => {
const item = Math.round(Math.random() * 100)
const browser = await webdriver(appPort, `/some-rewrite/${item}`)
await check(
() => browser.elementByCss('p').text(),
new RegExp(`Post: post-${item}`)
)
expect(JSON.parse(await browser.elementByCss('#params').text())).toEqual({
post: `post-${item}`,
})
expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
item: item + '',
post: `post-${item}`,
})
})
}
if (dev) {
it('should show error when rewriting to dynamic SSG page', async () => {
const item = Math.round(Math.random() * 100)
const html = await renderViaHTTP(appPort, `/some-rewrite/${item}`)
expect(html).toContain(
`Rewrites don't support dynamic pages with getStaticProps yet`
)
})
it('should not show warning from url prop being returned', async () => {
const urlPropPage = join(appDir, 'pages/url-prop.js')
await fs.writeFile(
@ -1707,30 +1717,6 @@ const runTests = (dev = false, isEmulatedServerless = false) => {
describe('SSG Prerender', () => {
describe('dev mode', () => {
beforeAll(async () => {
origConfig = await fs.readFile(nextConfig, 'utf8')
await fs.writeFile(
nextConfig,
`
module.exports = {
rewrites() {
return [
{
source: "/some-rewrite/:item",
destination: "/blog/post-:item"
},
{
source: '/about',
destination: '/lang/en/about'
},
{
source: '/blocked-create',
destination: '/blocking-fallback/blocked-create',
}
]
}
}
`
)
appPort = await findPort()
app = await launchApp(appDir, appPort, {
env: { __NEXT_TEST_WITH_DEVTOOL: 1 },
@ -1741,7 +1727,6 @@ describe('SSG Prerender', () => {
buildId = 'development'
})
afterAll(async () => {
await fs.writeFile(nextConfig, origConfig)
await killApp(app)
})
@ -1750,13 +1735,10 @@ describe('SSG Prerender', () => {
describe('dev mode getStaticPaths', () => {
beforeAll(async () => {
origConfig = await fs.readFile(nextConfig, 'utf8')
await fs.writeFile(
nextConfig,
nextConfig.write(
// we set cpus to 1 so that we make sure the requests
// aren't being cached at the jest-worker level
`module.exports = { experimental: { cpus: 1 } }`,
'utf8'
`module.exports = { experimental: { cpus: 1 } }`
)
await fs.remove(join(appDir, '.next'))
appPort = await findPort()
@ -1765,7 +1747,7 @@ describe('SSG Prerender', () => {
})
})
afterAll(async () => {
await fs.writeFile(nextConfig, origConfig)
nextConfig.restore()
await killApp(app)
})
@ -1808,7 +1790,6 @@ describe('SSG Prerender', () => {
beforeAll(async () => {
// remove firebase import since it breaks in legacy serverless mode
origBlogPageContent = await fs.readFile(blogPagePath, 'utf8')
origConfig = await fs.readFile(nextConfig, 'utf8')
await fs.writeFile(
blogPagePath,
@ -1818,25 +1799,7 @@ describe('SSG Prerender', () => {
)
)
await fs.writeFile(
nextConfig,
`module.exports = {
target: 'serverless',
rewrites() {
return [
{
source: '/about',
destination: '/lang/en/about'
},
{
source: '/blocked-create',
destination: '/blocking-fallback/blocked-create',
}
]
}
}`,
'utf8'
)
nextConfig.replace('// target', `target: 'serverless',`)
await fs.remove(join(appDir, '.next'))
await nextBuild(appDir)
stderr = ''
@ -1850,7 +1813,7 @@ describe('SSG Prerender', () => {
buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8')
})
afterAll(async () => {
await fs.writeFile(nextConfig, origConfig)
nextConfig.restore()
await fs.writeFile(blogPagePath, origBlogPageContent)
await killApp(app)
})
@ -1931,11 +1894,9 @@ describe('SSG Prerender', () => {
})
}
origConfig = await fs.readFile(nextConfig, 'utf8')
await fs.writeFile(
nextConfig,
`module.exports = { target: 'experimental-serverless-trace' }`,
'utf8'
await nextConfig.replace(
'// target',
`target: 'experimental-serverless-trace',`
)
await fs.writeFile(
cstmError,
@ -1963,8 +1924,8 @@ describe('SSG Prerender', () => {
app = await startServerlessEmulator(appDir, appPort, buildId)
})
afterAll(async () => {
nextConfig.restore()
await fs.remove(cstmError)
await fs.writeFile(nextConfig, origConfig)
await killApp(app)
})
@ -2024,9 +1985,7 @@ describe('SSG Prerender', () => {
beforeAll(async () => {
exportDir = join(appDir, 'out')
origConfig = await fs.readFile(nextConfig, 'utf8')
await fs.writeFile(
nextConfig,
nextConfig.write(
`module.exports = {
exportTrailingSlash: true,
exportPathMap: function(defaultPathMap) {
@ -2075,7 +2034,7 @@ describe('SSG Prerender', () => {
buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8')
})
afterAll(async () => {
await fs.writeFile(nextConfig, origConfig)
nextConfig.restore()
await stopApp(app)
for (const page of fallbackTruePages) {

View file

@ -80,7 +80,7 @@ describe('Production response size', () => {
)
// These numbers are without gzip compression!
const delta = responseSizesBytes - 275 * 1024
const delta = responseSizesBytes - 277 * 1024
expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb
expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target
})