diff --git a/readme.md b/readme.md index a33a53df..a3869ee4 100644 --- a/readme.md +++ b/readme.md @@ -159,7 +159,7 @@ Type: `string` | `URL` | `Request` Same as [`fetch` input](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#input). -When using a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance as `input`, any URL altering options (such as `prefixUrl`) will be ignored. +When using a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance as `input`, any URL altering options (such as `prefix`) will be ignored. #### options @@ -191,29 +191,55 @@ Search parameters to include in the request URL. Setting this will override all Accepts any value supported by [`URLSearchParams()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams). -##### prefixUrl +##### baseUrl Type: `string | URL` -A prefix to prepend to the `input` URL when making the request. It can be any valid URL, either relative or absolute. A trailing slash `/` is optional and will be added automatically, if needed, when it is joined with `input`. Only takes effect when `input` is a string. The `input` argument cannot start with a slash `/` when using this option. +A base to [resolve](https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references) relative `input` URLs. When the `input` (after applying the `prefix` option) is only a partial URL, such as `'users'`, `'/users'`, or `'//my-site.com'`, it will be resolved against the `baseUrl` to determine the destination of the request. Otherwise, the input is absolute, such as `'https://my-site.com'`, and it will bypass the `baseUrl`. Useful when used with [`ky.extend()`](#kyextenddefaultoptions) to create niche-specific Ky-instances. +When setting a `baseUrl` that has a path, we recommend that it includes a trailing slash `/`, as in `'/api/'` or `'https://my-site.com/api/'`, so that directory-relative `input` URLs, such as `'users'` or `'./users'`, use the full path of the `baseUrl` instead of just replacing its last path segment. + +If the `baseUrl` itself is relative, it will be resolved against the environment's base URL, such as [`document.baseURI`](https://developer.mozilla.org/en-US/docs/Web/API/Node/baseURI) in browsers or `location.href` in Deno, which can be set with the `--location` flag. + +```js +import ky from 'ky'; + +// On https://example.com + +const response = await ky('users', {baseUrl: '/api/'}); +//=> 'https://example.com/api/users' + +const response = await ky('/user', {baseUrl: '/api/'}); +//=> 'https://example.com/user' +``` + +##### prefix + +Type: `string | URL` + +A prefix to prepend to the `input` URL before making the request. It can be any valid path or URL, either relative or absolute. A trailing slash `/` is optional and will be added automatically, if needed, when it is joined with `input`. Only takes effect when `input` is a string. + +Useful when used with [`ky.extend()`](#kyextenddefaultoptions) to create niche-specific Ky-instances. + +In most cases, you should use the `baseUrl` option instead, as it is more consistent with web standards. The main use-case for the `prefix` option is to treat `input` URLs that start with a leading slash `/` as page-relative rather than origin-relative. + ```js import ky from 'ky'; // On https://example.com -const response = await ky('unicorn', {prefixUrl: '/api'}); -//=> 'https://example.com/api/unicorn' +const response = await ky('users', {baseUrl: '/api/'}); +//=> 'https://example.com/api/users' -const response2 = await ky('unicorn', {prefixUrl: 'https://cats.com'}); -//=> 'https://cats.com/unicorn' +const response = await ky('/user', {baseUrl: '/api/'}); +//=> 'https://example.com/api/user' ``` Notes: - - After `prefixUrl` and `input` are joined, the result is resolved against the [base URL](https://developer.mozilla.org/en-US/docs/Web/API/Node/baseURI) of the page (if any). - - Leading slashes in `input` are disallowed when using this option to enforce consistency and avoid confusion about how the `input` URL is handled, given that `input` will not follow the normal URL resolution rules when `prefixUrl` is being used, which changes the meaning of a leading slash. + - The `prefix` and `input` are joined with a slash `/` and deduplicated with any adjacent slashes already present in `prefix` or `input`. + - After `prefix` and `input` are joined, the result is resolved against the `baseUrl` option, if present. ##### retry @@ -570,9 +596,9 @@ You can also refer to parent defaults by providing a function to `.extend()`. ```js import ky from 'ky'; -const api = ky.create({prefixUrl: 'https://example.com/api'}); +const api = ky.create({prefix: 'https://example.com/api'}); -const usersApi = api.extend((options) => ({prefixUrl: `${options.prefixUrl}/users`})); +const usersApi = api.extend((options) => ({prefix: `${options.prefix}/users`})); const response = await usersApi.get('123'); //=> 'https://example.com/api/users/123' @@ -590,12 +616,12 @@ import ky from 'ky'; // On https://my-site.com -const api = ky.create({prefixUrl: 'https://example.com/api'}); +const api = ky.create({prefix: 'https://example.com/api'}); const response = await api.get('users/123'); //=> 'https://example.com/api/users/123' -const response = await api.get('/status', {prefixUrl: ''}); +const response = await api.get('/status', {prefix: ''}); //=> 'https://my-site.com/status' ``` diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 81907932..96a4416e 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -145,7 +145,7 @@ export class Ky { ), method: normalizeRequestMethod(options.method ?? (this._input as Request).method ?? 'GET'), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - prefixUrl: String(options.prefixUrl || ''), + prefix: String(options.prefix || ''), retry: normalizeRetryOptions(options.retry), throwHttpErrors: options.throwHttpErrors !== false, timeout: options.timeout ?? 10_000, @@ -156,16 +156,22 @@ export class Ky { throw new TypeError('`input` must be a string, URL, or Request'); } - if (this._options.prefixUrl && typeof this._input === 'string') { - if (this._input.startsWith('/')) { - throw new Error('`input` must not begin with a slash when using `prefixUrl`'); - } + if (typeof this._input === 'string') { + if (this._options.prefix) { + if (!this._options.prefix.endsWith('/')) { + this._options.prefix += '/'; + } - if (!this._options.prefixUrl.endsWith('/')) { - this._options.prefixUrl += '/'; + if (this._input.startsWith('/')) { + this._input = this._input.slice(1); + } + + this._input = this._options.prefix + this._input; } - this._input = this._options.prefixUrl + this._input; + if (this._options.baseUrl) { + this._input = new URL(this._input, (new Request(options.baseUrl ?? '')).url); + } } if (supportsAbortController) { diff --git a/source/core/constants.ts b/source/core/constants.ts index 0c216a9f..4ff44c53 100644 --- a/source/core/constants.ts +++ b/source/core/constants.ts @@ -64,7 +64,8 @@ export const kyOptionKeys: KyOptionsRegistry = { parseJson: true, stringifyJson: true, searchParams: true, - prefixUrl: true, + baseUrl: true, + prefix: true, retry: true, timeout: true, hooks: true, diff --git a/source/types/ky.ts b/source/types/ky.ts index 3f102f0f..83a4ac87 100644 --- a/source/types/ky.ts +++ b/source/types/ky.ts @@ -87,9 +87,9 @@ export type KyInstance = { ``` import ky from 'ky'; - const api = ky.create({prefixUrl: 'https://example.com/api'}); + const api = ky.create({prefix: 'https://example.com/api'}); - const usersApi = api.extend((options) => ({prefixUrl: `${options.prefixUrl}/users`})); + const usersApi = api.extend((options) => ({prefix: `${options.prefix}/users`})); const response = await usersApi.get('123'); //=> 'https://example.com/api/users/123' diff --git a/source/types/options.ts b/source/types/options.ts index a8a5fd1a..d9665986 100644 --- a/source/types/options.ts +++ b/source/types/options.ts @@ -92,13 +92,39 @@ export type KyOptions = { searchParams?: SearchParamsOption; /** - A prefix to prepend to the `input` URL when making the request. It can be any valid URL, either relative or absolute. A trailing slash `/` is optional and will be added automatically, if needed, when it is joined with `input`. Only takes effect when `input` is a string. The `input` argument cannot start with a slash `/` when using this option. + A base to [resolve](https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references) relative `input` URLs. When the `input` (after applying the `prefix` option) is only a partial URL, such as `'users'`, `'/users'`, or `'//my-site.com'`, it will be resolved against the `baseUrl` to determine the destination of the request. Otherwise, the input is absolute, such as `'https://my-site.com'`, and it will bypass the `baseUrl`. Useful when used with [`ky.extend()`](#kyextenddefaultoptions) to create niche-specific Ky-instances. + When setting a `baseUrl` that has a path, we recommend that it includes a trailing slash `/`, as in `'/api/'` or `'https://my-site.com/api/'`, so that directory-relative `input` URLs, such as `'users'` or `'./users'`, use the full path of the `baseUrl` instead of just replacing its last path segment. + + If the `baseUrl` itself is relative, it will be resolved against the environment's base URL, such as [`document.baseURI`](https://developer.mozilla.org/en-US/docs/Web/API/Node/baseURI) in browsers or `location.href` in Deno, which can be set with the `--location` flag. + + @example + ``` + import ky from 'ky'; + + // On https://example.com + + const response = await ky('users', {baseUrl: '/api/'}); + //=> 'https://example.com/api/users' + + const response = await ky('/user', {baseUrl: '/api/'}); + //=> 'https://example.com/user' + ``` + */ + baseUrl?: URL | string; + + /** + A prefix to prepend to the `input` URL before making the request. It can be any valid path or URL, either relative or absolute. A trailing slash `/` is optional and will be added automatically, if needed, when it is joined with `input`. Only takes effect when `input` is a string. + + Useful when used with [`ky.extend()`](#kyextenddefaultoptions) to create niche-specific Ky-instances. + + In most cases, you should use the `baseUrl` option instead, as it is more consistent with web standards. The main use-case for the `prefix` option is to treat `input` URLs that start with a leading slash `/` as page-relative rather than origin-relative. + Notes: - - After `prefixUrl` and `input` are joined, the result is resolved against the [base URL](https://developer.mozilla.org/en-US/docs/Web/API/Node/baseURI) of the page (if any). - - Leading slashes in `input` are disallowed when using this option to enforce consistency and avoid confusion about how the `input` URL is handled, given that `input` will not follow the normal URL resolution rules when `prefixUrl` is being used, which changes the meaning of a leading slash. + - The `prefix` and `input` are joined with a slash `/` and deduplicated with any adjacent slashes already present in `prefix` or `input`. + - After `prefix` and `input` are joined, the result is resolved against the `baseUrl` option, if present. @example ``` @@ -106,14 +132,14 @@ export type KyOptions = { // On https://example.com - const response = await ky('unicorn', {prefixUrl: '/api'}); - //=> 'https://example.com/api/unicorn' + const response = await ky('users', {baseUrl: '/api/'}); + //=> 'https://example.com/api/users' - const response = await ky('unicorn', {prefixUrl: 'https://cats.com'}); - //=> 'https://cats.com/unicorn' + const response = await ky('/user', {baseUrl: '/api/'}); + //=> 'https://example.com/api/user' ``` */ - prefixUrl?: URL | string; + prefix?: URL | string; /** An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time. @@ -291,12 +317,12 @@ export interface Options extends KyOptions, Omit { // es export type InternalOptions = Required< Omit, -'fetch' | 'prefixUrl' | 'timeout' +'fetch' | 'prefix' | 'timeout' > & { headers: Required; hooks: Required; retry: Required; - prefixUrl: string; + prefix: string; }; /** @@ -309,7 +335,7 @@ export interface NormalizedOptions extends RequestInit { // eslint-disable-line // Extended from custom `KyOptions`, but ensured to be set (not optional). retry: RetryOptions; - prefixUrl: string; + prefix: string; onDownloadProgress: Options['onDownloadProgress']; onUploadProgress: Options['onUploadProgress']; } diff --git a/test/base-url.ts b/test/base-url.ts new file mode 100644 index 00000000..4a614d22 --- /dev/null +++ b/test/base-url.ts @@ -0,0 +1,39 @@ +import test from 'ava'; +import ky from '../source/index.js'; +import {createHttpTestServer} from './helpers/create-http-test-server.js'; + +test('baseUrl option', async t => { + const server = await createHttpTestServer(); + server.get('/', (_request, response) => { + response.end('/'); + }); + server.get('/foo', (_request, response) => { + response.end('/foo'); + }); + server.get('/bar', (_request, response) => { + response.end('/bar'); + }); + server.get('/foo/bar', (_request, response) => { + response.end('/foo/bar'); + }); + + t.is( + // @ts-expect-error {baseUrl: boolean} isn't officially supported + await ky(`${server.url}/foo/bar`, {baseUrl: false}).text(), + '/foo/bar', + ); + t.is(await ky(`${server.url}/foo/bar`, {baseUrl: ''}).text(), '/foo/bar'); + t.is(await ky(new URL(`${server.url}/foo/bar`), {baseUrl: ''}).text(), '/foo/bar'); + t.is(await ky('foo/bar', {baseUrl: server.url}).text(), '/foo/bar'); + t.is(await ky('foo/bar', {baseUrl: new URL(server.url)}).text(), '/foo/bar'); + t.is(await ky('/bar', {baseUrl: `${server.url}/foo/`}).text(), '/bar'); + t.is(await ky('/bar', {baseUrl: `${server.url}/foo`}).text(), '/bar'); + t.is(await ky('bar', {baseUrl: `${server.url}/foo/`}).text(), '/foo/bar'); + t.is(await ky('bar', {baseUrl: `${server.url}/foo`}).text(), '/bar'); + t.is(await ky('bar', {baseUrl: new URL(`${server.url}/foo`)}).text(), '/bar'); + t.is(await ky('', {baseUrl: server.url}).text(), '/'); + t.is(await ky('', {baseUrl: `${server.url}/`}).text(), '/'); + t.is(await ky('', {baseUrl: new URL(server.url)}).text(), '/'); + + await server.close(); +}); diff --git a/test/browser.ts b/test/browser.ts index 490faaf4..37d5c96f 100644 --- a/test/browser.ts +++ b/test/browser.ts @@ -47,7 +47,7 @@ test.afterEach(async () => { await server.close(); }); -defaultBrowsersTest('prefixUrl option', async (t: ExecutionContext, page: Page) => { +defaultBrowsersTest('prefix option', async (t: ExecutionContext, page: Page) => { server.get('/', (_request, response) => { response.end('zebra'); }); @@ -59,20 +59,16 @@ defaultBrowsersTest('prefixUrl option', async (t: ExecutionContext, page: Page) await page.goto(server.url); await addKyScriptToPage(page); - await t.throwsAsync( - page.evaluate(async () => window.ky('/foo', {prefixUrl: '/'})), - {message: /`input` must not begin with a slash when using `prefixUrl`/}, - ); - const results = await page.evaluate(async (url: string) => Promise.all([ window.ky(`${url}/api/unicorn`).text(), - // @ts-expect-error unsupported {prefixUrl: null} type - window.ky(`${url}/api/unicorn`, {prefixUrl: null}).text(), - window.ky('api/unicorn', {prefixUrl: url}).text(), - window.ky('api/unicorn', {prefixUrl: `${url}/`}).text(), + // @ts-expect-error unsupported {prefix: null} type + window.ky(`${url}/api/unicorn`, {prefix: null}).text(), + window.ky('api/unicorn', {prefix: url}).text(), + window.ky('api/unicorn', {prefix: `${url}/`}).text(), + window.ky('/api/unicorn', {prefix: `${url}/`}).text(), ]), server.url); - t.deepEqual(results, ['rainbow', 'rainbow', 'rainbow', 'rainbow']); + t.deepEqual(results, ['rainbow', 'rainbow', 'rainbow', 'rainbow', 'rainbow']); }); defaultBrowsersTest('aborting a request', async (t: ExecutionContext, page: Page) => { diff --git a/test/fetch.ts b/test/fetch.ts index 05712087..719397a6 100644 --- a/test/fetch.ts +++ b/test/fetch.ts @@ -43,7 +43,7 @@ test('fetch option takes a custom fetch function', async t => { }).text(), `${fixture}?new#hash`, ); - t.is(await ky('unicorn', {fetch: customFetch, prefixUrl: `${fixture}/api/`}).text(), `${fixture}/api/unicorn`); + t.is(await ky('unicorn', {fetch: customFetch, prefix: `${fixture}/api/`}).text(), `${fixture}/api/unicorn`); }); test('options are correctly passed to Fetch #1', async t => { diff --git a/test/main.ts b/test/main.ts index 58cb969a..1e8d6b58 100644 --- a/test/main.ts +++ b/test/main.ts @@ -611,8 +611,8 @@ test('ky.extend() with function overrides primitives in parent defaults', async response.end(request.url); }); - const api = ky.create({prefixUrl: `${server.url}/api`}); - const usersApi = api.extend(options => ({prefixUrl: `${options.prefixUrl!.toString()}/users`})); + const api = ky.create({prefix: `${server.url}/api`}); + const usersApi = api.extend(options => ({prefix: `${options.prefix!.toString()}/users`})); t.is(await usersApi.get('123').text(), '/api/users/123'); t.is(await api.get('version').text(), '/api/version'); @@ -636,7 +636,7 @@ test('ky.extend() with function retains parent defaults when not specified', asy response.end(request.url); }); - const api = ky.create({prefixUrl: `${server.url}/api`}); + const api = ky.create({prefix: `${server.url}/api`}); const extendedApi = api.extend(() => ({})); t.is(await api.get('version').text(), '/api/version'); diff --git a/test/prefix-url.ts b/test/prefix-url.ts deleted file mode 100644 index e09650b4..00000000 --- a/test/prefix-url.ts +++ /dev/null @@ -1,40 +0,0 @@ -import test from 'ava'; -import ky from '../source/index.js'; -import {createHttpTestServer} from './helpers/create-http-test-server.js'; - -test('prefixUrl option', async t => { - const server = await createHttpTestServer(); - server.get('/', (_request, response) => { - response.end('zebra'); - }); - server.get('/api/unicorn', (_request, response) => { - response.end('rainbow'); - }); - - t.is( - // @ts-expect-error {prefixUrl: boolean} isn't officially supported - await ky(`${server.url}/api/unicorn`, {prefixUrl: false}).text(), - 'rainbow', - ); - t.is(await ky(`${server.url}/api/unicorn`, {prefixUrl: ''}).text(), 'rainbow'); - t.is(await ky(new URL(`${server.url}/api/unicorn`), {prefixUrl: ''}).text(), 'rainbow'); - t.is(await ky('api/unicorn', {prefixUrl: server.url}).text(), 'rainbow'); - t.is(await ky('api/unicorn', {prefixUrl: new URL(server.url)}).text(), 'rainbow'); - t.is(await ky('unicorn', {prefixUrl: `${server.url}/api`}).text(), 'rainbow'); - t.is(await ky('unicorn', {prefixUrl: `${server.url}/api/`}).text(), 'rainbow'); - t.is(await ky('unicorn', {prefixUrl: new URL(`${server.url}/api`)}).text(), 'rainbow'); - t.is(await ky('', {prefixUrl: server.url}).text(), 'zebra'); - t.is(await ky('', {prefixUrl: `${server.url}/`}).text(), 'zebra'); - t.is(await ky('', {prefixUrl: new URL(server.url)}).text(), 'zebra'); - - t.throws( - () => { - void ky('/unicorn', {prefixUrl: `${server.url}/api`}); - }, - { - message: '`input` must not begin with a slash when using `prefixUrl`', - }, - ); - - await server.close(); -}); diff --git a/test/prefix.ts b/test/prefix.ts new file mode 100644 index 00000000..e856171c --- /dev/null +++ b/test/prefix.ts @@ -0,0 +1,39 @@ +import test from 'ava'; +import ky from '../source/index.js'; +import {createHttpTestServer} from './helpers/create-http-test-server.js'; + +test('prefix option', async t => { + const server = await createHttpTestServer(); + server.get('/', (_request, response) => { + response.end('/'); + }); + server.get('/foo', (_request, response) => { + response.end('/foo'); + }); + server.get('/bar', (_request, response) => { + response.end('/bar'); + }); + server.get('/foo/bar', (_request, response) => { + response.end('/foo/bar'); + }); + + t.is( + // @ts-expect-error {prefix: boolean} isn't officially supported + await ky(`${server.url}/foo/bar`, {prefix: false}).text(), + '/foo/bar', + ); + t.is(await ky(`${server.url}/foo/bar`, {prefix: ''}).text(), '/foo/bar'); + t.is(await ky(new URL(`${server.url}/foo/bar`), {prefix: ''}).text(), '/foo/bar'); + t.is(await ky('foo/bar', {prefix: server.url}).text(), '/foo/bar'); + t.is(await ky('foo/bar', {prefix: new URL(server.url)}).text(), '/foo/bar'); + t.is(await ky('/bar', {prefix: `${server.url}/foo/`}).text(), '/foo/bar'); + t.is(await ky('/bar', {prefix: `${server.url}/foo`}).text(), '/foo/bar'); + t.is(await ky('bar', {prefix: `${server.url}/foo/`}).text(), '/foo/bar'); + t.is(await ky('bar', {prefix: `${server.url}/foo`}).text(), '/foo/bar'); + t.is(await ky('bar', {prefix: new URL(`${server.url}/foo`)}).text(), '/foo/bar'); + t.is(await ky('', {prefix: server.url}).text(), '/'); + t.is(await ky('', {prefix: `${server.url}/`}).text(), '/'); + t.is(await ky('', {prefix: new URL(server.url)}).text(), '/'); + + await server.close(); +});