Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement using Proxy #73

Merged
merged 12 commits into from Feb 14, 2020
1 change: 0 additions & 1 deletion .travis.yml
Expand Up @@ -3,4 +3,3 @@ node_js:
- '12'
- '10'
- '8'
- '6'
80 changes: 62 additions & 18 deletions index.js
@@ -1,6 +1,6 @@
'use strict';

const processFn = (fn, options) => function (...args) {
const processFn = (fn, options, proxy, unwrapped) => function (...args) {
const P = options.promiseModule;

return new P((resolve, reject) => {
Expand Down Expand Up @@ -29,10 +29,13 @@ const processFn = (fn, options) => function (...args) {
args.push(resolve);
}

fn.apply(this, args);
const self = this === proxy ? unwrapped : this;
Reflect.apply(fn, self, args);
});
};

const filterCache = new WeakMap();

module.exports = (input, options) => {
options = Object.assign({
exclude: [/.+(Sync|Stream)$/],
Expand All @@ -45,24 +48,65 @@ module.exports = (input, options) => {
throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``);
}

const filter = key => {
const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key);
return options.include ? options.include.some(match) : !options.exclude.some(match);
const filter = (target, key) => {
let cached = filterCache.get(target);

if (!cached) {
cached = {};
filterCache.set(target, cached);
}

if (key in cached) {
return cached[key];
}

const match = pattern => (typeof pattern === 'string' || typeof key === 'symbol') ? key === pattern : pattern.test(key);
const desc = Reflect.getOwnPropertyDescriptor(target, key);
const writableOrConfigurableOwn = (desc === undefined || desc.writable || desc.configurable);
const included = options.include ? options.include.some(match) : !options.exclude.some(match);
const shouldFilter = included && writableOrConfigurableOwn;
cached[key] = shouldFilter;
return shouldFilter;
};

let ret;
if (objType === 'function') {
ret = function (...args) {
return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args);
};
} else {
ret = Object.create(Object.getPrototypeOf(input));
}
const cache = new WeakMap();

for (const key in input) { // eslint-disable-line guard-for-in
const property = input[key];
ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property;
}
const proxy = new Proxy(input, {
apply(target, thisArg, args) {
const cached = cache.get(target);

if (cached) {
return Reflect.apply(cached, thisArg, args);
}

const pified = options.excludeMain ? target : processFn(target, options, proxy, target);
cache.set(target, pified);
return Reflect.apply(pified, thisArg, args);
},

get(target, key) {
const prop = target[key];

// eslint-disable-next-line no-use-extend-native/no-use-extend-native
if (!filter(target, key) || prop === Function.prototype[key]) {
return prop;
}

const cached = cache.get(prop);

if (cached) {
return cached;
}

if (typeof prop === 'function') {
const pified = processFn(prop, options, proxy, target);
cache.set(prop, pified);
return pified;
}

return prop;
}
});

return ret;
return proxy;
};
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -43,7 +43,7 @@
"bluebird"
],
"devDependencies": {
"ava": "^0.25.0",
"ava": "^2.4.0",
"pinkie-promise": "^2.0.0",
"v8-natives": "^1.1.0",
"xo": "^0.23.0"
Expand Down
117 changes: 91 additions & 26 deletions test.js
Expand Up @@ -176,7 +176,7 @@ test('`errorFirst` option and `multiArgs`', async t => {
})('🦄', '🌈'), ['🦄', '🌈']);
});

test('class support - creates a copy', async t => {
test('class support - does not create a copy', async t => {
const obj = {
x: 'foo',
y(cb) {
Expand All @@ -186,28 +186,17 @@ test('class support - creates a copy', async t => {
}
};

const pified = m(obj, {bind: false});
const pified = m(obj);
obj.x = 'bar';

t.is(await pified.y(), 'foo');
t.is(pified.x, 'foo');
t.is(await pified.y(), 'bar');
t.is(pified.x, 'bar');
});

test('class support — transforms inherited methods', t => {
const instance = new FixtureClass();
const pInstance = m(instance);

const flattened = {};
for (let prot = instance; prot; prot = Object.getPrototypeOf(prot)) {
Object.assign(flattened, prot);
}

const keys = Object.keys(flattened);
keys.sort();
const pKeys = Object.keys(pInstance);
pKeys.sort();
t.deepEqual(keys, pKeys);

t.is(instance.value1, pInstance.value1);
t.is(typeof pInstance.instanceMethod1().then, 'function');
t.is(typeof pInstance.method1().then, 'function');
Expand Down Expand Up @@ -236,17 +225,6 @@ test('class support - transforms only members in options.include, copies all', t
include: ['parentMethod1']
});

const flattened = {};
for (let prot = instance; prot; prot = Object.getPrototypeOf(prot)) {
Object.assign(flattened, prot);
}

const keys = Object.keys(flattened);
keys.sort();
const pKeys = Object.keys(pInstance);
pKeys.sort();
t.deepEqual(keys, pKeys);

t.is(typeof pInstance.parentMethod1().then, 'function');
t.not(typeof pInstance.method1(() => {}).then, 'function');
t.not(typeof pInstance.grandparentMethod1(() => {}).then, 'function');
Expand Down Expand Up @@ -278,3 +256,90 @@ test('promisify prototype function', async t => {
const instance = new FixtureClass();
t.is(await instance.method2Async(), 72);
});

test('method mutation', async t => {
const obj = {
foo(cb) {
setImmediate(() => cb(null, 'original'));
}
};
const pified = m(obj);

obj.foo = cb => setImmediate(() => cb(null, 'new'));

t.is(await pified.foo(), 'new');
});

test('symbol keys', async t => {
await t.notThrowsAsync(async () => {
const sym = Symbol('sym');
const obj = {[sym]: cb => setImmediate(cb)};
const pified = m(obj);
await pified[sym]();
});
});

// [[Get]] for proxy objects enforces the following invariants: The value
// reported for a property must be the same as the value of the corresponding
// target object property if the target object property is a non-writable,
// non-configurable own data property.
test('non-writable non-configurable property', t => {
const obj = {};
Object.defineProperty(obj, 'prop', {
value: cb => setImmediate(cb),
writable: false,
configurable: false
});

const pified = m(obj);
t.notThrows(() => Reflect.get(pified, 'prop'));
});

test('do not promisify Function.prototype.bind', async t => {
function fn(cb) {
cb(null, this);
}
const target = {};
t.is(await m(fn).bind(target)(), target);
});

test('do not break internal callback usage', async t => {
const obj = {
foo(cb) {
this.bar(4, cb);
},
bar(...args) {
const cb = args.pop();
cb(null, 42);
}
};
t.is(await m(obj).foo(), 42);
});

test('Function.prototype.call', async t => {
function fn(...args) {
const cb = args.pop();
cb(null, args.length);
}
const pified = m(fn);
t.is(await pified.call(), 0);
});

test('Function.prototype.apply', async t => {
function fn(...args) {
const cb = args.pop();
cb(null, args.length);
}
const pified = m(fn);
t.is(await pified.apply(), 0);
});

test('self as member', async t => {
function fn(...args) {
const cb = args.pop();
cb(null, args.length);
}
fn.self = fn;
const pified = m(fn);
t.is(await pified.self(), 0);
});