Skip to content
Closed
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
39 changes: 37 additions & 2 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,47 @@ const cloneSearchParametersForInitHook = (searchParameters: SearchParamsOption |
return cloneShallow(searchParameters) as SearchParamsOption | undefined;
};

// Shallow-clone mutable option properties so init hook mutations don't leak across requests.
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}

const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};

// Deep-clone plain objects/arrays so init hooks can mutate NESTED `context` metadata
// without leaking state across requests. Non-plain values (Date, URLSearchParams, Headers,
// class instances, functions) are passed through `cloneShallow`, preserving their identity/type.
// Only used for `context`: `json` is intentionally cloned shallowly (see `cloneInitHookOptions`)
// because it is arbitrary serializer input and may be cyclic or huge.
const cloneDeepForInitHook = <T>(value: T): T => {
if (Array.isArray(value)) {
return value.map(item => cloneDeepForInitHook(item)) as T;
}

if (isPlainObject(value)) {
const copy: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>)) {
copy[key] = cloneDeepForInitHook((value as Record<string, unknown>)[key]);
}

return copy as T;
}

return cloneShallow(value);
};

// Clone mutable option properties so init hook mutations don't leak across requests.
// `json` is cloned shallowly: it is typed as `unknown` and `stringifyJson` is the documented
// escape hatch for custom serialization, so Ky must not recursively walk it (deep-cloning
// cyclic or huge serializer input throws `RangeError: Maximum call stack size exceeded`).
// `context` is request metadata Ky owns, so it is safe to deep-clone for nested-mutation isolation.
function cloneInitHookOptions(options: Options): Options {
const clonedOptions: Options = {
...options,
json: cloneShallow(options.json),
context: cloneShallow(options.context)!,
context: cloneDeepForInitHook(options.context)!,
headers: cloneShallow(options.headers)!,
searchParams: cloneSearchParametersForInitHook(options.searchParams),
};
Expand Down
59 changes: 59 additions & 0 deletions test/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5096,6 +5096,33 @@ test('custom stringifyJson receives cyclic json unchanged when init hooks are ab
t.deepEqual(response, {ok: true});
});

test('custom stringifyJson receives cyclic json without crashing when init hooks are present', async t => {
const json: {self?: unknown} = {};
json.self = json;

let stringifyCalls = 0;

const response = await ky.post('https://example.com', {
fetch: async request => new Response(await request.text()),
json,
stringifyJson(data) {
stringifyCalls++;
t.is((data as {self?: unknown}).self !== undefined, true);
return '{"ok":true}';
},
hooks: {
init: [
options => {
options.headers = {'x-init': 'present'};
},
],
},
}).json<{ok: boolean}>();

t.is(stringifyCalls, 1);
t.deepEqual(response, {ok: true});
});

test('custom stringifyJson supports function-valued json when init hooks are present', async t => {
const response = await ky.post('https://example.com', {
fetch: async request => new Response(await request.text()),
Expand Down Expand Up @@ -5208,6 +5235,38 @@ test('init hook in-place context mutations do not leak across requests', async t
t.deepEqual(seenRequestIdentifiers, [1, 2]);
});

test('init hook nested context mutations do not leak across requests', async t => {
const seenRequestIdentifiers: Array<number | undefined> = [];
let requestIdentifier = 0;

const api = ky.extend({
context: {trace: {}},
hooks: {
init: [
options => {
const context = options.context as {trace: {requestIdentifier?: number}};
context.trace.requestIdentifier ??= ++requestIdentifier;
},
],
beforeRequest: [
({options}) => {
seenRequestIdentifiers.push((options.context.trace as {requestIdentifier?: number}).requestIdentifier);
},
],
},
});

await api.get('https://example.com', {
fetch: async () => new Response('ok'),
});

await api.get('https://example.com', {
fetch: async () => new Response('ok'),
});

t.deepEqual(seenRequestIdentifiers, [1, 2]);
});

test('multiple init hooks see each other\'s mutations on the shared cloned options', async t => {
const api = ky.extend({
json: {a: 1},
Expand Down
Loading