UNPKG

@sandboxed/diff

Version:

A zero dependency, high-performance, security-conscious JavaScript diffing library

684 lines (675 loc) 18.3 kB
// src/utils/constants.ts var Iterables = /* @__PURE__ */ new Set([Object, Array, Set, Map]); var ChangeType = /* @__PURE__ */ ((ChangeType2) => { ChangeType2["ADD"] = "add"; ChangeType2["UPDATE"] = "update"; ChangeType2["REMOVE"] = "remove"; ChangeType2["NOOP"] = "noop"; return ChangeType2; })(ChangeType || {}); var DefaultPathHints = { map: "__MAP__", set: "__SET__" }; var MAX_DEPTH = 50; var MAX_KEYS = 1e3; var ITERATION_TIMEOUT_CHECK = 1e3; var MAX_TIMEOUT_MS = 1e3; var REDACTED = "*****"; // src/utils/fns.ts function isPrimitive(value) { return value !== Object(value); } function getRawValue(value) { if (isPrimitive(value) && typeof value !== "symbol") return value; return value.toString(); } function isNullOrUndefined(value) { return value === null || value === void 0; } function isCustomClassInstance(object) { if (isNullOrUndefined(object) || typeof object !== "object" || !object.constructor) { return false; } return !object.constructor?.toString?.().includes("[native code]"); } function emptyShellClone(object) { if (isCustomClassInstance(object)) { return Object.create(Object.getPrototypeOf(object)); } if (isObject(object)) { return /* @__PURE__ */ Object.create(null); } return new object.constructor(); } function getWrapper(obj) { let wrapper = ["{", "}"]; if (Array.isArray(obj)) { wrapper = ["[", "]"]; } else if (isCustomClassInstance(obj)) { wrapper = [`${obj.constructor.name} {`, "}"]; } else if (obj instanceof Map) { wrapper = [`Map (${obj.size}) {`, "}"]; } else if (obj instanceof Set) { wrapper = ["Set [", "]"]; } return wrapper; } function getRef(value) { return `ref<${value?.constructor?.name || typeof value}>`; } function isObject(obj) { return obj !== null && typeof obj === "object" && (Object.getPrototypeOf(obj) === Object.prototype || Object.getPrototypeOf(obj) === null); } function areObjects(a, b) { return isObject(a) && isObject(b); } function getEnumerableKeys(obj) { return [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)]; } function isIterable(obj) { return Iterables.has(obj?.constructor) || isObject(obj) || isCustomClassInstance(obj); } // src/diff/shared.ts function lastPathValue(changeType, value) { return { deleted: changeType === "remove" /* REMOVE */, value }; } function includeDiffType(type, config) { return config.include?.includes?.(type) && !config.exclude?.includes?.(type); } function shouldRedactValue(key, config) { const rawKey = getRawValue(key); return config.redactKeys?.includes?.(rawKey); } function createReplacer(config, obj) { const seen = /* @__PURE__ */ new WeakSet(); return function replacer(k, v) { if (shouldRedactValue(k, config)) return REDACTED; if (k !== "" && v === obj) return "[Circular]"; if (v && typeof v === "object") { if (seen.has(v)) return "[Circular]"; seen.add(v); } if (v instanceof Set) { const stringified = JSON.stringify([...v.values()], replacer); seen.delete(v); return `Set ${stringified}`; } if (v instanceof Map) { const entries = [...v.entries()]; const stringified = entries.map(([key, value]) => { if (seen.has(key)) { return `"[Circular]": ${JSON.stringify(value, replacer)}`; } if (!isPrimitive(key)) { seen.add(key); } const serializedValue = shouldRedactValue(key, config) ? REDACTED : JSON.stringify(value, replacer); const serialized = `${JSON.stringify(key, replacer)}: ${serializedValue}`; if (!isPrimitive) { seen.delete(key); } return serialized; }).join(", "); seen.delete(v); return `Map (${entries.length}) { ${stringified} }`; } if (v instanceof Function) { return `Function ${v.name || "(anonymous)"}`; } if (typeof v === "symbol") { return v.toString(); } if (typeof v === "bigint") { return `BigInt(${v.toString()})`; } seen.delete(v); return v; }; } function stringify(obj, config) { return JSON.stringify(obj, createReplacer(config, obj)); } function getObjectChangeResult(lhs, rhs, depth, key, parsedKey, config, path) { const isLhsMap = lhs instanceof Map; const isRhsMap = rhs instanceof Map; let valueInLhs = lhs?.[key]; let valueInRhs = rhs?.[key]; let keyInLhs = Object.hasOwn(lhs, key); let keyInRhs = Object.hasOwn(rhs, key); if (isLhsMap) { valueInLhs = lhs.get(key); keyInLhs = lhs.has(key); } if (isRhsMap) { valueInRhs = rhs.get(key); keyInRhs = rhs.has(key); } const redactValue = shouldRedactValue(key, config); const rawValueInLhs = getRawValue(valueInLhs); const rawValueInRhs = getRawValue(valueInRhs); const formattedValueInLhs = stringify( redactValue ? REDACTED : rawValueInLhs, config ); const formattedValueInRhs = stringify( redactValue ? REDACTED : rawValueInRhs, config ); let type = "noop" /* NOOP */; let formattedValue = formattedValueInRhs; let pathValue = valueInRhs; if (!keyInLhs && keyInRhs) { type = "add" /* ADD */; } else if (keyInLhs && !keyInRhs) { type = "remove" /* REMOVE */; formattedValue = formattedValueInLhs; pathValue = valueInLhs; } else if (config.strict ? rawValueInLhs !== rawValueInRhs : rawValueInLhs != rawValueInRhs) { type = "update" /* UPDATE */; } const result = []; if (includeDiffType(type, config)) { const stringifiedParsedKey = stringify(parsedKey, config); if (type === "update" /* UPDATE */ && !config.showUpdatedOnly) { result.push({ type: "remove" /* REMOVE */, str: `${stringifiedParsedKey}: ${formattedValueInLhs},`, depth, path: [...path, lastPathValue("remove" /* REMOVE */, valueInLhs)] }); } result.push({ type, str: `${stringifiedParsedKey}: ${formattedValue},`, depth, path: [...path, lastPathValue(type, pathValue)] }); } return result; } function getPathHint(config, type) { if (typeof config.pathHints === "object") { const hint = config.pathHints[type]; if (typeof hint === "string") return hint; } return null; } function buildResult(rhs, result, depth, initialChangeType, parent, path, config) { if (!includeDiffType(initialChangeType, config)) return result; const parentDepth = depth - 1; const [open, close] = getWrapper(rhs); return [ { type: initialChangeType, str: parentDepth > 0 ? `${stringify(parent, config)}: ${open}` : open, depth: parentDepth, path }, ...result, { type: initialChangeType, str: parentDepth > 0 ? `${close},` : close, depth: parentDepth, path } ]; } function timeoutSecurityCheck(startedAt, config) { if (Date.now() - startedAt > config.timeout) { throw new Error("Diff took too much time! Aborting."); } } function maxKeysSecurityCheck(size, config) { if (size > config.maxKeys) { throw new Error("Object is too big to continue! Aborting."); } } // src/diff/diffObjects.ts function diffObjects({ recursiveDiff: recursiveDiff2, lhs, rhs, config, depth, parent, seen, initialChangeType, path, startedAt }) { const result = []; const lhsKeys = getEnumerableKeys(lhs); maxKeysSecurityCheck(lhsKeys.length, config); const rhsKeys = getEnumerableKeys(rhs); maxKeysSecurityCheck(rhsKeys.length, config); const keys = /* @__PURE__ */ new Set([...lhsKeys, ...rhsKeys]); let i = 0; for (const key of keys) { if (i++ % ITERATION_TIMEOUT_CHECK === 0) { timeoutSecurityCheck(startedAt, config); } const lhsValue = Array.isArray(lhs) ? lhs[key] : lhs?.[key]; const rhsValue = Array.isArray(rhs) ? rhs[key] : rhs?.[key]; const numericKey = typeof key !== "symbol" ? Number(key) : NaN; const parsedKey = isNaN(numericKey) ? key : numericKey; const updatedPath = [...path, parsedKey]; if (isIterable(lhsValue) || isIterable(rhsValue)) { result.push( ...recursiveDiff2({ lhs: lhsValue, rhs: rhsValue, config, depth: depth + 1, parent: parsedKey, seen, initialChangeType, path: updatedPath, startedAt }) ); continue; } result.push( ...getObjectChangeResult( lhs, rhs, depth, key, parsedKey, config, updatedPath ) ); } seen.delete(lhs); seen.delete(rhs); return buildResult( rhs, result, depth, initialChangeType, parent, path, config ); } var diffObjects_default = diffObjects; // src/diff/diffSets.ts function diffSets({ recursiveDiff: recursiveDiff2, lhs, rhs, config, depth, parent, seen, initialChangeType, path, startedAt }) { const result = []; maxKeysSecurityCheck(lhs.size, config); maxKeysSecurityCheck(rhs.size, config); const mergedSet = /* @__PURE__ */ new Set([...lhs, ...rhs]); let i = 0; for (const value of mergedSet) { if (i++ % ITERATION_TIMEOUT_CHECK === 0) { timeoutSecurityCheck(startedAt, config); } const existsInLhs = lhs.has(value); const existsInRhs = rhs.has(value); const hint = getPathHint(config, "set"); const updatedPath = [...path]; if (hint) { updatedPath.push(hint); } if (isIterable(value)) { result.push( ...recursiveDiff2({ lhs: existsInLhs ? value : void 0, rhs: existsInRhs ? value : void 0, config, depth: depth + 1, parent: getRef(value), seen, initialChangeType, path: updatedPath, startedAt }) ); continue; } let type = "noop" /* NOOP */; if (existsInLhs && !existsInRhs) { type = "remove" /* REMOVE */; } else if (!existsInLhs && existsInRhs) { type = "add" /* ADD */; } if (includeDiffType(type, config)) { result.push({ type, str: `${stringify(value, config)},`, depth, path: [...updatedPath, lastPathValue(type, value)] }); } } seen.delete(lhs); seen.delete(rhs); return buildResult( rhs, result, depth, initialChangeType, parent, path, config ); } var diffSets_default = diffSets; // src/diff/diffMaps.ts function diffMaps({ recursiveDiff: recursiveDiff2, lhs, rhs, config, depth, parent, seen, initialChangeType, path, startedAt }) { const result = []; maxKeysSecurityCheck(lhs.size, config); maxKeysSecurityCheck(rhs.size, config); const mergedMapKeys = /* @__PURE__ */ new Set([...lhs.keys(), ...rhs.keys()]); let i = 0; for (const key of mergedMapKeys) { if (i++ % ITERATION_TIMEOUT_CHECK === 0) { timeoutSecurityCheck(startedAt, config); } const keyInLhs = lhs.has(key); const keyInRhs = rhs.has(key); const lhsValue = keyInLhs ? lhs.get(key) : null; const rhsValue = keyInRhs ? rhs.get(key) : null; const hint = getPathHint(config, "map"); const pathUpdate = hint ? [hint, key] : [key]; const updatedPath = [...path, ...pathUpdate]; if (isIterable(lhsValue) || isIterable(rhsValue)) { result.push( ...recursiveDiff2({ lhs: lhsValue, rhs: rhsValue, config, depth: depth + 1, parent: key, seen, initialChangeType, path: updatedPath, startedAt }) ); continue; } result.push( ...getObjectChangeResult(lhs, rhs, depth, key, key, config, updatedPath) ); } seen.delete(lhs); seen.delete(rhs); return buildResult( rhs, result, depth, initialChangeType, parent, path, config ); } var diffMaps_default = diffMaps; // src/diff/diffConstructors.ts function diffConstructors({ recursiveDiff: recursiveDiff2, lhs, rhs, config, depth, parent, seen, initialChangeType, path, startedAt }) { let modLhs = lhs; let modRhs = rhs; let defaultChangeType = initialChangeType; if (isObject(rhs) || rhs?.constructor) { modLhs = emptyShellClone(rhs); defaultChangeType = "add" /* ADD */; } else if (isObject(lhs) || lhs?.constructor) { modRhs = emptyShellClone(lhs); defaultChangeType = "remove" /* REMOVE */; } else { throw new Error("Edge case raised, I don't know how to handle this input"); } return recursiveDiff2({ lhs: modLhs, rhs: modRhs, config, depth, parent, seen, initialChangeType: defaultChangeType, path, startedAt }); } var diffConstructors_default = diffConstructors; // src/diff/index.ts function recursiveDiff({ lhs, rhs, config, depth, parent, seen, initialChangeType, path, startedAt }) { if (depth > config.maxDepth) { throw new Error("Max depth exceeded!"); } timeoutSecurityCheck(startedAt, config); const lhsSeen = seen.get(lhs) ?? 0; const rhsSeen = seen.get(rhs) ?? 0; if (lhsSeen > 1 || rhsSeen > 1) { if (!includeDiffType(initialChangeType, config)) return []; return [ { type: initialChangeType, str: `${stringify(parent, config)}: [Circular],`, depth: depth - 1, path } ]; } if (typeof lhs === "object" && lhs !== null) seen.set(lhs, lhsSeen + 1); if (typeof rhs === "object" && rhs !== null) seen.set(rhs, rhsSeen + 1); const args = { recursiveDiff, lhs, rhs, config, depth, parent, seen, initialChangeType, path, startedAt }; if (isPrimitive(lhs) && isPrimitive(rhs)) { if (config.strict ? lhs === rhs : lhs == rhs) { return []; } const parentDepth = depth - 1; const lhsValue = stringify(lhs, config); const rhsValue = stringify(rhs, config); const result = []; if (includeDiffType("update" /* UPDATE */, config)) { if (!config.showUpdatedOnly) { result.push({ type: "remove" /* REMOVE */, str: `${lhsValue}`, depth: parentDepth, path: [...path, lastPathValue("remove" /* REMOVE */, lhs)] }); } result.push({ type: "update" /* UPDATE */, str: `${rhsValue}`, depth: parentDepth, path: [...path, lastPathValue("update" /* UPDATE */, rhs)] }); } return result; } if (!areObjects(lhs, rhs) && // Skips for Object.create(null) vs {} (lhs?.constructor !== rhs?.constructor || isNullOrUndefined(lhs) && rhs || lhs && isNullOrUndefined(rhs))) { return diffConstructors_default(args); } if (lhs instanceof Set && rhs instanceof Set) { return diffSets_default(args); } if (lhs instanceof Map && rhs instanceof Map) { return diffMaps_default(args); } return diffObjects_default(args); } var diff_default = recursiveDiff; // src/utils/toDiffString.ts var ANSI_RESET = "\x1B[0m"; function colorWrapper(color) { return (str) => `${color}${str}${ANSI_RESET}`; } var ansiColors = { ["remove" /* REMOVE */]: colorWrapper("\x1B[31m"), ["add" /* ADD */]: colorWrapper("\x1B[32m"), ["update" /* UPDATE */]: colorWrapper("\x1B[33m"), ["noop" /* NOOP */]: colorWrapper("") }; function toDiffString(diff2, config) { const defaultConfig = { withColors: true, colors: ansiColors, wrapper: [], indentSize: 2, symbols: { ["add" /* ADD */]: "+", ["remove" /* REMOVE */]: "-", ["update" /* UPDATE */]: "!", ["noop" /* NOOP */]: "" } }; const mergedConfig = { ...defaultConfig, ...config, colors: { ...defaultConfig.colors, ...config?.colors }, symbols: { ...defaultConfig.symbols, ...config?.symbols } }; const diffString = diff2.map(({ type, str, depth }, index) => { let symbolString = mergedConfig.symbols[type]; if (index > 0 && index < diff2.length - 1 && !symbolString.length) { symbolString = ` ${mergedConfig.symbols[type]}`; } let buildStr = `${symbolString}${" ".repeat(depth * mergedConfig.indentSize)}${str}`; if (mergedConfig.withColors) { buildStr = mergedConfig.colors[type](buildStr); } return buildStr; }).join("\n"); const [open = "", close = ""] = mergedConfig.wrapper || []; return `${open ? `${open} ` : ""}${diffString}${close ? ` ${close}` : ""}`; } // src/index.ts function diff(lhs, rhs, config) { const defaultConfig = { include: [ "add" /* ADD */, "remove" /* REMOVE */, "update" /* UPDATE */, "noop" /* NOOP */ ], exclude: [], strict: true, showUpdatedOnly: false, pathHints: { map: DefaultPathHints.map, set: DefaultPathHints.set }, maxDepth: MAX_DEPTH, maxKeys: MAX_KEYS, timeout: MAX_TIMEOUT_MS, redactKeys: [ "password", "secret", "token", "Symbol(password)", "Symbol(secret)", "Symbol(token)" ] }; const mergedConfig = { ...defaultConfig, ...config }; if (!mergedConfig.maxDepth) mergedConfig.maxDepth = MAX_DEPTH; if (!mergedConfig.maxKeys) mergedConfig.maxKeys = MAX_KEYS; if (!mergedConfig.timeout) mergedConfig.timeout = MAX_TIMEOUT_MS; mergedConfig.include = Array.isArray(mergedConfig.include) ? mergedConfig.include : [mergedConfig.include]; mergedConfig.exclude = Array.isArray(mergedConfig.exclude) ? mergedConfig.exclude : [mergedConfig.exclude]; const diffResult = diff_default({ lhs, rhs, config: mergedConfig, depth: 1, parent: null, seen: /* @__PURE__ */ new WeakMap(), initialChangeType: "noop" /* NOOP */, path: [], startedAt: Date.now() }); const areEqual = diffResult.every( (result) => result.type === "noop" /* NOOP */ ); Object.defineProperty(diffResult, "toDiffString", { value: (diffStringConfig) => toDiffString(diffResult, diffStringConfig), enumerable: false, writable: false, configurable: false }); Object.defineProperty(diffResult, "equal", { value: areEqual, enumerable: false, writable: false, configurable: false }); return diffResult; } var index_default = diff; export { ChangeType, index_default as default }; //# sourceMappingURL=index.js.map