Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need even clearer docs about the difference between baseUrl and prefix and when to use each. Maybe with some real-world example use-cases. Otherwise, it's going to be confusing for users and that ends up being a support burden for us.

##### baseUrl
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be great to have a matrix like you added in the original pull request description for both the baseUrl and prefix sections that goes through the motions of showing most (if not all) relevant permutations of baseUrl (or prefix), input and the resulting request URL.

This could help de-mystify what both options do and what might be appropriate for any given use case.

I think that's especially useful because not all people will know that "prefix" and "base URL" mean (sometimes subtly) different things (of course these docs make that clear, but still).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was originally going to do that but it felt like a lot of information all at once and made the choices seem complex, which could actually be more confusing than helpful. I went with a simple, explicit recommendation to use baseUrl unless you know you need prefix. It seems clearer to me overall.

I think we should see what questions come up and then make tweaks based on that. If there ends up being a lot, we could add a full matrix of examples to the FAQ. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm perfectly fine with that.

My angle is mainly that I think there might be a lot of (potential) users coming from contexts where all they need is setting some API prefix string (e.g. 'https://my-awesome-site.com/api/v1') so that their API client code can be simplified to accept path segments without that prefix (e.g. '/users' or 'users') to produce a simply joined request URL (e.g. 'https://my-awesome-site.com/api/v1/users'). For those cases, developers might never think to care about what difference a leading slash on '/users' vs no leading slash on 'users' could make. For them, both are equivalent and should result in the same request URL.

Since it might never occur to them that there can even be a difference between both cases, they might also just gloss over the documentation for prefix and baseUrl assuming (wrongly) that surely it behaves as expected. A tabular representation of the "concatenation" behavior might be a bit of an easier "catch" while reading the docs.

But again, I'm perfectly fine with not having that explained in a tabular manner.


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.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is prefix still preferred for this or should it be moved to baseUrl?


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

Expand Down Expand Up @@ -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'
Expand All @@ -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'
```

Expand Down
22 changes: 14 additions & 8 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion source/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions source/types/ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
48 changes: 37 additions & 11 deletions source/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,28 +92,54 @@ 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
```
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 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.
Expand Down Expand Up @@ -291,12 +317,12 @@ export interface Options extends KyOptions, Omit<RequestInit, 'headers'> { // es

export type InternalOptions = Required<
Omit<Options, 'hooks' | 'retry'>,
'fetch' | 'prefixUrl' | 'timeout'
'fetch' | 'prefix' | 'timeout'
> & {
headers: Required<Headers>;
hooks: Required<Hooks>;
retry: Required<RetryOptions>;
prefixUrl: string;
prefix: string;
};

/**
Expand All @@ -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'];
}
Expand Down
39 changes: 39 additions & 0 deletions test/base-url.ts
Original file line number Diff line number Diff line change
@@ -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();
});
18 changes: 7 additions & 11 deletions test/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand All @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion test/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
6 changes: 3 additions & 3 deletions test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
40 changes: 0 additions & 40 deletions test/prefix-url.ts

This file was deleted.

Loading