diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 6d6918437e6bf52..88b3f63263b06c5 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -95,10 +95,10 @@ import { parseNextUrl } from '../shared/lib/router/utils/parse-next-url' import isError from '../lib/is-error' import { getMiddlewareInfo } from './require' import { MIDDLEWARE_ROUTE } from '../lib/constants' -import { NextResponse } from './web/spec-extension/response' import { run } from './web/sandbox' import { addRequestMeta, getRequestMeta } from './request-meta' import { toNodeHeaders } from './web/utils' +import { relativizeURL } from '../shared/lib/router/utils/relativize-url' const getCustomRouteMatcher = pathMatch(true) @@ -384,7 +384,13 @@ export default class Server { parsedUrl.query = parseQs(parsedUrl.query) } - addRequestMeta(req, '__NEXT_INIT_URL', req.url) + // When there are hostname and port we build an absolute URL + const initUrl = + this.hostname && this.port + ? `http://${this.hostname}:${this.port}${req.url}` + : req.url + + addRequestMeta(req, '__NEXT_INIT_URL', initUrl) addRequestMeta(req, '__NEXT_INIT_QUERY', { ...parsedUrl.query }) const url = parseNextUrl({ @@ -667,6 +673,14 @@ export default class Server { }): Promise { this.middlewareBetaWarning() + // For middleware to "fetch" we must always provide an absolute URL + const url = getRequestMeta(params.request, '__NEXT_INIT_URL')! + if (!url.startsWith('http')) { + throw new Error( + 'To use middleware you must provide a `hostname` and `port` to the Next.js Server' + ) + } + const page: { name?: string; params?: { [key: string]: string } } = {} if (await this.hasPage(params.parsedUrl.pathname)) { page.name = params.parsedUrl.pathname @@ -681,8 +695,6 @@ export default class Server { } } - const subreq = params.request.headers[`x-middleware-subrequest`] - const subrequests = typeof subreq === 'string' ? subreq.split(':') : [] const allHeaders = new Headers() let result: FetchEventResult | null = null @@ -702,14 +714,6 @@ export default class Server { serverless: this._isLikeServerless, }) - if (subrequests.includes(middlewareInfo.name)) { - result = { - response: NextResponse.next(), - waitUntil: Promise.resolve(), - } - continue - } - result = await run({ name: middlewareInfo.name, paths: middlewareInfo.paths, @@ -721,7 +725,7 @@ export default class Server { i18n: this.nextConfig.i18n, trailingSlash: this.nextConfig.trailingSlash, }, - url: getRequestMeta(params.request, '__NEXT_INIT_URL')!, + url: url, page: page, }, useCache: !this.nextConfig.experimental.concurrentFeatures, @@ -1181,9 +1185,13 @@ export default class Server { type: 'route', name: 'middleware catchall', fn: async (req, res, _params, parsed) => { - const fullUrl = getRequestMeta(req, '__NEXT_INIT_URL') + if (!this.middleware?.length) { + return { finished: false } + } + + const initUrl = getRequestMeta(req, '__NEXT_INIT_URL')! const parsedUrl = parseNextUrl({ - url: fullUrl, + url: initUrl, headers: req.headers, nextConfig: { basePath: this.nextConfig.basePath, @@ -1222,6 +1230,18 @@ export default class Server { return { finished: true } } + if (result.response.headers.has('x-middleware-rewrite')) { + const value = result.response.headers.get('x-middleware-rewrite')! + const rel = relativizeURL(value, initUrl) + result.response.headers.set('x-middleware-rewrite', rel) + } + + if (result.response.headers.has('Location')) { + const value = result.response.headers.get('Location')! + const rel = relativizeURL(value, initUrl) + result.response.headers.set('Location', rel) + } + if ( !result.response.headers.has('x-middleware-rewrite') && !result.response.headers.has('x-middleware-next') && diff --git a/packages/next/server/web/adapter.ts b/packages/next/server/web/adapter.ts index 33325dc873776e5..ff7f3559453c35c 100644 --- a/packages/next/server/web/adapter.ts +++ b/packages/next/server/web/adapter.ts @@ -1,8 +1,9 @@ import type { NextMiddleware, RequestData, FetchEventResult } from './types' +import type { RequestInit } from './spec-extension/request' import { DeprecationError } from './error' import { fromNodeHeaders } from './utils' import { NextFetchEvent } from './spec-extension/fetch-event' -import { NextRequest, RequestInit } from './spec-extension/request' +import { NextRequest } from './spec-extension/request' import { NextResponse } from './spec-extension/response' import { waitUntilSymbol } from './spec-compliant/fetch-event' @@ -11,13 +12,9 @@ export async function adapter(params: { page: string request: RequestData }): Promise { - const url = params.request.url.startsWith('/') - ? `https://${params.request.headers.host}${params.request.url}` - : params.request.url - const request = new NextRequestHint({ page: params.page, - input: url, + input: params.request.url, init: { geo: params.request.geo, headers: fromNodeHeaders(params.request.headers), 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/packages/next/server/web/sandbox/sandbox.ts b/packages/next/server/web/sandbox/sandbox.ts index 6fb8012c921aa74..7a98d4d1223432f 100644 --- a/packages/next/server/web/sandbox/sandbox.ts +++ b/packages/next/server/web/sandbox/sandbox.ts @@ -18,6 +18,19 @@ export async function run(params: { runInContext(paramPath) } + const subreq = params.request.headers[`x-middleware-subrequest`] + const subrequests = typeof subreq === 'string' ? subreq.split(':') : [] + if (subrequests.includes(params.name)) { + return { + waitUntil: Promise.resolve(), + response: new context.Response(null, { + headers: { + 'x-middleware-next': '1', + }, + }), + } + } + return context._ENTRIES[`middleware_${params.name}`].default({ request: params.request, }) diff --git a/packages/next/shared/lib/router/utils/relativize-url.ts b/packages/next/shared/lib/router/utils/relativize-url.ts new file mode 100644 index 000000000000000..2bc97102f08e64b --- /dev/null +++ b/packages/next/shared/lib/router/utils/relativize-url.ts @@ -0,0 +1,13 @@ +/** + * Given a URL as a string and a base URL it will make the URL relative + * if the parsed protocol and host is the same as the one in the base + * URL. Otherwise it returns the same URL string. + */ +export function relativizeURL(url: string | string, base: string | URL) { + const baseURL = typeof base === 'string' ? new URL(base) : base + const relative = new URL(url, base) + const origin = `${baseURL.protocol}//${baseURL.host}` + return `${relative.protocol}//${relative.host}` === origin + ? relative.toString().replace(origin, '') + : relative.toString() +} diff --git a/test/integration/custom-server/server.js b/test/integration/custom-server/server.js index 0b34500af9e57ad..29550fb1906f468 100644 --- a/test/integration/custom-server/server.js +++ b/test/integration/custom-server/server.js @@ -1,3 +1,7 @@ +if (process.env.POLYFILL_FETCH) { + global.fetch = require('node-fetch').default +} + const http = require('http') const next = require('next') diff --git a/test/integration/custom-server/test/index.test.js b/test/integration/custom-server/test/index.test.js index 272000993f8f2dc..ef0dee74715b5d4 100644 --- a/test/integration/custom-server/test/index.test.js +++ b/test/integration/custom-server/test/index.test.js @@ -195,4 +195,14 @@ describe('Custom Server', () => { } ) }) + + describe('with a custom fetch polyfill', () => { + beforeAll(() => startServer({ POLYFILL_FETCH: 'true' })) + afterAll(() => killApp(server)) + + it('should serve internal file from render', async () => { + const data = await renderViaHTTP(appPort, '/static/hello.txt') + expect(data).toMatch(/hello world/) + }) + }) }) diff --git a/test/integration/middleware/core/pages/interface/[id]/_middleware.js b/test/integration/middleware/core/pages/interface/[id]/_middleware.js new file mode 100644 index 000000000000000..679ab02834e00b6 --- /dev/null +++ b/test/integration/middleware/core/pages/interface/[id]/_middleware.js @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server' + +export function middleware() { + const response = NextResponse.next() + response.headers.set('x-dynamic-path', 'true') + return response +} diff --git a/test/integration/middleware/core/pages/interface/_middleware.js b/test/integration/middleware/core/pages/interface/_middleware.js index a3ac20fd7c379c7..53e52ae12f1d7ee 100644 --- a/test/integration/middleware/core/pages/interface/_middleware.js +++ b/test/integration/middleware/core/pages/interface/_middleware.js @@ -54,9 +54,7 @@ export async function middleware(request) { } if (url.pathname.endsWith('/root-subrequest')) { - return fetch( - `http://${request.headers.get('host')}/interface/root-subrequest` - ) + return fetch(url) } return new Response(null, { diff --git a/test/integration/middleware/core/pages/responses/_middleware.js b/test/integration/middleware/core/pages/responses/_middleware.js index 255f1c3180a1b84..a19326aad8f0fd6 100644 --- a/test/integration/middleware/core/pages/responses/_middleware.js +++ b/test/integration/middleware/core/pages/responses/_middleware.js @@ -57,11 +57,11 @@ export async function middleware(request, ev) { ev.waitUntil( (async () => { writer.write(encoder.encode('this is a streamed '.repeat(10))) - await sleep(2000) + await sleep(200) writer.write(encoder.encode('after 2 seconds '.repeat(10))) - await sleep(2000) + await sleep(200) writer.write(encoder.encode('after 4 seconds '.repeat(10))) - await sleep(2000) + await sleep(200) writer.close() })() ) diff --git a/test/integration/middleware/core/test/index.test.js b/test/integration/middleware/core/test/index.test.js index f0e10c23284144b..6c3e90e01a56a76 100644 --- a/test/integration/middleware/core/test/index.test.js +++ b/test/integration/middleware/core/test/index.test.js @@ -447,6 +447,14 @@ function interfaceTests(locale = '') { const element = await browser.elementByCss('.title') expect(await element.text()).toEqual('Dynamic route') }) + + it(`${locale} allows subrequests without infinite loops`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `/interface/root-subrequest` + ) + expect(res.headers.get('x-dynamic-path')).toBe('true') + }) } function getCookieFromResponse(res, cookieName) { 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') +})