/* global __NEXT_DATA__ */
// tslint:disable:no-console
import { ParsedUrlQuery } from 'querystring'
import { ComponentType } from 'react'
import { UrlObject } from 'url'
import {
normalizePathTrailingSlash,
removePathTrailingSlash,
} from '../../../client/normalize-trailing-slash'
import { GoodPageCache, StyleSheetTuple } from '../../../client/page-loader'
import {
getClientBuildManifest,
isAssetError,
markAssetError,
} from '../../../client/route-loader'
import { DomainLocales } from '../../server/config'
import { denormalizePagePath } from '../../server/denormalize-page-path'
import { normalizeLocalePath } from '../i18n/normalize-locale-path'
import mitt, { MittEmitter } from '../mitt'
import {
AppContextType,
formatWithValidation,
getLocationOrigin,
getURL,
loadGetInitialProps,
NextPageContext,
ST,
NEXT_DATA,
} from '../utils'
import { isDynamicRoute } from './utils/is-dynamic'
import { parseRelativeUrl } from './utils/parse-relative-url'
import { searchParamsToUrlQuery } from './utils/querystring'
import resolveRewrites from './utils/resolve-rewrites'
import { getRouteMatcher } from './utils/route-matcher'
import { getRouteRegex } from './utils/route-regex'
declare global {
interface Window {
/* prod */
__NEXT_DATA__: NEXT_DATA
}
}
interface RouteProperties {
shallow: boolean
}
interface TransitionOptions {
shallow?: boolean
locale?: string | false
scroll?: boolean
}
interface NextHistoryState {
url: string
as: string
options: TransitionOptions
}
type HistoryState =
| null
| { __N: false }
| ({ __N: true; idx: number } & NextHistoryState)
let detectDomainLocale: typeof import('../i18n/detect-domain-locale').detectDomainLocale
if (process.env.__NEXT_I18N_SUPPORT) {
detectDomainLocale = require('../i18n/detect-domain-locale')
.detectDomainLocale
}
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
function buildCancellationError() {
return Object.assign(new Error('Route Cancelled'), {
cancelled: true,
})
}
function addPathPrefix(path: string, prefix?: string) {
return prefix && path.startsWith('/')
? path === '/'
? normalizePathTrailingSlash(prefix)
: `${prefix}${pathNoQueryHash(path) === '/' ? path.substring(1) : path}`
: path
}
export function getDomainLocale(
path: string,
locale?: string | false,
locales?: string[],
domainLocales?: DomainLocales
) {
if (process.env.__NEXT_I18N_SUPPORT) {
locale = locale || normalizeLocalePath(path, locales).detectedLocale
const detectedDomain = detectDomainLocale(domainLocales, undefined, locale)
if (detectedDomain) {
return `http${detectedDomain.http ? '' : 's'}://${detectedDomain.domain}${
basePath || ''
}${locale === detectedDomain.defaultLocale ? '' : `/${locale}`}${path}`
}
return false
}
return false
}
export function addLocale(
path: string,
locale?: string | false,
defaultLocale?: string
) {
if (process.env.__NEXT_I18N_SUPPORT) {
const pathname = pathNoQueryHash(path)
const pathLower = pathname.toLowerCase()
const localeLower = locale && locale.toLowerCase()
return locale &&
locale !== defaultLocale &&
!pathLower.startsWith('/' + localeLower + '/') &&
pathLower !== '/' + localeLower
? addPathPrefix(path, '/' + locale)
: path
}
return path
}
export function delLocale(path: string, locale?: string) {
if (process.env.__NEXT_I18N_SUPPORT) {
const pathname = pathNoQueryHash(path)
const pathLower = pathname.toLowerCase()
const localeLower = locale && locale.toLowerCase()
return locale &&
(pathLower.startsWith('/' + localeLower + '/') ||
pathLower === '/' + localeLower)
? (pathname.length === locale.length + 1 ? '/' : '') +
path.substr(locale.length + 1)
: path
}
return path
}
function pathNoQueryHash(path: string) {
const queryIndex = path.indexOf('?')
const hashIndex = path.indexOf('#')
if (queryIndex > -1 || hashIndex > -1) {
path = path.substring(0, queryIndex > -1 ? queryIndex : hashIndex)
}
return path
}
export function hasBasePath(path: string): boolean {
path = pathNoQueryHash(path)
return path === basePath || path.startsWith(basePath + '/')
}
export function addBasePath(path: string): string {
// we only add the basepath on relative urls
return addPathPrefix(path, basePath)
}
export function delBasePath(path: string): string {
path = path.slice(basePath.length)
if (!path.startsWith('/')) path = `/${path}`
return path
}
/**
* Detects whether a given url is routable by the Next.js router (browser only).
*/
export function isLocalURL(url: string): boolean {
// prevent a hydration mismatch on href for url with anchor refs
if (url.startsWith('/') || url.startsWith('#')) return true
try {
// absolute urls can be local if they are on the same origin
const locationOrigin = getLocationOrigin()
const resolved = new URL(url, locationOrigin)
return resolved.origin === locationOrigin && hasBasePath(resolved.pathname)
} catch (_) {
return false
}
}
type Url = UrlObject | string
export function interpolateAs(
route: string,
asPathname: string,
query: ParsedUrlQuery
) {
let interpolatedRoute = ''
const dynamicRegex = getRouteRegex(route)
const dynamicGroups = dynamicRegex.groups
const dynamicMatches =
// Try to match the dynamic route against the asPath
(asPathname !== route ? getRouteMatcher(dynamicRegex)(asPathname) : '') ||
// Fall back to reading the values from the href
// TODO: should this take priority; also need to change in the router.
query
interpolatedRoute = route
const params = Object.keys(dynamicGroups)
if (
!params.every((param) => {
let value = dynamicMatches[param] || ''
const { repeat, optional } = dynamicGroups[param]
// support single-level catch-all
// TODO: more robust handling for user-error (passing `/`)
let replaced = `[${repeat ? '...' : ''}${param}]`
if (optional) {
replaced = `${!value ? '/' : ''}[${replaced}]`
}
if (repeat && !Array.isArray(value)) value = [value]
return (
(optional || param in dynamicMatches) &&
// Interpolate group into data URL if present
(interpolatedRoute =
interpolatedRoute!.replace(
replaced,
repeat
? (value as string[])
.map(
// these values should be fully encoded instead of just
// path delimiter escaped since they are being inserted
// into the URL and we expect URL encoded segments
// when parsing dynamic route params
(segment) => encodeURIComponent(segment)
)
.join('/')
: encodeURIComponent(value as string)
) || '/')
)
})
) {
interpolatedRoute = '' // did not satisfy all requirements
// n.b. We ignore this error because we handle warning for this case in
// development in the `` component directly.
}
return {
params,
result: interpolatedRoute,
}
}
function omitParmsFromQuery(query: ParsedUrlQuery, params: string[]) {
const filteredQuery: ParsedUrlQuery = {}
Object.keys(query).forEach((key) => {
if (!params.includes(key)) {
filteredQuery[key] = query[key]
}
})
return filteredQuery
}
/**
* Resolves a given hyperlink with a certain router state (basePath not included).
* Preserves absolute urls.
*/
export function resolveHref(
currentPath: string,
href: Url,
resolveAs?: boolean
): string {
// we use a dummy base url for relative urls
const base = new URL(currentPath, 'http://n')
const urlAsString =
typeof href === 'string' ? href : formatWithValidation(href)
// Return because it cannot be routed by the Next.js router
if (!isLocalURL(urlAsString)) {
return (resolveAs ? [urlAsString] : urlAsString) as string
}
try {
const finalUrl = new URL(urlAsString, base)
finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname)
let interpolatedAs = ''
if (
isDynamicRoute(finalUrl.pathname) &&
finalUrl.searchParams &&
resolveAs
) {
const query = searchParamsToUrlQuery(finalUrl.searchParams)
const { result, params } = interpolateAs(
finalUrl.pathname,
finalUrl.pathname,
query
)
if (result) {
interpolatedAs = formatWithValidation({
pathname: result,
hash: finalUrl.hash,
query: omitParmsFromQuery(query, params),
})
}
}
// if the origin didn't change, it means we received a relative href
const resolvedHref =
finalUrl.origin === base.origin
? finalUrl.href.slice(finalUrl.origin.length)
: finalUrl.href
return (resolveAs
? [resolvedHref, interpolatedAs || resolvedHref]
: resolvedHref) as string
} catch (_) {
return (resolveAs ? [urlAsString] : urlAsString) as string
}
}
function stripOrigin(url: string) {
const origin = getLocationOrigin()
return url.startsWith(origin) ? url.substring(origin.length) : url
}
function prepareUrlAs(router: NextRouter, url: Url, as?: Url) {
// If url and as provided as an object representation,
// we'll format them into the string version here.
let [resolvedHref, resolvedAs] = resolveHref(router.pathname, url, true)
const origin = getLocationOrigin()
const hrefHadOrigin = resolvedHref.startsWith(origin)
const asHadOrigin = resolvedAs && resolvedAs.startsWith(origin)
resolvedHref = stripOrigin(resolvedHref)
resolvedAs = resolvedAs ? stripOrigin(resolvedAs) : resolvedAs
const preparedUrl = hrefHadOrigin ? resolvedHref : addBasePath(resolvedHref)
const preparedAs = as
? stripOrigin(resolveHref(router.pathname, as))
: resolvedAs || resolvedHref
return {
url: preparedUrl,
as: asHadOrigin ? preparedAs : addBasePath(preparedAs),
}
}
function resolveDynamicRoute(pathname: string, pages: string[]) {
const cleanPathname = removePathTrailingSlash(denormalizePagePath(pathname!))
if (cleanPathname === '/404' || cleanPathname === '/_error') {
return pathname
}
// handle resolving href for dynamic routes
if (!pages.includes(cleanPathname!)) {
// eslint-disable-next-line array-callback-return
pages.some((page) => {
if (isDynamicRoute(page) && getRouteRegex(page).re.test(cleanPathname!)) {
pathname = page
return true
}
})
}
return removePathTrailingSlash(pathname)
}
export type BaseRouter = {
route: string
pathname: string
query: ParsedUrlQuery
asPath: string
basePath: string
locale?: string
locales?: string[]
defaultLocale?: string
domainLocales?: DomainLocales
isLocaleDomain: boolean
}
export type NextRouter = BaseRouter &
Pick<
Router,
| 'push'
| 'replace'
| 'reload'
| 'back'
| 'prefetch'
| 'beforePopState'
| 'events'
| 'isFallback'
| 'isReady'
| 'isPreview'
>
export type PrefetchOptions = {
priority?: boolean
locale?: string | false
}
export type PrivateRouteInfo =
| (Omit & { initial: true })
| CompletePrivateRouteInfo
export type CompletePrivateRouteInfo = {
Component: ComponentType
styleSheets: StyleSheetTuple[]
__N_SSG?: boolean
__N_SSP?: boolean
props?: Record
err?: Error
error?: any
}
export type AppProps = Pick & {
router: Router
} & Record
export type AppComponent = ComponentType
type Subscription = (
data: PrivateRouteInfo,
App: AppComponent,
resetScroll: { x: number; y: number } | null
) => Promise
type BeforePopStateCallback = (state: NextHistoryState) => boolean
type ComponentLoadCancel = (() => void) | null
type HistoryMethod = 'replaceState' | 'pushState'
const manualScrollRestoration =
process.env.__NEXT_SCROLL_RESTORATION &&
typeof window !== 'undefined' &&
'scrollRestoration' in window.history &&
!!(function () {
try {
let v = '__next'
// eslint-disable-next-line no-sequences
return sessionStorage.setItem(v, v), sessionStorage.removeItem(v), true
} catch (n) {}
})()
const SSG_DATA_NOT_FOUND = Symbol('SSG_DATA_NOT_FOUND')
function fetchRetry(url: string, attempts: number): Promise {
return fetch(url, {
// Cookies are required to be present for Next.js' SSG "Preview Mode".
// Cookies may also be required for `getServerSideProps`.
//
// > `fetch` won’t send cookies, unless you set the credentials init
// > option.
// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
//
// > For maximum browser compatibility when it comes to sending &
// > receiving cookies, always supply the `credentials: 'same-origin'`
// > option instead of relying on the default.
// https://github.com/github/fetch#caveats
credentials: 'same-origin',
}).then((res) => {
if (!res.ok) {
if (attempts > 1 && res.status >= 500) {
return fetchRetry(url, attempts - 1)
}
if (res.status === 404) {
return res.json().then((data) => {
if (data.notFound) {
return { notFound: SSG_DATA_NOT_FOUND }
}
throw new Error(`Failed to load static props`)
})
}
throw new Error(`Failed to load static props`)
}
return res.json()
})
}
function fetchNextData(dataHref: string, isServerRender: boolean) {
return fetchRetry(dataHref, isServerRender ? 3 : 1).catch((err: Error) => {
// We should only trigger a server-side transition if this was caused
// on a client-side transition. Otherwise, we'd get into an infinite
// loop.
if (!isServerRender) {
markAssetError(err)
}
throw err
})
}
export default class Router implements BaseRouter {
route: string
pathname: string
query: ParsedUrlQuery
asPath: string
basePath: string
/**
* Map of all components loaded in `Router`
*/
components: { [pathname: string]: PrivateRouteInfo }
// Static Data Cache
sdc: { [asPath: string]: object } = {}
// In-flight Server Data Requests, for deduping
sdr: { [asPath: string]: Promise