keystonejs / grappling-hook Goto Github PK
View Code? Open in Web Editor NEWHooks for pre/post events used by KeystoneJS
License: MIT License
Hooks for pre/post events used by KeystoneJS
License: MIT License
var grappling = require('grappling-hook');
var instance = grappling.create(); // create an instance
instance.addHooks({ // declare the hookable methods
save: function(done){
console.log('save!');
done && done();
}
});
instance.pre('save', function(){ //allow middleware to be registered for a hook
console.log('saving!');
}).post('save', function(){
console.log('saved!');
});
instance.save();
I just copy this example and run.
somehow something goes wrong
TypeError: object is not a function
at safeNext (...node_modules/grappling-hook/index.js:74:4)
at ...node_modules/grappling-hook/index.js:116:4
at ...node_modules/grappling-hook/node_modules/async/lib/async.js:154:25
at async.eachSeries.asyncFinished (...node_modules/grappling-hook/index.js:111:5)
at iterate (...node_modules/grappling-hook/node_modules/async/lib/async.js:146:13)
at ...node_modules/grappling-hook/node_modules/async/lib/async.js:157:25
at ...node_modules/grappling-hook/index.js:71:5
at process._tickDomainCallback (node.js:381:11)
Building on the API docs in #1, I propose the following:
var hook = require('grappling-hook')
hook.attach(MyClass)
hook.mixin(myObj)
MyClass.hookup(event, fn)
// e.g.
MyClass.hookup('save', fn) // registers pre:save and post:save
MyClass.hookup('pre:save', fn) // only allows pre:save
Adds MyClass[event]
as a method that wraps fn
with hooks
calls. Alternatively, if fn
is omitted and {event}
is a already a method on the object, the existing method is wrapped.
Async handlers are automatically detected by the presence of done
, next
or callback
in the argument names. Async handlers are provided (err, ...args)
. Errors are passed from pre
events to the handler. Non-async handlers throw when they receive err.
If you don't want automatic hook wrapping, you can just specify than an event is hookable:
MyClass.hookable(event)
// e.g.
MyClass.hookable('save') // registers pre:save and post:save
MyClass.hookable('pre:save') // only allows pre:save
MyClass[hook](event, fn)
e.g.
MyClass.pre('save', fn)
MyClass.post('save', fn)
Async handlers are automatically detected by the presence of done
, next
or callback
in the argument names.
Non-async event handlers are automatically wrapped for internal consistency.
Allow parallel event handlers by accepting both next
and done
arguments:
MyClass.pre('save', true, function(...args, next, done) {
next() // middleware continues
doSomethingAsync(function() {
done() // hook is complete, save can continue
})
});
MyClass.getHooks() // { pre: { save: [fn] } }
MyClass.getHooks('save') // { pre: [fn] }
MyClass.getHooks('pre:save') // [fn]
MyClass.hasHooks('pre:save') // shortcut for MyClass.getHooks('pre:save').length ? true : false
MyClass.hooks('pre:save', [args]) // fires a sync event; throws if an async handler is bound
MyClass.hooks('pre:save', [args], callback) // fires an async event; handler is wrapped if sync
An Error
is thrown when sync events are called with a callback, and vice-versa.
field.unhook('pre:save', fn) // removes `fn` as a `pre:save` hook
field.unhook('pre:save') // removes ALL hooks for `pre:save`
field.unhook('save') // removes ALL `save` hooks
field.unhook() // removes ALL hooks
As suggested by @mattapperson in #15 it might be useful to have error handling middleware.
Decisions to make:
How are they registered? To qualified hook identifiers? Or action identifiers? Or post
only?
What would their signature be? (err, p0, p1, p2)
or simply (err)
?
Do we allow async error handlers or strictly synced? How does this integrate with promises?
Also, what sequence would they run in? Before the final callback or after?
Is it just me or are some exceptions disappearing into a black-hole vortex? :-)
Really nice library. But unfortunately since it actually acts on instances (and not on prototypes perhaps) it breaks when using Proxy
traps. A somewhat complicated example. Lets say we are creating a modeling library (think of any ODM for example like Mongoose) that takes a schema / descriptor and returns a model class based on the schema. We might have a base class that encapsulates all the behaviour we would like of a client's model instance and a function to actually build a class that extends our base. We can use Proxies to force the model instances to only have properties based on the schema and of the correct type.
A simple example can be like the following:
compile.js
:
const _ = require('lodash')
const grappling = require('grappling-hook')
const _privateKey = '_privateKey'
const PROXY = true
class Base {
constructor(values, descriptor) {
// Object used to store internals.
const _private = this[_privateKey] = {}
// Object with getters and setters bound.
_private._getset = this
// Public version of ourselves.
// Overwritten with proxy if available.
_private._this = this
// Object used to store raw values.
_private._obj = {}
_private.descriptor = descriptor
_.each(descriptor, (p, k) => {
const t = p.toLowerCase()
Object.defineProperty(_private._getset, k, {
configurable: true,
get: () => this[_privateKey]._obj[k],
set: value => {
if (typeof value !== t) {
return
}
this[_privateKey]._obj[k] = value
}
})
})
if (PROXY && typeof Proxy !== 'undefined') {
const proxy = this[_privateKey]._this = new Proxy(this, {
// Ensure only public keys are shown
ownKeys: (target) => {
return Object.keys(this.toObject())
},
// Return keys to iterate
enumerate: (target) => {
return Object.keys(this[_privateKey]._this)[Symbol.iterator]()
},
// Check to see if key exists
has: (target, key) => {
return Boolean(_private._getset[key])
},
// Ensure correct prototype is returned.
getPrototypeOf: () => {
return _private._getset
},
// Ensure readOnly fields are not writeable.
getOwnPropertyDescriptor: (target, key) => {
return {
value: proxy[key],
writeable: !descriptor[key],
enumerable: true,
configurable: true
}
},
// Intercept all get calls.
get: (target, name, receiver) => {
return this[name]
},
// Intercept all set calls.
set: (target, name, value, receiver) => {
if (!descriptor[name]) {
return false
}
this[name] = value
return true
},
// Intercept all delete calls.
deleteProperty: (target, property) => {
this[property] = undefined
return true
}
})
}
if (_.isObject(values)) {
let data = values
this.set(data)
}
return _private._this
}
_toObject(options, json) {
let ret = {}
_.each(this[_privateKey].descriptor, (properties, index) => {
// Fetch value through the public interface.
let value = this[_privateKey]._this[index]
// Clone objects so they can't be modified by reference.
if (typeof value === 'object' && value) {
if (_.isArray(value)) {
value = value.splice(0)
} else if (_.isDate(value)) {
value = new Date(value.getTime())
} else {
value = _.clone(value)
}
}
// Write to object.
ret[index] = value
})
return ret
}
set(path, value) {
if (_.isObject(path) && !value) {
value = path
for (const key in value) {
if (value.hasOwnProperty(key)) {
try {
this[_privateKey]._this[key] = value[key]
} catch (err) {}
}
}
} else {
try {
this[_privateKey]._this[path] = value
} catch (err) {}
}
}
get(path) {
return this[path]
}
toObject(options) {
return this._toObject(options)
}
inspect() {
return this.toObject({})
}
toString() {
return this.inspect()
}
save(options, fn) {
if (_.isFunction(options)) {
fn = options
options = {}
}
if (!fn) {
fn = _.noop
}
process.nextTick(() => {
console.log('save!')
console.dir(this.toObject(), { depth: null })
return fn(null, this)
})
}
}
exports.Base = Base
exports.compile = function compile(schema, hooks) {
class Model extends Base {
constructor(data) {
super(data, schema)
_.forEach(hooks, ho => {
this.addHooks(ho.name)
this[ho.hook](ho.name, ho.fn)
})
}
}
grappling.mixin(Model.prototype)
return Model
}
now if we do:
var compile = require('./compile').compile
// we only want our models to have foo and bar of the following types
var MyModel1 = compile({
foo: 'string',
bar: 'number'
});
var m = new MyModel1({foo: 'something'});
console.log(m)
m.asdf = 'asdf' // this is not going to work
console.log(m)
console.log(m.asdf)
m.save(function(err, res) {
console.log('done!')
});
Everything works as expected:
{ foo: 'something', bar: undefined }
{ foo: 'something', bar: 11 }
undefined // asdf wasn't saved because it's not allowed
save!
{ foo: 'something', bar: 11 }
done!
If we try to add a hook options:
var MyModel2 = compile({
foo: 'string',
bar: 'number'
}, [{
hook: 'pre',
name: 'save',
fn: function(next) {
console.log('pre save')
next()
}
}]);
var m2 = new MyModel2({foo: 'something'});
console.log(m2)
m2.asdf = 'asdf'
m2.bar = 11
console.log(m2)
console.log(m2.asdf)
m2.save(function(err, res) {
console.log('done!')
});
We get
/Users/bojand/dev/nodejs/grappling-test/node_modules/grappling-hook/index.js:284
instance[hookObj.name] = function() {
^
TypeError: 'set' on proxy: trap returned falsish for property 'save'
...
Because we do not allow writing to the instance an unknown key ('save'
in this case). Note that if we turn off proxy usage with const PROXY = false
this works... but then asdf
would actually be written to the object.
attach
also doesn't work and I've tried messing around with the lib but thought I would ask for advice... Any ideas on how we can get the library to work in this type of scenario? To make it perhaps somehow wrap on a prototype and not the actual instances? Any suggestions are helpful, and I can explore them... I could use this functionality.
All the above code should work natively (ie. w/o Babel) with Node 6.
Thanks!
Bojan
var grappling, instance;
grappling = require('grappling-hook');
instance = grappling.create();
instance.addHooks({
save: function(done) {
console.log('save!');
done();
}
});
instance.pre('save', function(next) {
console.log('saving!');
next("saving")
}).post('save', function() {
console.log('saved!'); //this post hook never be called
});
instance.save(function(err) {
console.log('All done!!');
});
anything I miss?
See here:
keystonejs/keystone#2477 (comment)
I am trying to add a new route to keystonejs, because my intention is to have two contextroots using recaptcha for spamming protection.
What I did to add the route:
In my keystone.js file there is a line: keystone.set('routes', require('./routes'));
so I added:
keystone.set('protectedroutes', require('./protectedroutes'));
because the name of my folder is "protectedroutes".
My part in the middleware.js:
exports.initContributes = function (req, res, next) {
if (!(req.body['g-recaptcha-response'] && req.body['g-recaptcha-response'].length)) {
return res.json({
"responseCode": 1,
"responseDesc": "Please select captcha"
});
}
if (validateRecaptcha(req.body['g-recaptcha-response'], req.connection.remoteAddress)) {
next();
} else {
return res.json({
"responseCode": 1,
"responseDesc": "Failed captcha verification"
});
}
};
My whole index.js:
var keystone = require('keystone'),
middleware = require('./middleware'),
importRoutes = keystone.importer(__dirname),
importProtectedRoutes = keystone.importer('./protectedroutes');
// Common Middleware
keystone.pre('routes', middleware.initLocals);
keystone.pre('protectedroutes', middleware.initContributes);
keystone.pre('render', middleware.flashMessages);
// Import Route Controllers
var routes = {
views: importRoutes('./views')
},
protectedRoutes = {
views: importProtectedRoutes('./views')
};
// Setup Route Bindings
exports = module.exports = function (app) {
// Views
app.get('/', routes.views.index);
app.get('/blog/:category?', routes.views.blog);
app.get('/blog/post/:post', routes.views.post);
app.get('/lockoverview/:category?', routes.views.lockoverview);
app.get('/systemoverview/:category?', routes.views.systemoverview);
app.get('/tooloverview/:category?', routes.views.tooloverview);
app.get('/lockoverview/lock/:lock', routes.views.lock);
app.get('/systemoverview/system/:system', routes.views.system);
app.get('/tooloverview/tool/:tool', routes.views.tool);
app.get('/gallery', routes.views.gallery);
app.all('/contact', routes.views.contact);
app.all('/contributelock', protectedRoutes.views.contributelock);
app.all('/contributetool', protectedRoutes.views.contributetool);
// NOTE: To protect a route so that only admins can see it, use the requireUser middleware:
// app.get('/protected', middleware.requireUser, routes.views.protected);
};
Error: if(cache.opts.strict) throw new Error('Hooks for ' + hook + ' are not supported.'); Error: Hooks for pre: protectedroutes are not supported.
It is caused by this line:
keystone.pre('protectedroutes', middleware.initContributes);
What am I doing wrong?
One of the things I racked my brain on when writing the parallel stuff was how to deal with next
and done
. ATM the synchronisation strategy is left to the consumer, i.e. if next
is called synchronously, it will immediately call the next middleware. This has subtle but important effects, for instance:
function createParallel(name) {
return function(next, done) {
console.log(name + ' setup');
setTimeout(function() {
console.log(name + ' done');
done();
}, 0);
next();// <------------------- next is called after the async stuff is fired off
};
}
instance.pre('test', createParallel("A"), createParallel("B")).callHook('pre:test');
# output
A setup
B setup
A done
B done
However
function createParallel(name) {
return function(next, done) {
console.log(name + ' setup');
next(); // <------------------- next is called before the async stuff is fired off
setTimeout(function() {
console.log(name + ' done');
done();
}, 0);
};
}
instance.pre('test', createParallel("A"), createParallel("B")).callHook('pre:test');
# output
A setup
B setup
B done
A done
Now B finishes first. This has its pros and cons.
For a long time I wanted to next-tick the parallel next
, to be sure all of the sync code would finish before the next middleware is called.
But then I realized that it makes next
useless. It doesn't matter where you call it in your parallel middleware, the function will execute in full, which means we might as well leave out next
(disregarding the fact we need it to recognize parallel middleware)
On the other hand: the above might lead to some unwanted side-effects. Granted, I can't come up with a case though. I mean, if you allow your function to execute in parallel with other, possibly unknown functions, it's your responsibility to make sure it really doesn't matter what the other functions are doing.
Now this is just for parallel middleware, but I had to ask myself the same question regarding the "final" callback. I.e. the one that's passed to callHook
.
ATM if all your middleware is sync, the callHook
call is finished sync as well. I'm not entirely sure this is ok.
@JedWatson any thoughts?
Or @JohnnyEstilles ?
I would like to suggest an additional type of hook, a filter hook.
Plugins should be able to effect change within other plugins or core itself. I would state that I think the ability to have filter hooks has been a major contributor to word presses success as It allows a system to be far more flexible.
For example, word presses query filters:
function exclude_category( $query ) {
if ( $query->is_home() && $query->is_main_query() ) {
$query->set( 'cat', '-1,-1347' );
}
}
add_action( 'pre_get_posts', 'exclude_category' );
In the above case, a plugin can filter the results a user sees.
OK so in this example:
instance.pre('initPlugins', function (foo, next) {
foo.push('funky');
next(null, foo)
})
instance.pre('initPlugins', function (foo, next) {
foo.push('monkey');
next(null, foo)
});
instance.callHook('pre:initPlugins', [
'basic-plugin-1'
], function () {
// arguments = { '0': undefined }
});
Not the result I expected. This next example is close, but still not the expected behavior:
instance.pre('initPlugins', function (foo, next) {
foo.push('funky');
next(null, foo)
})
instance.pre('initPlugins', function (foo, next) {
foo.push('monkey');
next(foo)
});
instance.callHook('pre:initPlugins', [
'basic-plugin-1'
], function () {
// arguments = { '0': [ 'basic-plugin-1', 'funky', 'monkey' ] }
});
My issue is, it seems hooks can pass data between each other, but not back to the calling code. Only errors seem to make it back. Is this intended? A Bug? Or am I missing something in the docs?
@creynders, I'm not sure if you're willing to support this, but I ran into a weird edge-case. This is the scenario:
var instance = {
// "method" is SYNC, "fn" is NOT a callback
method: function(fn) { // <-- grappling assumes this is ASYNC when arg not passed
if('function' === typeof fn) {
... do something with fn
}
}
}
grappling.mixin(instance)
.addHooks('method')
.pre('method', function() {
... do something
});
instance.method();
Since the fn
is not passed to instance.method()
last parameter ( grappling assumes fn
) is a function
.method()
is async., when in this particular case it's not.
Would you be willing to support this edge-case? Maybe with an .addHooksSync()
method or something similar ...
First, @creynders and @JedWatson this is an AWESOME hooks implementation. I've been using using kareem ever since hooks became unsupported, but I'm going to migrate all my apps to grappling. This implementation is "off-the-hook". :-)
Enough compliments, now back to business.
I ran into a use-case recently (might be a bit of an edge case) in which I needed to support pre/post hooks for a method that returned a promise. My initial workaround was simply to wrap the method in an async function with a callback upon resolution. While this works fine, I've been encountering this use-case more frequently, so I was considered submitting a PR to kareem to support this.
Now that I'm switching to grappling, I'm wondering if you're inclined to support this feature.
Thanks again for sharing this! It is, by far, the best hooks implementation I've seen.
ATM all middleware gets the original arguments passed as parameters and are executed in the scope of the emitting instance, e.g.
instance.whatever = 'mofo!';
instance.pre('save', function(a,b){
console.log(a); //outputs 'foo'
console.log(b); //outputs 'bar'
console.log(this.whatever); //outputs 'mofo!'
});
instance.save('foo', 'bar', _.noop);
However, no doubt there's situations where we want to pass some kind of result to post
middleware.
Question is: how?
Would it make more sense to not pass the original arguments to post
middleware, but the results instead?
Or should we pass it as the first parameter and shift the others? E.g.
instance.post('save', function(result, a,b){
console.log(result); // outputs the results of the `save` operation
console.log(a); //outputs 'foo'
console.log(b); //outputs 'bar'
});
instance.save('foo', 'bar', _.noop);
Hi there, I want to report (possible) a bug.
inside /index.js
line 206 method iterateAsyncMiddleware
:
async.eachSeries(middleware, function(callback, next) {
var d = callback.length - args.length;
switch (d) {
case 1: //async series
callback.apply(context, args.concat(next));
break;
case 2: //async parallel
callback.apply(context, args.concat(next, wait(callback)));
break;
default :
//synced
var err;
var result;
try {
result = callback.apply(context, args);
} catch (e) {
err = e;
}
if (!err && module.exports.isThenable(result)) {
//thenable
result.then(function() {
next();
}, next);
} else {
//synced
next(err);
}
}
}, function(err) {
var d = callback.length - args.length;
will not pass next
to callback
(middleware) if I register pre routes hook like this:
keystone.pre('routes', function () {
return someMiddleware.apply(null, arguments);
});
This happens because there's a 3rd party libraries that use coffeescript. (I'm using asset-rack)
the converted(from coffeescript to javascript) function (middleware) is as follows
function () {
fn.apply(me, arguments);
}
callback.length
will have value 0, and it will go to the default
switch case and won't get the async next
callback.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
๐ Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. ๐๐๐
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google โค๏ธ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.