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
38 changes: 38 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,44 @@ Default: `10000`
Timeout in milliseconds for getting a response, including any retries. Can not be greater than 2147483647.
If set to `false`, there will be no timeout.

##### context

Type: `object<string, unknown>`\
Default: `{}`

Custom per-request data that is shared with your hooks. Ky shallow merges the context when you `.extend()` or supply request-specific overrides, so properties you pass with a request overwrite defaults. Only enumerable properties are merged.

```js
import ky from 'ky';

const api = ky.extend({
context: {requestId: 'default'},
hooks: {
beforeRequest: [
(request, options) => {
const token = options.context.token;
if (typeof token !== 'string') {
throw new Error('Token required');
}

request.headers.set('token', token);
request.headers.set('x-request-id', options.context.requestId as string);
}
]
}
});

await api('https://example.com', {
context: {
token: 'secret',
requestId: 'request-123',
}
});
```

> [!NOTE]
> Non-enumerable properties inside the context are not merged.

##### hooks

Type: `object<string, Function[]>`\
Expand Down
7 changes: 7 additions & 0 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import delay from '../utils/delay.js';
import {type ObjectEntries} from '../utils/types.js';
import {findUnknownOptions, hasSearchParameters} from '../utils/options.js';
import {isHTTPError, isTimeoutError} from '../utils/type-guards.js';
import {isObject} from '../utils/is.js';
import {
maxSafeTimeout,
responseTypes,
Expand Down Expand Up @@ -166,6 +167,11 @@ export class Ky {
constructor(input: Input, options: Options = {}) {
this.#input = input;

const rawContext = options.context;
if (rawContext !== undefined && (!isObject(rawContext) || Array.isArray(rawContext))) {
throw new TypeError('The `context` option must be an object');
}

this.#options = {
...options,
headers: mergeHeaders((this.#input as Request).headers, options.headers),
Expand All @@ -182,6 +188,7 @@ export class Ky {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
prefixUrl: String(options.prefixUrl || ''),
retry: normalizeRetryOptions(options.retry),
context: rawContext ? {...rawContext} : {},
throwHttpErrors: options.throwHttpErrors !== false,
timeout: options.timeout ?? 10_000,
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
Expand Down
1 change: 1 addition & 0 deletions source/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const kyOptionKeys: KyOptionsRegistry = {
parseJson: true,
stringifyJson: true,
searchParams: true,
context: true,
prefixUrl: true,
retry: true,
timeout: true,
Expand Down
33 changes: 33 additions & 0 deletions source/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,37 @@ export type KyOptions = {
*/
throwHttpErrors?: boolean;

/**
User-defined per-request metadata shared with hooks.

This object is shallow-merged when you `.extend()` or pass new defaults, so request-specific values override defaults. Only enumerable properties are merged.

@example
```
import ky from 'ky';

const client = ky.extend({
hooks: {
beforeRequest: [
(_request, options) => {
const token = options.context.token;
if (typeof token !== 'string') {
throw new Error('Token required');
}

options.headers.set('token', token);
}
]
}
});

const response = await client('https://example.com', {
context: {token: 'secret'},
});
```
*/
context?: Record<string, unknown>;

/**
Download progress event handler.

Expand Down Expand Up @@ -297,6 +328,7 @@ Omit<Options, 'hooks' | 'retry'>,
> & {
headers: Required<Headers>;
hooks: Required<Hooks>;
context: Record<string, unknown>;
retry: Required<RetryOptions>;
prefixUrl: string;
};
Expand All @@ -314,6 +346,7 @@ export interface NormalizedOptions extends RequestInit { // eslint-disable-line
prefixUrl: string;
onDownloadProgress: Options['onDownloadProgress'];
onUploadProgress: Options['onUploadProgress'];
context: Record<string, unknown>;
}

export type {RetryOptions} from './retry.js';
14 changes: 14 additions & 0 deletions source/utils/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ export const deepMerge = <T>(...sources: Array<Partial<T> | undefined>): T => {
continue;
}

if (key === 'context') {
if (value === undefined) {
returnValue = {...returnValue, context: undefined};
} else if (isObject(value) && !Array.isArray(value)) {
const existingContext = returnValue.context;
const base = isObject(existingContext) && !Array.isArray(existingContext) ? existingContext : {};
returnValue = {...returnValue, context: {...base, ...value}};
} else {
returnValue = {...returnValue, context: value};
}

continue;
}

if (isObject(value) && key in returnValue) {
value = deepMerge(returnValue[key], value);
}
Expand Down
113 changes: 113 additions & 0 deletions test/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,119 @@ test('afterResponse hook can change response instance by sequence', async t => {
await server.close();
});

test('context is available in hooks', async t => {
const server = await createHttpTestServer();
server.get('/', (request, response) => {
response.json({
token: request.get('token'),
});
});

const body = await ky
.get(server.url, {
context: {token: 'secret'},
hooks: {
beforeRequest: [
(request, options) => {
const {token} = options.context as {token?: string};
if (typeof token === 'string') {
request.headers.set('token', token);
}
},
],
},
})
.json<{token?: string}>();

t.is(body.token, 'secret');

await server.close();
});

test('context merges across extended instances', async t => {
const server = await createHttpTestServer();
server.get('/', (request, response) => {
response.json({
token: request.get('x-token'),
requestId: request.get('x-request-id'),
});
});

const base = ky.extend({
context: {token: 'base-token'},
hooks: {
beforeRequest: [
(request, options) => {
const {token, requestId} = options.context as {
token?: string;
requestId?: string;
};

if (typeof token === 'string') {
request.headers.set('x-token', token);
}

if (typeof requestId === 'string') {
request.headers.set('x-request-id', requestId);
}
},
],
},
});

const child = base.extend({context: {requestId: 'child-request'}});

const body = await child
.get(server.url, {
context: {token: 'override-token'},
})
.json<{token?: string; requestId?: string}>();

t.deepEqual(body, {
token: 'override-token',
requestId: 'child-request',
});

await server.close();
});

test('context does not leak between requests', async t => {
const server = await createHttpTestServer();
server.get('/', (request, response) => {
response.json({
count: Number(request.get('x-count')),
});
});

const client = ky.extend({
context: {count: 0},
hooks: {
beforeRequest: [
(request, options) => {
const context = options.context as {count?: number};
const next = (context.count ?? 0) + 1;
context.count = next;
request.headers.set('x-count', String(next));
},
],
},
});

const first = await client.get(server.url).json<{count?: number}>();
const second = await client.get(server.url).json<{count?: number}>();

t.deepEqual(first, {count: 1});
t.deepEqual(second, {count: 1});

await server.close();
});

test('context must be an object', t => {
t.throws(() => {
void ky('https://example.com', {context: [] as any});
}, {message: 'The `context` option must be an object'});
});

test('afterResponse hook can throw error to reject the request promise', async t => {
const server = await createHttpTestServer();
server.get('/', (_request, response) => {
Expand Down