From d027c2b55a751355ef4065689cb5315dc1ca5c1f Mon Sep 17 00:00:00 2001 From: Javi Velasco Date: Fri, 3 Dec 2021 13:22:13 +0100 Subject: [PATCH] Improve NextURL --- packages/next/server/web/next-url.ts | 206 +++++++++++++------------ test/unit/web-runtime/next-url.test.ts | 116 +++++++++----- 2 files changed, 186 insertions(+), 136 deletions(-) diff --git a/packages/next/server/web/next-url.ts b/packages/next/server/web/next-url.ts index cba854d0d3079ef..e1287b45dc2948e 100644 --- a/packages/next/server/web/next-url.ts +++ b/packages/next/server/web/next-url.ts @@ -1,66 +1,77 @@ import type { PathLocale } from '../../shared/lib/i18n/normalize-locale-path' import type { DomainLocale, I18NConfig } from '../config-shared' import { getLocaleMetadata } from '../../shared/lib/i18n/get-locale-metadata' -import cookie from 'next/dist/compiled/cookie' import { replaceBasePath } from '../router' - -/** - * TODO - * - * - Add comments to the URLNext API. - * - Move internals to be using symbols for its shape. - * - Make sure logging does not show any implementation details. - * - Include in the event payload the nextJS configuration - */ +import cookie from 'next/dist/compiled/cookie' interface Options { + base?: string | URL basePath?: string headers?: { [key: string]: string | string[] | undefined } i18n?: I18NConfig | null trailingSlash?: boolean } -const REGEX_LOCALHOST_HOSTNAME = - /(?!^https?:\/\/)(127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|::1)/ +const Internal = Symbol('NextURLInternal') export class NextURL extends URL { - private _basePath: string - private _locale?: { - defaultLocale: string - domain?: DomainLocale - locale: string - path: PathLocale - redirect?: string - trailingSlash?: boolean - } - private _options: Options - private _url: URL - - constructor(input: string, options: Options = {}) { - const url = createWHATWGURL(input) - super(url) - this._options = options - this._basePath = '' - this._url = url - this.analyzeUrl() + [Internal]: { + url: URL + options: Options + basePath: string + locale?: { + defaultLocale: string + domain?: DomainLocale + locale: string + path: PathLocale + redirect?: string + trailingSlash?: boolean + } } - get absolute() { - return this._url.hostname !== 'localhost' + 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 + ) { + super('http://127.0.0.1') // This works as a placeholder + + let base: undefined | string | URL + let options: Options + + if (baseOrOpts instanceof URL || typeof baseOrOpts === 'string') { + base = baseOrOpts + options = opts || {} + } else { + options = opts || baseOrOpts || {} + } + + this[Internal] = { + url: parseURL(input, base ?? options.base), + options: options, + basePath: '', + } + + this.analyzeUrl() } - analyzeUrl() { - const { headers = {}, basePath, i18n } = this._options + private analyzeUrl() { + const { headers = {}, basePath, i18n } = this[Internal].options - if (basePath && this._url.pathname.startsWith(basePath)) { - this._url.pathname = replaceBasePath(this._url.pathname, basePath) - this._basePath = basePath + if (basePath && this[Internal].url.pathname.startsWith(basePath)) { + this[Internal].url.pathname = replaceBasePath( + this[Internal].url.pathname, + basePath + ) + this[Internal].basePath = basePath } else { - this._basePath = '' + this[Internal].basePath = '' } if (i18n) { - this._locale = getLocaleMetadata({ + this[Internal].locale = getLocaleMetadata({ cookies: () => { const value = headers['cookie'] return value @@ -73,154 +84,156 @@ export class NextURL extends URL { i18n: i18n, }, url: { - hostname: this._url.hostname || null, - pathname: this._url.pathname, + hostname: this[Internal].url.hostname || null, + pathname: this[Internal].url.pathname, }, }) - if (this._locale?.path.detectedLocale) { - this._url.pathname = this._locale.path.pathname + if (this[Internal].locale?.path.detectedLocale) { + this[Internal].url.pathname = this[Internal].locale!.path.pathname } } } - formatPathname() { - const { i18n } = this._options - let pathname = this._url.pathname + private formatPathname() { + const { i18n } = this[Internal].options + let pathname = this[Internal].url.pathname - if (this._locale?.locale && i18n?.defaultLocale !== this._locale?.locale) { - pathname = `/${this._locale?.locale}${pathname}` + if ( + this[Internal].locale?.locale && + i18n?.defaultLocale !== this[Internal].locale?.locale + ) { + pathname = `/${this[Internal].locale?.locale}${pathname}` } - if (this._basePath) { - pathname = `${this._basePath}${pathname}` + if (this[Internal].basePath) { + pathname = `${this[Internal].basePath}${pathname}` } return pathname } - get locale() { - if (!this._locale) { - throw new TypeError(`The URL is not configured with i18n`) - } - - return this._locale.locale + public get locale() { + return this[Internal].locale?.locale ?? '' } - set locale(locale: string) { - if (!this._locale) { - throw new TypeError(`The URL is not configured with i18n`) + public set locale(locale: string) { + if ( + !this[Internal].locale || + !this[Internal].options.i18n?.locales.includes(locale) + ) { + throw new TypeError( + `The NextURL configuration includes no locale "${locale}"` + ) } - this._locale.locale = locale + this[Internal].locale!.locale = locale } get defaultLocale() { - return this._locale?.defaultLocale + return this[Internal].locale?.defaultLocale } get domainLocale() { - return this._locale?.domain + return this[Internal].locale?.domain } get searchParams() { - return this._url.searchParams + return this[Internal].url.searchParams } get host() { - return this.absolute ? this._url.host : '' + return this[Internal].url.host } set host(value: string) { - this._url.host = value + this[Internal].url.host = value } get hostname() { - return this.absolute ? this._url.hostname : '' + return this[Internal].url.hostname } set hostname(value: string) { - this._url.hostname = value || 'localhost' + this[Internal].url.hostname = value } get port() { - return this.absolute ? this._url.port : '' + return this[Internal].url.port } set port(value: string) { - this._url.port = value + this[Internal].url.port = value } get protocol() { - return this.absolute ? this._url.protocol : '' + return this[Internal].url.protocol } set protocol(value: string) { - this._url.protocol = value + this[Internal].url.protocol = value } get href() { const pathname = this.formatPathname() - return this.absolute - ? `${this.protocol}//${this.host}${pathname}${this._url.search}` - : `${pathname}${this._url.search}` + return `${this.protocol}//${this.host}${pathname}${this[Internal].url.search}` } set href(url: string) { - this._url = createWHATWGURL(url) + this[Internal].url = parseURL(url) this.analyzeUrl() } get origin() { - return this.absolute ? this._url.origin : '' + return this[Internal].url.origin } get pathname() { - return this._url.pathname + return this[Internal].url.pathname } set pathname(value: string) { - this._url.pathname = value + this[Internal].url.pathname = value } get hash() { - return this._url.hash + return this[Internal].url.hash } set hash(value: string) { - this._url.hash = value + this[Internal].url.hash = value } get search() { - return this._url.search + return this[Internal].url.search } set search(value: string) { - this._url.search = value + this[Internal].url.search = value } get password() { - return this._url.password + return this[Internal].url.password } set password(value: string) { - this._url.password = value + this[Internal].url.password = value } get username() { - return this._url.username + return this[Internal].url.username } set username(value: string) { - this._url.username = value + this[Internal].url.username = value } get basePath() { - return this._basePath + return this[Internal].basePath } set basePath(value: string) { - this._basePath = value.startsWith('/') ? value : `/${value}` + this[Internal].basePath = value.startsWith('/') ? value : `/${value}` } toString() { @@ -232,13 +245,12 @@ export class NextURL extends URL { } } -function createWHATWGURL(url: string) { - url = url.replace(REGEX_LOCALHOST_HOSTNAME, 'localhost') - return isRelativeURL(url) - ? new URL(url.replace(/^\/+/, '/'), new URL('https://localhost')) - : new URL(url) -} +const REGEX_LOCALHOST_HOSTNAME = + /(?!^https?:\/\/)(127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|::1|localhost)/ -function isRelativeURL(url: string) { - return url.startsWith('/') +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') + ) } diff --git a/test/unit/web-runtime/next-url.test.ts b/test/unit/web-runtime/next-url.test.ts index 43508d4d3253e37..7acbdd25b2be0a0 100644 --- a/test/unit/web-runtime/next-url.test.ts +++ b/test/unit/web-runtime/next-url.test.ts @@ -1,74 +1,69 @@ /* eslint-env jest */ import { NextURL } from 'next/dist/server/web/next-url' -it('has the right shape', () => { - const parsed = new NextURL('/about?param1=value1') +it('has the right shape and prototype', () => { + const parsed = new NextURL('/about?param1=value1', 'http://127.0.0.1') expect(parsed).toBeInstanceOf(URL) }) -it('allows to format relative urls', async () => { - const parsed = new NextURL('/about?param1=value1') +it('allows to the pathname', async () => { + const parsed = new NextURL('/about?param1=value1', 'http://127.0.0.1:3000') expect(parsed.basePath).toEqual('') - expect(parsed.hostname).toEqual('') - expect(parsed.host).toEqual('') - expect(parsed.href).toEqual('/about?param1=value1') + expect(parsed.hostname).toEqual('localhost') + expect(parsed.host).toEqual('localhost:3000') + expect(parsed.href).toEqual('http://localhost:3000/about?param1=value1') parsed.pathname = '/hihi' - expect(parsed.href).toEqual('/hihi?param1=value1') + expect(parsed.href).toEqual('http://localhost:3000/hihi?param1=value1') }) -it('allows to change the host of a relative url', () => { - const parsed = new NextURL('/about?param1=value1') - expect(parsed.hostname).toEqual('') - expect(parsed.host).toEqual('') - expect(parsed.href).toEqual('/about?param1=value1') +it('allows to change the host', () => { + const parsed = new NextURL('/about?param1=value1', 'http://127.0.0.1') + expect(parsed.hostname).toEqual('localhost') + expect(parsed.host).toEqual('localhost') + expect(parsed.href).toEqual('http://localhost/about?param1=value1') parsed.hostname = 'foo.com' expect(parsed.hostname).toEqual('foo.com') expect(parsed.host).toEqual('foo.com') - expect(parsed.href).toEqual('https://foo.com/about?param1=value1') - expect(parsed.toString()).toEqual('https://foo.com/about?param1=value1') + expect(parsed.href).toEqual('http://foo.com/about?param1=value1') + expect(parsed.toString()).toEqual('http://foo.com/about?param1=value1') }) -it('allows to change the hostname of a relative url', () => { - const url = new NextURL('/example') - url.hostname = 'foo.com' - expect(url.toString()).toEqual('https://foo.com/example') -}) - -it('allows to remove the hostname of an absolute url', () => { +it('does noop changing to an invalid hostname', () => { const url = new NextURL('https://foo.com/example') url.hostname = '' - expect(url.toString()).toEqual('/example') + expect(url.toString()).toEqual('https://foo.com/example') }) -it('allows to change the whole href of an absolute url', () => { +it('allows to change the whole href', () => { const url = new NextURL('https://localhost.com/foo') expect(url.hostname).toEqual('localhost.com') expect(url.protocol).toEqual('https:') expect(url.host).toEqual('localhost.com') - url.href = '/foo' - expect(url.hostname).toEqual('') - expect(url.protocol).toEqual('') - expect(url.host).toEqual('') + url.href = 'http://foo.com/bar' + expect(url.hostname).toEqual('foo.com') + expect(url.protocol).toEqual('http:') + expect(url.host).toEqual('foo.com') }) it('allows to update search params', () => { - const url = new NextURL('/example') + const url = new NextURL('/example', 'http://localhost.com') url.searchParams.set('foo', 'bar') expect(url.search).toEqual('?foo=bar') - expect(url.toString()).toEqual('/example?foo=bar') + expect(url.toString()).toEqual('http://localhost.com/example?foo=bar') }) it('parses and formats the basePath', () => { const url = new NextURL('/root/example', { + base: 'http://127.0.0.1', basePath: '/root', }) expect(url.basePath).toEqual('/root') expect(url.pathname).toEqual('/example') - expect(url.toString()).toEqual('/root/example') + expect(url.toString()).toEqual('http://localhost/root/example') const url2 = new NextURL('https://foo.com/root/bar', { basePath: '/root', @@ -89,14 +84,47 @@ it('parses and formats the basePath', () => { expect(url3.basePath).toEqual('') - url3.href = '/root/example' - expect(url.basePath).toEqual('/root') - expect(url.pathname).toEqual('/example') - expect(url.toString()).toEqual('/root/example') + url3.href = 'http://localhost.com/root/example' + expect(url3.basePath).toEqual('/root') + expect(url3.pathname).toEqual('/example') + expect(url3.toString()).toEqual('http://localhost.com/root/example') +}) + +it('allows to get empty locale when there is no locale', () => { + const url = new NextURL('https://localhost:3000/foo') + expect(url.locale).toEqual('') +}) + +it('doesnt allow to set an unexisting locale', () => { + const url = new NextURL('https://localhost:3000/foo') + let error: Error | null = null + try { + url.locale = 'foo' + } catch (err) { + error = err + } + + expect(error).toBeInstanceOf(TypeError) + expect(error.message).toEqual( + 'The NextURL configuration includes no locale "foo"' + ) +}) + +it('always get a default locale', () => { + const url = new NextURL('/bar', { + base: 'http://127.0.0.1', + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'fr'], + }, + }) + + expect(url.locale).toEqual('en') }) it('parses and formats the default locale', () => { const url = new NextURL('/es/bar', { + base: 'http://127.0.0.1', basePath: '/root', i18n: { defaultLocale: 'en', @@ -105,19 +133,19 @@ it('parses and formats the default locale', () => { }) expect(url.locale).toEqual('es') - expect(url.toString()).toEqual('/es/bar') + expect(url.toString()).toEqual('http://localhost/es/bar') url.basePath = '/root' expect(url.locale).toEqual('es') - expect(url.toString()).toEqual('/root/es/bar') + expect(url.toString()).toEqual('http://localhost/root/es/bar') url.locale = 'en' expect(url.locale).toEqual('en') - expect(url.toString()).toEqual('/root/bar') + expect(url.toString()).toEqual('http://localhost/root/bar') url.locale = 'fr' expect(url.locale).toEqual('fr') - expect(url.toString()).toEqual('/root/fr/bar') + expect(url.toString()).toEqual('http://localhost/root/fr/bar') }) it('consider 127.0.0.1 and variations as localhost', () => { @@ -131,3 +159,13 @@ it('consider 127.0.0.1 and variations as localhost', () => { expect(new NextURL('https://127.0.1.0:3000/hello')).toStrictEqual(httpsUrl) expect(new NextURL('https://::1:3000/hello')).toStrictEqual(httpsUrl) }) + +it('allows to change the port', () => { + const url = new NextURL('https://localhost:3000/foo') + url.port = '3001' + expect(url.href).toEqual('https://localhost:3001/foo') + url.port = '80' + expect(url.href).toEqual('https://localhost:80/foo') + url.port = '' + expect(url.href).toEqual('https://localhost/foo') +})