Skip to content

Commit

Permalink
Implement using Proxy (#73)
Browse files Browse the repository at this point in the history
  • Loading branch information
frangio authored Feb 14, 2020
1 parent e0a8b71 commit 57e9a04
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 46 deletions.
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@ node_js:
- '12'
- '10'
- '8'
- '6'
80 changes: 62 additions & 18 deletions index.js
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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);
});

0 comments on commit 57e9a04

Please sign in to comment.