rsnext/packages/next/server/web/next-url.ts
Javi Velasco 2d5d43fb75
Refactor server routing (#37725)
This PR fixes an issue where we have a middleware that rewrites every single request to the same origin while having `i18n` configured. It would be something like: 

```typescript
import { NextResponse } from 'next/server'

export function middleware(req) {
  return NextResponse.rewrite(req.nextUrl)
}
```

In this case we are going to be adding always the `locale` at the beginning of the destination since it is a rewrite. This causes static assets to not match and the whole application to break. I believe this is a potential footgun so in this PR we are addressing the issue by removing the locale from pathname for those cases where we check against the filesystem (e.g. public folder).

To achieve this change, this PR introduces some preparation changes and then a refactor of the logic in the server router. After this refactor we are going to be relying on properties that can be defined in the `Route` to decide wether or not we should remove the `basePath`, `locale`, etc instead of checking which _type_ of route it is that we are matching.

Overall this simplifies quite a lot the server router. The way we are testing the mentioned issue is by adding a default rewrite in the rewrite tests middleware.
2022-06-16 21:43:01 +00:00

266 lines
6 KiB
TypeScript

import type { DomainLocale, I18NConfig } from '../config-shared'
import { detectDomainLocale } from '../../shared/lib/i18n/detect-domain-locale'
import { formatNextPathnameInfo } from '../../shared/lib/router/utils/format-next-pathname-info'
import { getHostname } from '../../shared/lib/get-hostname'
import { getNextPathnameInfo } from '../../shared/lib/router/utils/get-next-pathname-info'
interface Options {
base?: string | URL
headers?: { [key: string]: string | string[] | undefined }
forceLocale?: boolean
nextConfig?: {
basePath?: string
i18n?: I18NConfig | null
trailingSlash?: boolean
}
}
const Internal = Symbol('NextURLInternal')
export class NextURL {
[Internal]: {
basePath: string
buildId?: string
defaultLocale?: string
domainLocale?: DomainLocale
locale?: string
options: Options
trailingSlash?: boolean
url: URL
}
constructor(input: string | URL, base?: string | URL, opts?: Options)
constructor(input: string | URL, opts?: Options)
constructor(
input: string | URL,
baseOrOpts?: string | URL | Options,
opts?: Options
) {
let base: undefined | string | URL
let options: Options
if (
(typeof baseOrOpts === 'object' && 'pathname' in baseOrOpts) ||
typeof baseOrOpts === 'string'
) {
base = baseOrOpts
options = opts || {}
} else {
options = opts || baseOrOpts || {}
}
this[Internal] = {
url: parseURL(input, base ?? options.base),
options: options,
basePath: '',
}
this.analyzeUrl()
}
private analyzeUrl() {
const pathnameInfo = getNextPathnameInfo(this[Internal].url.pathname, {
nextConfig: this[Internal].options.nextConfig,
parseData: true,
})
this[Internal].domainLocale = detectDomainLocale(
this[Internal].options.nextConfig?.i18n?.domains,
getHostname(this[Internal].url, this[Internal].options.headers)
)
const defaultLocale =
this[Internal].domainLocale?.defaultLocale ||
this[Internal].options.nextConfig?.i18n?.defaultLocale
this[Internal].url.pathname = pathnameInfo.pathname
this[Internal].defaultLocale = defaultLocale
this[Internal].basePath = pathnameInfo.basePath ?? ''
this[Internal].buildId = pathnameInfo.buildId
this[Internal].locale = pathnameInfo.locale ?? defaultLocale
this[Internal].trailingSlash = pathnameInfo.trailingSlash
}
private formatPathname() {
return formatNextPathnameInfo({
basePath: this[Internal].basePath,
buildId: this[Internal].buildId,
defaultLocale: !this[Internal].options.forceLocale
? this[Internal].defaultLocale
: undefined,
locale: this[Internal].locale,
pathname: this[Internal].url.pathname,
trailingSlash: this[Internal].trailingSlash,
})
}
public get buildId() {
return this[Internal].buildId
}
public set buildId(buildId: string | undefined) {
this[Internal].buildId = buildId
}
public get locale() {
return this[Internal].locale ?? ''
}
public set locale(locale: string) {
if (
!this[Internal].locale ||
!this[Internal].options.nextConfig?.i18n?.locales.includes(locale)
) {
throw new TypeError(
`The NextURL configuration includes no locale "${locale}"`
)
}
this[Internal].locale = locale
}
get defaultLocale() {
return this[Internal].defaultLocale
}
get domainLocale() {
return this[Internal].domainLocale
}
get searchParams() {
return this[Internal].url.searchParams
}
get host() {
return this[Internal].url.host
}
set host(value: string) {
this[Internal].url.host = value
}
get hostname() {
return this[Internal].url.hostname
}
set hostname(value: string) {
this[Internal].url.hostname = value
}
get port() {
return this[Internal].url.port
}
set port(value: string) {
this[Internal].url.port = value
}
get protocol() {
return this[Internal].url.protocol
}
set protocol(value: string) {
this[Internal].url.protocol = value
}
get href() {
const pathname = this.formatPathname()
return `${this.protocol}//${this.host}${pathname}${this[Internal].url.search}`
}
set href(url: string) {
this[Internal].url = parseURL(url)
this.analyzeUrl()
}
get origin() {
return this[Internal].url.origin
}
get pathname() {
return this[Internal].url.pathname
}
set pathname(value: string) {
this[Internal].url.pathname = value
}
get hash() {
return this[Internal].url.hash
}
set hash(value: string) {
this[Internal].url.hash = value
}
get search() {
return this[Internal].url.search
}
set search(value: string) {
this[Internal].url.search = value
}
get password() {
return this[Internal].url.password
}
set password(value: string) {
this[Internal].url.password = value
}
get username() {
return this[Internal].url.username
}
set username(value: string) {
this[Internal].url.username = value
}
get basePath() {
return this[Internal].basePath
}
set basePath(value: string) {
this[Internal].basePath = value.startsWith('/') ? value : `/${value}`
}
toString() {
return this.href
}
toJSON() {
return this.href
}
[Symbol.for('edge-runtime.inspect.custom')]() {
return {
href: this.href,
origin: this.origin,
protocol: this.protocol,
username: this.username,
password: this.password,
host: this.host,
hostname: this.hostname,
port: this.port,
pathname: this.pathname,
search: this.search,
searchParams: this.searchParams,
hash: this.hash,
}
}
clone() {
return new NextURL(String(this), this[Internal].options)
}
}
const REGEX_LOCALHOST_HOSTNAME =
/(?!^https?:\/\/)(127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|::1|localhost)/
function parseURL(url: string | URL, base?: string | URL) {
return new URL(
String(url).replace(REGEX_LOCALHOST_HOSTNAME, 'localhost'),
base && String(base).replace(REGEX_LOCALHOST_HOSTNAME, 'localhost')
)
}