rsnext/test/unit/web-runtime/next-url.test.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

430 lines
12 KiB
TypeScript
Raw Normal View History

/**
* @jest-environment @edge-runtime/jest-environment
*/
import { NextURL } from 'next/dist/server/web/next-url'
// TODO Make NextURL extend URL
it.skip('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 the pathname', async () => {
const parsed = new NextURL('/about?param1=value1', 'http://127.0.0.1:3000')
expect(parsed.basePath).toEqual('')
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('http://localhost:3000/hihi?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('http://foo.com/about?param1=value1')
expect(parsed.toString()).toEqual('http://foo.com/about?param1=value1')
})
it('does noop changing to an invalid hostname', () => {
const url = new NextURL('https://foo.com/example')
url.hostname = ''
expect(url.toString()).toEqual('https://foo.com/example')
})
Append the fragment in NextUrl.toString() (#41501) <!-- Thanks for opening a PR! Your contribution is much appreciated. To make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below. Choose the right checklist for the change that you're making: --> Consider the following middleware which redirects to `/path/to#fragment`. ```ts import { NextResponse } from 'next/server'; export async function middleware(request) { const url = new URL('/path/to#fragment', request.url); return NextResponse.redirect(url); } ``` However, it actually redirects to `/path/to`, namely it discards the fragment part in the destination URL `#fragment`. This is because `NextURL.toString()` does not append that part. This PR fixes the bug. ## Bug - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
2022-10-18 06:56:28 +02:00
it('preserves the fragment', () => {
const url = new NextURL(
'https://example.com/path/to?param1=value1#this-is-fragment'
)
expect(url.toString()).toEqual(
'https://example.com/path/to?param1=value1#this-is-fragment'
)
})
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 = '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', 'http://localhost.com')
url.searchParams.set('foo', 'bar')
expect(url.search).toEqual('?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',
nextConfig: { basePath: '/root' },
})
expect(url.basePath).toEqual('/root')
expect(url.pathname).toEqual('/example')
expect(url.toString()).toEqual('http://localhost/root/example')
const url2 = new NextURL('https://foo.com/root/bar', {
nextConfig: { basePath: '/root' },
})
expect(url2.basePath).toEqual('/root')
expect(url2.pathname).toEqual('/bar')
expect(url2.toString()).toEqual('https://foo.com/root/bar')
url2.basePath = '/test'
expect(url2.basePath).toEqual('/test')
expect(url2.pathname).toEqual('/bar')
expect(url2.toString()).toEqual('https://foo.com/test/bar')
const url3 = new NextURL('https://foo.com/example', {
nextConfig: { basePath: '/root' },
})
expect(url3.basePath).toEqual('')
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',
nextConfig: {
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',
nextConfig: {
basePath: '/root',
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
})
expect(url.locale).toEqual('es')
expect(url.toString()).toEqual('http://localhost/es/bar')
url.basePath = '/root'
expect(url.locale).toEqual('es')
expect(url.toString()).toEqual('http://localhost/root/es/bar')
url.locale = 'en'
expect(url.locale).toEqual('en')
expect(url.toString()).toEqual('http://localhost/root/bar')
url.locale = 'fr'
expect(url.locale).toEqual('fr')
expect(url.toString()).toEqual('http://localhost/root/fr/bar')
})
it('parses and formats the default locale with forceLocale', () => {
const url = new NextURL('/es/bar', {
base: 'http://127.0.0.1',
forceLocale: true,
nextConfig: {
basePath: '/root',
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
})
expect(url.locale).toEqual('es')
expect(url.toString()).toEqual('http://localhost/es/bar')
url.basePath = '/root'
expect(url.locale).toEqual('es')
expect(url.toString()).toEqual('http://localhost/root/es/bar')
url.locale = 'en'
expect(url.locale).toEqual('en')
expect(url.toString()).toEqual('http://localhost/root/en/bar')
url.locale = 'fr'
expect(url.locale).toEqual('fr')
expect(url.toString()).toEqual('http://localhost/root/fr/bar')
})
it('consider 127.0.0.1 and variations as localhost', () => {
const httpUrl = new NextURL('http://localhost:3000/hello')
expect(new NextURL('http://127.0.0.1:3000/hello')).toStrictEqual(httpUrl)
expect(new NextURL('http://127.0.1.0:3000/hello')).toStrictEqual(httpUrl)
Better IPv6 support for `next-server` (#53131) ### What? This PR makes it easier to use Next.js with IPv6 hostnames such as `::1` and `::`. ### How? It does so by removing rewrites from `localhost` to `127.0.0.1` introduced in #52492. It also fixes the issue where Next.js tries to fetch something like `http://::1:3000` when `--hostname` is `::1` as it is not a valid URL (browsers' `URL` class throws an error when constructed with such hosts). It also fixes `NextURL` so that it doesn't accept `http://::1:3000` but refuse `http://[::1]:3000`. It also changes `next/src/server/lib/setup-server-worker.ts` so that it uses the server's `address` method to retrieve the host instead of our provided `opts.hostname`, ensuring that no matter what `opts.hostname` is we will always get the correct one. ### Note I've verified that `next dev`, `next start` and `node .next/standalone/server.js` work with IPv6 hostnames (such as `::` and `::1`), IPv4 hostnames (such as `127.0.0.1`, `0.0.0.0`) and `localhost` - and with any of these hostnames fetching to `localhost` also works. Server Actions and middleware have no problems as well. This also removes `.next/standalone/server.js`'s logging as we now use `start-server`'s logging to avoid duplicates. `start-server`'s logging has also been updated to report the actual hostname. ![image](https://github.com/vercel/next.js/assets/75556609/cefa5f23-ff09-4cef-a055-13eea7c11d89) ![image](https://github.com/vercel/next.js/assets/75556609/619e82ce-45d9-47b7-8644-f4ad083429db) The above pictures also demonstrate using Server Actions with Next.js after this PR. ![image](https://github.com/vercel/next.js/assets/75556609/3d4166e9-f950-4390-bde9-af2547658148) Fixes #53171 Fixes #49578 Closes NEXT-1510 Co-authored-by: Tim Neutkens <6324199+timneutkens@users.noreply.github.com> Co-authored-by: Zack Tanner <1939140+ztanner@users.noreply.github.com>
2023-08-14 09:23:24 +02:00
expect(new NextURL('http://[::1]:3000/hello')).toStrictEqual(httpUrl)
const httpsUrl = new NextURL('https://localhost:3000/hello')
expect(new NextURL('https://127.0.0.1:3000/hello')).toStrictEqual(httpsUrl)
expect(new NextURL('https://127.0.1.0:3000/hello')).toStrictEqual(httpsUrl)
Better IPv6 support for `next-server` (#53131) ### What? This PR makes it easier to use Next.js with IPv6 hostnames such as `::1` and `::`. ### How? It does so by removing rewrites from `localhost` to `127.0.0.1` introduced in #52492. It also fixes the issue where Next.js tries to fetch something like `http://::1:3000` when `--hostname` is `::1` as it is not a valid URL (browsers' `URL` class throws an error when constructed with such hosts). It also fixes `NextURL` so that it doesn't accept `http://::1:3000` but refuse `http://[::1]:3000`. It also changes `next/src/server/lib/setup-server-worker.ts` so that it uses the server's `address` method to retrieve the host instead of our provided `opts.hostname`, ensuring that no matter what `opts.hostname` is we will always get the correct one. ### Note I've verified that `next dev`, `next start` and `node .next/standalone/server.js` work with IPv6 hostnames (such as `::` and `::1`), IPv4 hostnames (such as `127.0.0.1`, `0.0.0.0`) and `localhost` - and with any of these hostnames fetching to `localhost` also works. Server Actions and middleware have no problems as well. This also removes `.next/standalone/server.js`'s logging as we now use `start-server`'s logging to avoid duplicates. `start-server`'s logging has also been updated to report the actual hostname. ![image](https://github.com/vercel/next.js/assets/75556609/cefa5f23-ff09-4cef-a055-13eea7c11d89) ![image](https://github.com/vercel/next.js/assets/75556609/619e82ce-45d9-47b7-8644-f4ad083429db) The above pictures also demonstrate using Server Actions with Next.js after this PR. ![image](https://github.com/vercel/next.js/assets/75556609/3d4166e9-f950-4390-bde9-af2547658148) Fixes #53171 Fixes #49578 Closes NEXT-1510 Co-authored-by: Tim Neutkens <6324199+timneutkens@users.noreply.github.com> Co-authored-by: Zack Tanner <1939140+ztanner@users.noreply.github.com>
2023-08-14 09:23:24 +02:00
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')
})
Enforce absolute URLs in Edge Functions runtime (#33410) We currently have inconsistencies when working with URLs in the Edge Functions runtime, this PR addresses them introducing a warning for inconsistent usage that will break in the future. Here is the reasoning. ### The Browser When we are in a browser environment there is a fixed location stored at `globalThis.location`. Then, if one tries to build a request with a relative URL it will work using that location global hostname as _base_ to construct its URL. For example: ```typescript // https://nextjs.org new Request('/test').url; // https://nextjs.org/test Response.redirect('/test').headers.get('Location'); // https://nextjs.org/test ``` However, if we attempt to run the same code from `about:blank` it would not work because the global to use as a base `String(globalThis.location)` is not a valid URL. Therefore a call to `Response.redirect('/test')` or `new Response('/test')` would fail. ### Edge Functions Runtime In Next.js Edge Functions runtime the situation is slightly different from a browser. Say that we have a root middleware (`pages/_middleware`) that gets invoked for every page. In the middleware file we expose the handler function and also define a global variable that we mutate on every request: ```typescript // pages/_middleware let count = 0; export function middleware(req: NextRequest) { console.log(req.url); count += 1; } ``` Currently we cache the module scope in the runtime so subsequent invocations would hold the same globals and the module would not be evaluated again. This would make the counter to increment for each request that the middleware handles. It is for this reason that we **can't have a global location** that changes across different invocations. Each invocation of the same function uses the same global which also holds primitives like `URL` or `Request` so changing an hypothetical `globalThis.location` per request would affect concurrent requests being handled. Then, it is not possible to use relative URLs in the same way the browser does because we don't have a global to rely on to use its host to compose a URL from a relative path. ### Why it works today We are **not** validating what is provided to, for example, `NextResponse.rewrite()` nor `NextResponse.redirect()`. We simply create a `Response` instance that adds the corresponding header for the rewrite or the redirect. Then it is **the consumer** the one that composes the final destination based on the request. Theoretically you can pass any value and it would fail on redirect but won't validate the input. Of course this is inconsistent because it doesn't make sense that `NextResponse.rewrite('/test')` works but `fetch(new NextRequest('/test'))` does not. Also we should validate what is provided. Finally, we want to be consistent with the way a browser behaves so `new Request('/test')` _should_ not work if there is no global location which we lack. ### What this PR does We will have to deprecate the usage of relative URLs in the previously mentioned scenarios. In preparation for it, this PR adds a validation function in those places where it will break in the future, printing a warning with a link that points to a Next.js page with an explanation of the issue and ways to fix it. Although middleware changes are not covered by semver, we will roll this for some time to make people aware that this change is coming. Then after a reasonable period of time we can remove the warning and make the code fail when using relative URLs in the previously exposed scenarios.
2022-01-19 16:10:25 +01:00
it('allows to clone a new copy', () => {
const url = new NextURL('/root/es/bar', {
base: 'http://127.0.0.1',
nextConfig: {
basePath: '/root',
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
Enforce absolute URLs in Edge Functions runtime (#33410) We currently have inconsistencies when working with URLs in the Edge Functions runtime, this PR addresses them introducing a warning for inconsistent usage that will break in the future. Here is the reasoning. ### The Browser When we are in a browser environment there is a fixed location stored at `globalThis.location`. Then, if one tries to build a request with a relative URL it will work using that location global hostname as _base_ to construct its URL. For example: ```typescript // https://nextjs.org new Request('/test').url; // https://nextjs.org/test Response.redirect('/test').headers.get('Location'); // https://nextjs.org/test ``` However, if we attempt to run the same code from `about:blank` it would not work because the global to use as a base `String(globalThis.location)` is not a valid URL. Therefore a call to `Response.redirect('/test')` or `new Response('/test')` would fail. ### Edge Functions Runtime In Next.js Edge Functions runtime the situation is slightly different from a browser. Say that we have a root middleware (`pages/_middleware`) that gets invoked for every page. In the middleware file we expose the handler function and also define a global variable that we mutate on every request: ```typescript // pages/_middleware let count = 0; export function middleware(req: NextRequest) { console.log(req.url); count += 1; } ``` Currently we cache the module scope in the runtime so subsequent invocations would hold the same globals and the module would not be evaluated again. This would make the counter to increment for each request that the middleware handles. It is for this reason that we **can't have a global location** that changes across different invocations. Each invocation of the same function uses the same global which also holds primitives like `URL` or `Request` so changing an hypothetical `globalThis.location` per request would affect concurrent requests being handled. Then, it is not possible to use relative URLs in the same way the browser does because we don't have a global to rely on to use its host to compose a URL from a relative path. ### Why it works today We are **not** validating what is provided to, for example, `NextResponse.rewrite()` nor `NextResponse.redirect()`. We simply create a `Response` instance that adds the corresponding header for the rewrite or the redirect. Then it is **the consumer** the one that composes the final destination based on the request. Theoretically you can pass any value and it would fail on redirect but won't validate the input. Of course this is inconsistent because it doesn't make sense that `NextResponse.rewrite('/test')` works but `fetch(new NextRequest('/test'))` does not. Also we should validate what is provided. Finally, we want to be consistent with the way a browser behaves so `new Request('/test')` _should_ not work if there is no global location which we lack. ### What this PR does We will have to deprecate the usage of relative URLs in the previously mentioned scenarios. In preparation for it, this PR adds a validation function in those places where it will break in the future, printing a warning with a link that points to a Next.js page with an explanation of the issue and ways to fix it. Although middleware changes are not covered by semver, we will roll this for some time to make people aware that this change is coming. Then after a reasonable period of time we can remove the warning and make the code fail when using relative URLs in the previously exposed scenarios.
2022-01-19 16:10:25 +01:00
},
})
const clone = url.clone()
clone.pathname = '/test'
clone.basePath = '/root-test'
expect(url.toString()).toEqual('http://localhost/root/es/bar')
expect(clone.toString()).toEqual('http://localhost/root-test/es/test')
})
it('does not add locale for api route', () => {
const url = new NextURL('http:///localhost:3000/api', {
nextConfig: {
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
})
url.locale = 'fr'
let expected = 'http://localhost:3000/api'
expect(url.href).toEqual(expected)
expect(url.toString()).toEqual(expected)
expect(url.toJSON()).toEqual(expected)
url.pathname = '/api/hello'
expected = 'http://localhost:3000/api/hello'
expect(url.href).toEqual(expected)
expect(url.toString()).toEqual(expected)
expect(url.toJSON()).toEqual(expected)
})
it('correctly parses a prefetch url', async () => {
const url = new NextURL(
'/_next/data/1234/en/hello.json',
'http://127.0.0.1:3000'
)
expect(url.buildId).toEqual('1234')
expect(url.pathname).toEqual('/en/hello')
expect(url.locale).toEqual('')
expect(String(url)).toEqual(
'http://localhost:3000/_next/data/1234/en/hello.json'
)
})
it('correctly handles trailing slash in _next/data', async () => {
const url = new NextURL('/abc/', 'http://127.0.0.1:3000')
url.buildId = '1234'
expect(url.pathname).toEqual('/abc/')
expect(url.locale).toEqual('')
expect(String(url)).toEqual('http://localhost:3000/_next/data/1234/abc.json')
})
it('correctly handles trailing slash in _next/data with config', async () => {
const url = new NextURL('/abc/', 'http://127.0.0.1:3000', {
nextConfig: { trailingSlash: true },
})
url.buildId = '1234'
expect(url.pathname).toEqual('/abc/')
expect(url.locale).toEqual('')
expect(String(url)).toEqual('http://localhost:3000/_next/data/1234/abc.json')
})
it('correctly handles trailing slash in _next/data with basePath', async () => {
const url = new NextURL('/docs/abc/', 'http://127.0.0.1:3000', {
nextConfig: { basePath: '/docs', trailingSlash: true },
})
url.buildId = '1234'
expect(url.pathname).toEqual('/abc/')
expect(url.locale).toEqual('')
expect(String(url)).toEqual(
'http://localhost:3000/docs/_next/data/1234/abc.json'
)
})
it('correctly parses a prefetch index url', async () => {
const url = new NextURL(
'/_next/data/development/index.json',
'http://127.0.0.1:3000'
)
expect(url.pathname).toEqual('/')
})
it('correctly parses a prefetch url with i18n', async () => {
const url = new NextURL(
'/_next/data/development/en/hello.json',
'http://127.0.0.1:3000',
{
nextConfig: {
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
}
)
expect(url.buildId).toEqual('development')
expect(url.pathname).toEqual('/hello')
expect(url.locale).toEqual('en')
expect(String(url)).toEqual(
'http://localhost:3000/_next/data/development/en/hello.json'
)
})
it('allows to update the pathname for a prefetch url', async () => {
const url = new NextURL(
'/_next/data/development/en/hello.json',
'http://127.0.0.1:3000',
{
nextConfig: {
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
}
)
url.pathname = '/foo'
expect(String(url)).toEqual(
'http://localhost:3000/_next/data/development/en/foo.json'
)
})
it('allows to update the pathname to the root path for a prefetch url', async () => {
const url = new NextURL(
'/_next/data/development/hello.json',
'http://127.0.0.1:3000'
)
url.pathname = '/'
expect(String(url)).toEqual(
'http://localhost:3000/_next/data/development/index.json'
)
})
it('preserves the trailingSlash', async () => {
const url = new NextURL('/es/', {
base: 'http://127.0.0.1:3000',
nextConfig: {
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
})
expect(String(url)).toEqual('http://localhost:3000/es/')
})
it('formats correctly the trailingSlash for root pages', async () => {
const url = new NextURL('/', {
base: 'http://127.0.0.1:3000',
nextConfig: {
trailingSlash: true,
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
})
url.locale = 'es'
expect(String(url)).toEqual('http://localhost:3000/es/')
})
it('keeps the trailingSlash format for non root pages', async () => {
const url = new NextURL('/es', {
base: 'http://127.0.0.1:3000',
nextConfig: {
trailingSlash: true,
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
})
expect(String(url)).toEqual('http://localhost:3000/es')
})
it('allows to preserve a json request', async () => {
const url = new NextURL(
'http://localhost:3000/_next/static/development/_devMiddlewareManifest.json',
{
nextConfig: {
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
},
}
)
expect(String(url)).toEqual(
'http://localhost:3000/_next/static/development/_devMiddlewareManifest.json'
)
})