diff --git a/source/core/Ky.ts b/source/core/Ky.ts index b8d83d8f..4ca4784a 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -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 => { + 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 = (value: T): T => { + if (Array.isArray(value)) { + return value.map(item => cloneDeepForInitHook(item)) as T; + } + + if (isPlainObject(value)) { + const copy: Record = {}; + for (const key of Object.keys(value as Record)) { + copy[key] = cloneDeepForInitHook((value as Record)[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), }; diff --git a/test/hooks.ts b/test/hooks.ts index 66e6f444..67d54f29 100644 --- a/test/hooks.ts +++ b/test/hooks.ts @@ -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()), @@ -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 = []; + 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},