ky
Version:
Tiny and elegant HTTP client based on the Fetch API
419 lines (414 loc) • 21.7 kB
JavaScript
import { supportsAbortSignal } from '../core/constants.js';
import { isNonArrayObject, isObject } from './is.js';
// Registered globally so that a wrapper created by a second copy of Ky, which happens with a duplicated dependency, is still recognised.
const replaceSymbol = Symbol.for('ky.replaceOption');
const getReplaceState = (value) => isObject(value) && value[replaceSymbol] === true
? {
isReplace: true,
value: value.value,
}
: {
isReplace: false,
value,
};
/**
Wraps a value so that `ky.extend()` will replace the parent value instead of merging with it. Works with hooks, headers, search parameters, context, and any other deep-merged option.
Inside the `json` option, which holds your own data, use it only where it replaces an inherited value. Ky does not look for it anywhere else there. The `context` option is merged shallowly, so only a wrapper around the whole `context` is supported.
By default, `.extend()` deep-merges options with the parent instance: hooks get appended, headers get merged, and search parameters get accumulated. Use `replaceOption` when you want to fully replace a merged property instead.
@example
```
import ky, {replaceOption} from 'ky';
const base = ky.create({
hooks: {beforeRequest: [addAuth, addTracking]},
});
// Replaces instead of appending
const extended = base.extend({
hooks: replaceOption({beforeRequest: [onlyThis]}),
});
// hooks.beforeRequest is now [onlyThis], not [addAuth, addTracking, onlyThis]
```
*/
export const replaceOption = (value) => {
const markedValue = {
[replaceSymbol]: true,
value,
// The leftover pass below skips `json`, so a wrapper there with nothing to replace is unwrapped when the body is serialized.
toJSON: () => value,
};
return markedValue;
};
export const validateAndMerge = (...sources) => {
for (const source of sources) {
if (source !== undefined && !isNonArrayObject(source)) {
throw new TypeError('The `options` argument must be an object');
}
}
return deepMerge({}, ...sources);
};
export const mergeHeaders = (source1 = {}, source2 = {}) => {
// The base source is copied as-is. Only the second source carries deletion markers: `undefined` in a plain object (for example, from `init` hooks or merged options), or the string `'undefined'` in a `Headers` instance.
const result = new globalThis.Headers(source1);
for (const [key, value] of Object.entries(toHeaderObject(source2))) {
if (value === undefined) {
result.delete(key);
}
else {
result.set(key, value);
}
}
return result;
};
const isPlainObject = (value) => {
if (!isNonArrayObject(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
// Only plain objects and arrays are merged. Class instances (for example, a `Date` in `json` or an undici `Agent` in `dispatcher`) are replaced as a whole, since merging their own properties into a plain object would strip their prototype.
const isMergeable = (value) => isPlainObject(value) || Array.isArray(value);
export const cloneShallow = (value) => {
if (value instanceof URLSearchParams) {
const copy = new URLSearchParams(value);
const deleted = value[deletedParametersSymbol];
if (deleted) {
// Preserve internal deletion markers so init-hook cloning does not resurrect params removed during option merging.
copy[deletedParametersSymbol] = new Set(deleted);
}
return copy;
}
if (Array.isArray(value)) {
return [...value];
}
if (isPlainObject(value)) {
const copy = { ...value };
return copy;
}
return value;
};
// A tuple array is copied pair by pair, so changing a pair of the copy does not change the caller's `searchParams`.
export const cloneSearchParameters = (value) => Array.isArray(value) ? value.map(parameter => cloneShallow(parameter)) : cloneShallow(value);
// Recursively clone plain objects and arrays so that `init`-hook mutations to nested `json` or `context` values do not leak into the next request. Non-plain values (functions, class instances, `Date`, `URLSearchParams`, …) are kept by reference, matching how option merging treats them as whole values. The `seen` map keeps shared and cyclic references intact.
export const cloneDeep = (value, seen = new WeakMap()) => {
if (!isMergeable(value)) {
return value;
}
const existingCopy = seen.get(value);
if (existingCopy) {
return existingCopy;
}
if (Array.isArray(value)) {
const copy = [];
seen.set(value, copy);
for (const item of value) {
copy.push(cloneDeep(item, seen));
}
return copy;
}
// Preserve a null prototype, which callers use to avoid inheriting from `Object.prototype`.
const copy = Object.getPrototypeOf(value) === null ? Object.create(null) : {};
seen.set(value, copy);
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) {
continue;
}
// Define instead of assign so a `__proto__` key becomes an own property rather than changing the prototype.
Object.defineProperty(copy, key, {
value: cloneDeep(value[key], seen),
writable: true,
enumerable: true,
configurable: true,
});
}
return copy;
};
// Every header source is merged as a plain object so deletion markers survive no matter how the headers were provided. A `Headers` instance cannot hold `undefined`, so its string `'undefined'` counts as a deletion. A plain object is returned as-is, so callers must not mutate the result.
const toHeaderObject = (source) => {
if (isPlainObject(source)) {
return source;
}
const isHeadersInstance = source instanceof globalThis.Headers;
const result = {};
// A header named `__proto__` is dropped here, because assigning it sets the prototype instead of an own property. This is too much of an edge case to be worth supporting.
for (const [key, value] of new globalThis.Headers(source).entries()) {
result[key] = isHeadersInstance && value === 'undefined' ? undefined : value;
}
return result;
};
// Header names are case-insensitive, so they are normalized to lowercase (like `Headers` does) so that overrides and `undefined` deletions match regardless of how the name was spelled and `init` hooks can rely on lowercase keys.
// An `undefined` value is kept as a deletion marker so it can still remove a header inherited from a `Request` input when the request is created.
export const mergeHeaderContainers = (source1, source2) => {
const result = new Map();
for (const [key, value] of [...Object.entries(toHeaderObject(source1)), ...Object.entries(toHeaderObject(source2))]) {
result.set(key.toLowerCase(), value);
}
return Object.fromEntries(result);
};
function newHookValue(original, incoming, property) {
// An absent key must keep the parent's hooks, which is why this check needs `Object.hasOwn`.
if (Object.hasOwn(incoming, property) && incoming[property] === undefined) {
return [];
}
// A single hook type can be wrapped in `replaceOption()` to replace only that array instead of the whole `hooks` object.
const { isReplace, value } = getReplaceState(incoming[property]);
// Merging ignores anything that is neither an array nor an object, so a single function or a string used to be dropped without a word and the hook simply never ran. `null` is rejected here too, rather than treated as absent: use `undefined` to clear a hook list.
if (value !== undefined && (!Array.isArray(value) || !value.every(hook => typeof hook === 'function'))) {
throw new TypeError(`\`hooks.${property}\` must be an array of functions`);
}
if (isReplace) {
return [...(value ?? [])];
}
return deepMerge(original[property] ?? [], value ?? []);
}
export const mergeHooks = (original = {}, incoming = {}) => {
// `undefined` selects the default through the parameter default. `null` is not accepted, so it reaches this check and is reported like any other bad shape.
if (!isNonArrayObject(incoming)) {
throw new TypeError('The `hooks` option must be an object');
}
return {
init: newHookValue(original, incoming, 'init'),
beforeRequest: newHookValue(original, incoming, 'beforeRequest'),
beforeRetry: newHookValue(original, incoming, 'beforeRetry'),
beforeError: newHookValue(original, incoming, 'beforeError'),
afterResponse: newHookValue(original, incoming, 'afterResponse'),
};
};
export const deletedParametersSymbol = Symbol('deletedParameters');
// Copies the array or object only when an item actually changes, so an untouched value keeps its identity. A hole in a sparse array is skipped, which is fine since it holds no wrapper.
const resolveItemMarkers = (value, resolveItem) => {
let resolved;
for (const [key, item] of Object.entries(value)) {
const replacement = resolveItem(item);
if (replacement !== item) {
resolved ??= Array.isArray(value) ? [...value] : { ...value };
// An array index is written through its string key, the same way `Object.entries()` gave it.
resolved[key] = replacement;
}
}
return resolved ?? value;
};
// `json` and `context` are user data, which can be large, so they are not walked on every request.
const userDataOptions = new Set(['json', 'context']);
// `replaceOption()` can wrap a value at any depth of a deep-merged option, but a wrapper is only unwrapped where a merge actually runs, so a wrapper on a key with no parent value kept its `{value}` envelope. This resolves the leftovers in one pass, copying only the branches that actually contained a wrapper so every other value keeps its identity. `resolved` maps each visited value to its result, so a value shared by two branches is walked once and stays shared, and a cyclic option cannot recurse forever: a value still being resolved maps to itself.
const resolveReplaceMarkers = (value, resolved) => {
if (!isMergeable(value)) {
return value;
}
if (resolved.has(value)) {
return resolved.get(value);
}
resolved.set(value, value);
const resolveItem = (item) => resolveReplaceMarkers(getReplaceState(item).value, resolved);
const result = resolveItemMarkers(value, resolveItem);
resolved.set(value, result);
return result;
};
const appendSearchParameters = (target, source) => {
const result = new URLSearchParams();
// Deleted keys stay marked even when a later layer re-adds the key, so the key is still removed from the input URL before the re-added value is appended.
const deleted = new Set();
// Both are defined, since an `undefined` layer clears the search parameters instead of being merged.
for (const input of [target, source]) {
if (input instanceof URLSearchParams) {
// A merged `URLSearchParams` already applied its own deletions before any re-added entries, so its deletions only apply to what was merged before it.
const inputDeleted = input[deletedParametersSymbol];
if (inputDeleted) {
for (const key of inputDeleted) {
result.delete(key);
deleted.add(key);
}
}
for (const [key, value] of input.entries()) {
result.append(key, value);
}
}
else if (Array.isArray(input)) {
for (const pair of input) {
if (!Array.isArray(pair) || pair.length !== 2) {
throw new TypeError('Array search parameters must be provided in [[key, value], ...] format');
}
// Unwrapped for the same reason as in the object branch below.
const key = String(pair[0]);
const { isReplace, value } = getReplaceState(pair[1]);
if (isReplace) {
result.delete(key);
}
result.append(key, String(value));
}
}
else if (isObject(input)) {
for (const [key, value] of Object.entries(input)) {
// A `replaceOption()` wrapper is unwrapped here, because this branch stringifies every value, so a wrapper that reached it would land in the URL as `[object Object]`. A wrapped value replaces the inherited values of its key instead of being added to them.
const { isReplace, value: entry } = getReplaceState(value);
if (entry === undefined) {
result.delete(key);
deleted.add(key);
}
else {
if (isReplace) {
result.delete(key);
}
result.append(key, String(entry));
}
}
}
else {
// String
const parameters = new URLSearchParams(input);
for (const [key, value] of parameters.entries()) {
result.append(key, value);
}
}
}
if (deleted.size > 0) {
result[deletedParametersSymbol] = deleted;
}
return result;
};
// TODO: Make this strongly-typed (no `any`).
const deepMergeInternal = (isRoot, ...sources) => {
let returnValue = {};
let headers = {};
let hooks = {};
let searchParameters;
const signals = [];
for (const source of sources) {
if (Array.isArray(source)) {
if (!Array.isArray(returnValue)) {
returnValue = [];
}
returnValue = [...returnValue, ...source];
}
else if (isObject(source)) {
for (let [key, value] of Object.entries(source)) {
const replaceState = getReplaceState(value);
const { isReplace } = replaceState;
value = replaceState.value;
const isRootSignal = isRoot && key === 'signal';
if (isRootSignal && (isReplace || value === undefined)) {
signals.length = 0;
}
// Special handling for AbortSignal instances at the root options level
if (isRootSignal && value instanceof globalThis.AbortSignal) {
signals.push(value);
continue;
}
// Option merging would otherwise let a `null` silently replace the inherited headers.
if (isRoot && key === 'headers' && value === null) {
throw new TypeError('The `headers` option must not be `null`. Use `undefined` to clear it.');
}
// Special handling for context - shallow merge only.
// Scoped to the root options level so it never rewrites nested user data that
// happens to contain a `context` key (e.g. a `json` request body).
if (isRoot && key === 'context') {
// `null` is not accepted, so it is reported here rather than treated as absent.
if (value !== undefined && !isNonArrayObject(value)) {
throw new TypeError('The `context` option must be an object');
}
// Shallow merge: always create a new object to prevent mutation bugs
returnValue = {
...returnValue,
context: value === undefined
? {}
: (isReplace
? { ...value }
: { ...returnValue.context, ...value }),
};
continue;
}
// Special handling for searchParams.
// Scoped to the root options level so it never rewrites nested user data that
// happens to contain a `searchParams` key (e.g. a `json` request body).
if (isRoot && key === 'searchParams') {
if (value === null) {
throw new TypeError('The `searchParams` option must not be `null`. Use `undefined` to clear it.');
}
if (value === undefined) {
// Explicit undefined removes searchParams
searchParameters = undefined;
}
else if (isReplace || searchParameters === undefined) {
// First or replacement source: shallow-clone to preserve type (string/object/URLSearchParams/tuples) without sharing the caller's object
searchParameters = cloneSearchParameters(value);
}
else {
// Subsequent sources: merge and convert to URLSearchParams
searchParameters = appendSearchParameters(searchParameters, value);
}
continue;
}
// Checked here as well as when the request is built, because a later layer would otherwise replace an invalid value, `null` included, before anything reports it.
if (isRoot && key === 'retry' && value !== undefined && typeof value !== 'number' && !isNonArrayObject(value)) {
throw new TypeError('`retry` must be a number or an object');
}
// `retry` accepts a number as shorthand for `{limit: number}`. Expand it before
// merging so extending a numeric `retry` with an object keeps the limit instead
// of dropping it (e.g. `ky.create({retry: 3}).extend({retry: {methods: ['get']}})`),
// and extending an object `retry` with a number keeps the other options
// (e.g. `ky.create({retry: {methods: ['post']}}).extend({retry: 3})`).
// Scoped to the root options level so it never rewrites nested user data that
// happens to contain a `retry` key (e.g. a `json` request body).
if (isRoot && key === 'retry' && !isReplace) {
if (isObject(value) && typeof returnValue[key] === 'number') {
returnValue = { ...returnValue, [key]: { limit: returnValue[key] } };
}
else if (typeof value === 'number' && isObject(returnValue[key])) {
value = { limit: value };
}
}
if (isRoot && key === 'retry' && isPlainObject(value) && (isReplace || !isMergeable(returnValue[key]))) {
// Resolve nested replacement markers even when there is no parent retry configuration to merge.
value = deepMergeInternal(false, {}, value);
}
else if (!isReplace && isMergeable(returnValue[key]) && isMergeable(value) && Array.isArray(returnValue[key]) === Array.isArray(value)) {
value = deepMergeInternal(false, returnValue[key], value);
}
returnValue = { ...returnValue, [key]: value };
}
// Scoped to the root options level so it never rewrites nested user data that
// happens to contain a `hooks` key (e.g. a `json` request body).
// Every value but `undefined` goes through `mergeHooks`, which rejects a bad shape, `null` included, so a later layer cannot hide it.
if (isRoot && source.hooks !== undefined) {
const { value: hookValue, isReplace } = getReplaceState(source.hooks);
hooks = isReplace
? mergeHooks({}, hookValue)
: mergeHooks(hooks, hookValue);
returnValue.hooks = hooks;
}
// Scoped to the root options level so it never rewrites nested user data that
// happens to contain a `headers` key (e.g. a `json` request body).
if (isRoot && isObject(source.headers)) {
const { value: headerValue, isReplace } = getReplaceState(source.headers);
headers = mergeHeaderContainers(isReplace ? {} : headers, headerValue);
returnValue.headers = headers;
}
}
}
if (searchParameters !== undefined) {
returnValue.searchParams = searchParameters;
}
if (signals.length > 0) {
if (signals.length === 1) {
returnValue.signal = signals[0];
}
else if (supportsAbortSignal) {
returnValue.signal = AbortSignal.any(signals);
}
else {
// When AbortSignal.any is not available, use the last signal
// This maintains the previous behavior before signal merging was added
// This can be removed when the `supportsAbortSignal` check is removed.
returnValue.signal = signals.at(-1);
}
}
if (isRoot) {
for (const [key, value] of Object.entries(returnValue)) {
if (!userDataOptions.has(key)) {
returnValue[key] = resolveReplaceMarkers(value, new Map());
}
}
}
return returnValue;
};
export const deepMerge = (...sources) => deepMergeInternal(true, ...sources);
//# sourceMappingURL=merge.js.map