ky
Version:
Tiny and elegant HTTP client based on the Fetch API
259 lines (255 loc) • 10.2 kB
JavaScript
import { supportsAbortSignal } from '../core/constants.js';
import { isObject } from './is.js';
const replaceSymbol = Symbol('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.
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 };
return markedValue;
};
export const validateAndMerge = (...sources) => {
for (const source of sources) {
if ((!isObject(source) || Array.isArray(source)) && source !== undefined) {
throw new TypeError('The `options` argument must be an object');
}
}
return deepMerge({}, ...sources);
};
export const mergeHeaders = (source1 = {}, source2 = {}) => {
const result = new globalThis.Headers(source1);
const isHeadersInstance = source2 instanceof globalThis.Headers;
const source = new globalThis.Headers(source2);
for (const [key, value] of source.entries()) {
if ((isHeadersInstance && value === 'undefined') || value === undefined) {
result.delete(key);
}
else {
result.set(key, value);
}
}
return result;
};
const isPlainObject = (value) => {
if (!isObject(value) || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
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 (value instanceof globalThis.Headers) {
return new globalThis.Headers(value);
}
if (Array.isArray(value)) {
return [...value];
}
if (isPlainObject(value)) {
const copy = { ...value };
return copy;
}
return value;
};
const normalizeHeaderObject = (headers) => Object.fromEntries(Object.entries(headers).filter((entry) => entry[1] !== undefined));
const mergeHeaderContainers = (source1, source2) => {
if (isPlainObject(source1) && isPlainObject(source2)) {
return normalizeHeaderObject({ ...source1, ...source2 });
}
return mergeHeaders(source1, source2);
};
function newHookValue(original, incoming, property) {
return (Object.hasOwn(incoming, property) && incoming[property] === undefined)
? []
: deepMerge(original[property] ?? [], incoming[property] ?? []);
}
export const mergeHooks = (original = {}, incoming = {}) => ({
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');
const appendSearchParameters = (target, source) => {
const result = new URLSearchParams();
const deleted = new Set();
for (const input of [target, source]) {
if (input === undefined) {
continue;
}
if (input instanceof URLSearchParams) {
for (const [key, value] of input.entries()) {
result.append(key, value);
deleted.delete(key);
}
const inputDeleted = input[deletedParametersSymbol];
if (inputDeleted) {
for (const key of inputDeleted) {
result.delete(key);
deleted.add(key);
}
}
}
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');
}
result.append(String(pair[0]), String(pair[1]));
deleted.delete(String(pair[0]));
}
}
else if (isObject(input)) {
for (const [key, value] of Object.entries(input)) {
if (value === undefined) {
result.delete(key);
deleted.add(key);
}
else {
result.append(key, String(value));
deleted.delete(key);
}
}
}
else {
// String
const parameters = new URLSearchParams(input);
for (const [key, value] of parameters.entries()) {
result.append(key, value);
deleted.delete(key);
}
}
}
if (deleted.size > 0) {
result[deletedParametersSymbol] = deleted;
}
return result;
};
// TODO: Make this strongly-typed (no `any`).
export const deepMerge = (...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)) {
// Special handling for AbortSignal instances
if (key === 'signal' && value instanceof globalThis.AbortSignal) {
signals.push(value);
continue;
}
const replaceState = getReplaceState(value);
const { isReplace } = replaceState;
value = replaceState.value;
// Special handling for context - shallow merge only
if (key === 'context') {
if (value !== undefined && value !== null && (!isObject(value) || Array.isArray(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 || value === null)
? {}
: (isReplace
? { ...value }
: { ...returnValue.context, ...value }),
};
continue;
}
// Special handling for searchParams
if (key === 'searchParams') {
if (value === undefined || value === null) {
// Explicit undefined or null removes searchParams
searchParameters = undefined;
}
else if (isReplace) {
searchParameters = value;
}
else {
// First source: keep as-is to preserve type (string/object/URLSearchParams)
// Subsequent sources: merge and convert to URLSearchParams
searchParameters = searchParameters === undefined ? value : appendSearchParameters(searchParameters, value);
}
continue;
}
if (isObject(value) && !isReplace && key in returnValue) {
value = deepMerge(returnValue[key], value);
}
returnValue = { ...returnValue, [key]: value };
}
if (isObject(source.hooks)) {
const { value: hookValue, isReplace } = getReplaceState(source.hooks);
hooks = isReplace
? mergeHooks({}, hookValue)
: mergeHooks(hooks, hookValue);
returnValue.hooks = hooks;
}
if (isObject(source.headers)) {
const { value: headerValue, isReplace } = getReplaceState(source.headers);
headers = isReplace
? cloneShallow(headerValue)
: mergeHeaderContainers(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 remove when the `supportsAbortSignal` check is removed.`
returnValue.signal = signals.at(-1);
}
}
return returnValue;
};
//# sourceMappingURL=merge.js.map