proxy-extend
Version:
Transparently extend a JS object with additional properties (using ES6 Proxy)
216 lines (180 loc) • 8.74 kB
JavaScript
import $msg from 'message-tag';
// Version of `hasOwnProperty` that doesn't conflict
const hasOwnProperty = (obj, propKey) => Object.prototype.hasOwnProperty.call(obj, propKey);
// Cache some values
const nullObject = Object.create(null);
const TypedArray = Object.getPrototypeOf(Int8Array);
const nodeInspectCustom = Symbol.for('nodejs.util.inspect.custom');
export const proxyKey = Symbol('proxy-wrapper.proxy');
export const extend = (_value, _extension = nullObject) => {
let value = _value;
let extension = _extension;
if (typeof extension !== 'object' || extension === null) {
throw new TypeError($msg`Extension must be an object, given ${extension}`);
}
// Check if the given value is already a proxy with extension. If so, flatten.
if (typeof value === 'object' && value !== null && proxyKey in value) {
const unproxied = value[proxyKey];
value = unproxied.value;
extension = { ...unproxied.extension, ...extension };
}
let isString = false;
let isNumber = false;
// Handle primitive values. Because a Proxy always behaves as an object, we cannot really transparently
// "simulate" a primitive. However, we use sensible equivalents where possible.
const valueType = typeof value;
let target = value;
if (valueType === 'undefined') {
throw new TypeError($msg`Cannot construct proxy, given \`undefined\``);
} else if (value === null) {
target = nullObject;
} else if (valueType === 'string') {
target = new String(value);
isString = true;
} else if (valueType === 'number') {
target = new Number(value);
isNumber = true;
} else if (valueType === 'bigint') {
// TODO: we could use a boxed `BigInt` through `Object()` (e.g. `Object(42n) instanceof BigInt`):
// https://2ality.com/2022/02/wrapper-objects.html
throw new TypeError($msg`Cannot construct proxy from bigint, given ${value}`);
} else if (valueType === 'boolean') {
// Note: we could use a boxed `Boolean`, but it would not be very useful because there's not much you can
// do with it. Boxed booleans (including `new Boolean(false)`) are treated as truthy in logic operations.
throw new TypeError($msg`Cannot construct proxy from boolean, given ${value}`);
} else if (valueType === 'symbol') {
throw new TypeError($msg`Cannot construct proxy from symbol, given ${value}`);
} else if (valueType !== 'object' && valueType !== 'function') {
// Note: this shouldn't happen, unless there's a new type of primitive added to JS
throw new TypeError($msg`Cannot construct proxy, given value of unknown type ${valueType}`);
}
// Some methods of built-in types cannot be proxied, i.e. they need to bound directly to the
// target. Because they explicitly check the type of `this` (e.g. `Date`), or because they need
// to access an internal slot of the target (e.g. `String.toString`).
// https://stackoverflow.com/questions/36394479/proxies-on-regexps-and-boxed-primitives
// https://stackoverflow.com/questions/47874488/proxy-on-a-date-object
// https://stackoverflow.com/questions/43927933/why-is-set-incompatible-with-proxy
const usesInternalSlots =
target instanceof String
|| target instanceof Number
|| target instanceof Boolean
|| target instanceof Date
|| target instanceof RegExp
|| target instanceof Map
|| target instanceof WeakMap
|| target instanceof Set
|| target instanceof WeakSet
|| target instanceof ArrayBuffer
|| target instanceof TypedArray;
const handler = {
has(target, propKey) {
if (hasOwnProperty(extension, propKey)) {
// Note: use `hasOwnProperty` for the extension, rather than `in`, because we do not want to
// consider properties in the prototype chain as being part of the extension.
return true;
}
// Implement `toJSON` for boxed primitives (otherwise `JSON.stringify` will not work properly).
if (propKey === 'toJSON' && (isString || isNumber)) { return true; }
if (propKey === nodeInspectCustom) { return true; }
if (propKey === proxyKey) { return true; }
return Reflect.has(target, propKey);
},
get(target, propKey, receiver) {
// Backdoor to get the internal proxied data (value and extension).
// Note: use `value` here, not `target` (target is just an internal representation).
if (propKey === proxyKey) { return { value, extension }; }
let targetProp = undefined;
if (hasOwnProperty(extension, propKey)) {
targetProp = extension[propKey];
} else if (propKey in target) {
targetProp = target[propKey];
// Note: any getter properties will receive the `target`, rather than the proxy as their `this`
// value. Thus, getters will not have access to the extension. If you really need this behavior,
// you can use the following. But it's not recommended, due to the impact on performance.
/*
if (hasOwnProperty(target, propKey)) {
const descriptor = Object.getOwnPropertyDescriptor(target, propKey);
if (typeof descriptor.get === 'function') {
targetProp = descriptor.get.call(receiver);
}
}
*/
} else {
// Fallback: property is present in neither the target nor the extension
// Implement `toJSON` for boxed primitives (otherwise `JSON.stringify` will not work properly).
if (propKey === 'toJSON') {
if (isString) {
targetProp = target.toString.bind(target);
} else if (isNumber) {
targetProp = target.valueOf.bind(target);
}
}
if (propKey === nodeInspectCustom) { targetProp = () => target; }
}
if (typeof targetProp === 'function') {
if (usesInternalSlots) {
// Have `this` bound to the original target
return targetProp.bind(target);
} else {
// Unbound (i.e. `this` can be bound to anything, usually will be the proxy object)
return targetProp;
}
} else {
return targetProp;
}
},
};
return new Proxy(target, handler);
};
// Whether the given value can be proxied
export const isProxyable = value => {
if (typeof value === 'object') { // Also covers the case where `value === null`
return true;
} else if (typeof value === 'function') {
return true;
} else if (typeof value === 'string') {
return true;
} else if (typeof value === 'number') {
return true;
} else {
return false;
}
};
// Whether the given value is a proxy
export const isProxy = value => {
return typeof value === 'object' && value !== null && proxyKey in value;
};
// Unwrap the given proxy to access its internal data
export const unwrapProxy = proxy => {
if (!isProxy(proxy)) {
throw new TypeError($msg`Cannot unwrap input, expected a proxy, received: ${proxy}`);
}
return proxy[proxyKey];
};
// Add some properties to `extend` as shorthand
extend.is = isProxy;
extend.unwrap = unwrapProxy;
// Make formatting of proxies a little nicer
export const registerProxyFormatter = () => {
if (typeof require === 'function') {
const util = require('util');
if (util.inspect && util.inspect.replDefaults) {
util.inspect.replDefaults.showProxy = false;
}
}
// https://stackoverflow.com/questions/55733647/chrome-devtools-formatter-for-javascript-proxy
if (typeof window === 'object' && window !== null) {
if (!Array.isArray(window.devtoolsFormatters)) {
window.devtoolsFormatters = [];
}
window.devtoolsFormatters.push({
header(value) {
if (!isProxy(value)) {
return null;
}
return ['object', { object: value[proxyKey].value }];
},
});
}
};
export default extend;