Attach events to Lists and create simple restful routes.
npm install keystone-live
For Keystone v0.4.x apiRoutes
must be added in routes as the onMount
event is fired too late.
For keystone-live 0.2.0 you must include a keystone instance with init
.
var keystone = require('keystone');
var Live = require('keystone-live');
// Add keystone.init()
// Add keystone models
// ...
Live.init(keystone);
keystone.set('routes', function(app) {
var opts = {
exclude: '_id,__v',
route: 'galleries',
paths: {
get: 'find',
create: 'new'
}
}
Live.
apiRoutes('Post').
apiRoutes('Gallery',opts);
});
keystone.start({
onStart: function() {
Live.
apiSockets().
listEvents();
}
});
For Keystone 0.3.x and below you can add apiRoutes
in the onMount
event.
var Live = require('keystone-live');
// optionally add the keystone instance
// Live.init(keystone)
keystone.start({
onMount: function() {
Live.apiRoutes();
},
onStart: function() {
Live.
apiSockets().
listEvents();
}
});
A complete demo with live testbed is included in the git repo (not npm).
View the README for installation.
The following is a list of methods available.
@param keystone {Instance} - Keystone instance
@return this
In order to use keystone-live 2.0+ you must include a keystone instance with .init(keystone)
. Keystone-live <2.0 .init
is optional.
@param list {String} - Optional Keystone List key
@param options {Object} - Optional Options
@return this
Set list
= false
to attach routes to all lists. Call multiple times to attach to chosen Lists.
For Keystone v0.4.x apiRoutes
must be added in routes as the onMount
event is fired too late. For Keystone 0.3.x and below you can add apiRoutes
in the onMount
event.
keystone.set('routes', function(app) {
var opts = {
exclude: '_id,__v',
route: 'galleries',
paths: {
get: 'find',
create: 'new'
}
}
live.
apiRoutes('Post').
apiRoutes('Gallery',opts);
});
options
is an object that may contain:
skip - {String} - Comma seperated string of default Routes to skip.
exclude - {String} - Comma seperated string of Fields to exclude from requests (takes precedence over include)
include - {String} - Comma seperated string of Fields to include in requests
auth - {...Boolean|Function} - Global auth.true
sets check ofreq.user
middleware - {...Array|Function} - Global middleware routes
route - {String} - Root path without pre and trailing slash eg: api paths - {Object} rename the default action uri pathscreate - {String}
get - {String}
list - {String}
remove - {String}
update - {String}
updateField - {String}
routes - {Object} override the default routes
create - {...Object|Function}
get - {...Object|Function}
list - {...Object|Function}
remove - {...Object|Function}
update - {...Object|Function}
updateField - {...Object|Function}
additionalCustomRoute - {...Object|Function} - add your own routesEach route can be a single route function or an object that contains:
route - {Function} - your route function
auth - {...Boolean|Function} - auth for the route. usetrue
for the built inreq.user
check.
middleware - {...Array|Function} - middleware for the route.
excludeFields - {String} - comma seperated string of fields to exclude.'_id, __v'
(takes precedence over include)
includeFields - {String} - comma seperated string of fields to exclude.'name, address, city'
NOTE: include
and exclude
can be set for each list individually, before applying to all other lists with Live.apiRoute(null, options)
. exclude
takes precedent over include
and only one is used per request. You can override the global setting per request.
var opts = {
route: 'api/v2',
exclude: '_id, __v',
auth: false,
paths: {
remove: 'delete'
},
skip: 'create, remove',
routes: {
get: {
auth: false,
middleware: [],
route: function(list) {
return function(req, res) {
console.log('custom get');
list.model.findById(req.params.id).exec(function(err, item) {
if (err) return res.apiError('database error', err);
if (!item) return res.apiError('not found');
var data2 = {}
data2[list.path] = item;
res.apiResponse(data2);
});
}
},
},
create: {
auth: function requireUser(req, res, next) {
if (!req.user) {
return res.apiError('not authorized', 'Please login to use the service', null, 401);
} else {
next();
}
},
},
yourCustomFunction: function(list) {
return function(req, res) {
console.log('my custom function');
list.model.findById(req.params.id).exec(function(err, item) {
if (err) return res.apiError('database error', err);
if (!item) return res.apiError('not found');
var data2 = {}
data2[list.path] = item;
res.apiResponse(data2);
});
}
}
}
}
// add rest routes to all lists
Live.apiRoutes(false, opts);
// add rest routes to Post
// Live.apiRoutes('Post', opts);
Created Routes
Each registered list gets a set of routes created and attached to the schema. You can control the uri of the routes with the options
object as explained above.
action | route |
---|---|
list | /api/posts/list |
create | /api/posts/create |
get | /api/posts/:id |
update | /api/posts/:id/update |
updateField | /api/posts/:id/updateField |
remove | /api/posts/:id/remove |
yourRoute | /api/posts/:id/yourRoute |
yourRoute | /api/posts/yourRoute |
Modifiers: each request can have relevant modifiers added to filter the results.
include: 'name, slug' - fields to include in result
exclude: '__v' - fields to exclude from result
populate: 'createdBy updatedBy' - fields to populate
populate: 0 - do not populate - createdBy and updatedBy are defaults
limit: 10 - limit results
skip: 10 - skip results
sort: {} - sort results
route requests look like
/api/posts/55dbe981a0699a5f76354707/?list=Post&path=posts&emit=get&id=55dbe981a0699a5f76354707&exclude=__v&populate=0
alias
.live
@param options {Object} - Options for creating events
@return callback {Function}
Create the socket server and attach to events
Returns this
if no callback
provided.
options
is an object that may contain:
exclude - {String} - Comma seperated Fields to exclude from requests (takes precedence over include)
include - {String} - Fields to include in requests
auth - {...Boolean|Function} - require user
middleware - {...Array|Function} - global middleware function stackfunction(socket, data, next)
listConfig - {Object} - configuration for listsonly - {String} - comma seperated string of Lists allowed (takes first precedence)
skip - {String} - comma seperated string of Lists not to allow
lists - {Object} - individual List config
KEY - {Object} - Each Key should be a valid List with an object consisting of:
exclude - {String} - comma seperated string of routes to exclude.
'create, update, remove, updateField'
...get|find|list - {...Object|Function} - all of the routes
auth - {...Boolean|Function} - global auth funtion ortrue
for default auth for all paths
middleware - {...Array|Function} - global middleware function stack for all pathsfunction(socket, data, next)
routes - {Object} - override the default routes
create - {...Object|Function}
get - {...Object|Function} returnsObject
find - {...Object|Function} alias of list
list - {...Object|Function} returnsArray
ofObjects
remove - {...Object|Function}
update - {...Object|Function}
updateField - {...Object|Function}
...customRoutes - {...Object|Function} - create your own routes
Each route can be a Function or an object consisting of:route - {Function} - route to run
auth - {...Boolean|Function} - auth funtion ortrue
for default auth
middleware - {...Array|Function} - middleware stack - function(socket, data, next)
excludeFields - {String} - comma seperated string of fields to exclude.'_id, __v'
(takes precedence over include)
includeFields - {String} - comma seperated string of fields to exclude.'name, address, city'
var opts = {
include: 'name,slug,_id,createdAt',
auth: function(socket, next) {
if (socket.handshake.session) {
console.log(socket.handshake.session)
var session = socket.handshake.session;
if(!session.userId) {
console.log('request without userId session');
return next(new Error('Authentication error'));
} else {
var User = keystone.list(keystone.get('user model'));
User.model.findById(session.userId).exec(function(err, user) {
if (err) {
return next(new Error(err));
}
if(!user.isAdmin) {
return next(new Error('User is not authorized'))
}
session.user = user;
next();
});
}
} else {
console.log('session error');
next(new Error('Authentication session error'));
}
},
routes: {
// all functions except create and update follow this argument structure
get: function(data, socket, callback) {
console.log('custom get');
if(!_.isFunction(callback)) callback = function(err,data){
console.log('callback not specified for get',err,data);
};
var list = data.list;
var id = data.id;
if(!list) return callback('list required');
if(!id) return callback('id required');
list.model.findById(id).exec(function(err, item) {
if (err) return callback(err);
if (!item) return callback('not found');
var data = {}
data[list.path] = item;
callback(null, data);
});
},
yourCustomRoute: function(data, req, socket, callback) {
// req contains a user field with the session id
console.log('this is my custom room listener function');
if(!_.isFunction(callback)) callback = function(err,data){
console.log('callback not specified for get',err,data);
};
var list = data.list;
var id = data.id;
if(!list) return callback('list required');
if(!id) return callback('id required');
list.model.findById(id).exec(function(err, item) {
if (err) return callback(err);
if (!item) return callback('not found');
var data = {}
data[list.path] = item;
callback(null, data);
});
},
// create and update follow the same argument structure
update: function(data, req, socket, callback) {
if(!_.isFunction(callback)) callback = function(err,data){
console.log('callback not specified for update',err,data);
};
var list = data.list;
var id = data.id;
var doc = data.doc;
if(!list) return callback('list required');
if(!id) return callback('id required');
if(!_.isObject(doc)) return callback('data required');
if(!_.isObject(req)) req = {};
list.model.findById(id).exec(function(err, item) {
if (err) return callback(err);
if (!item) return callback('not found');
item.getUpdateHandler(req).process(doc, function(err) {
if (err) return callback(err);
var data2 = {}
data2[list.path] = item;
callback(null, data2);
});
});
}
}
}
// start live events and add emitters to Post
Live.apiSockets(opts).listEvents('Post');
// alternate new configuration
Live.apiSockets({
auth: false,
listConfig: {
exclude: 'Tool, Brand',
},
middleware: function(data, socket, next) {
debug('should run 1st for everyone' );
data.test = 'Hello Peg!';
next();
},
routes: {
create: {
auth: true,
},
update: {
auth: true,
},
updateField: {
auth: true,
},
remove: {
auth: true,
},
},
lists: {
'RomBox': {
// exclude: 'create',
middleware: [function(data, socket, next) {
debug('run middleware', data.test, socket.handshake.session);
data.test = 'Hello Al!';
next();
}, function(data, socket, next) {
debug('should run 3rd', data.test);
next();
}],
},
'Spec': {
auth: false,
// exclude: 'create',
middleware: [function(data, socket, next) {
debug('run middleware', data.test, socket.handshake.session);
data.test = 'Hello Al!';
next();
}, function(data, socket, next) {
debug('should run 3rd', data.test);
next();
}],
create: {
auth: false,
},
update: {
auth: false,
},
updateField: {
auth: false,
},
remove: {
auth: false,
},
}
}
});
Modifiers: each request can have relevant modifiers added to filter the results.
include: 'name, slug' - fields to include in result
exclude: '__v' - fields to exclude from result
populate: 'createdBy updatedBy' - fields to populate
populate: 0 - do not populate - createdBy and updatedBy are defaults
limit: 10 - limit results
skip: 10 - skip results
sort: {} - sort results
socket requests look like - see socket requests and client
var data = {
list: 'Post',
limit: 10,
skip: 10,
sort: {}
}
live.io.emit('list', data);
Listens to emitter events
/* add Live doc events */
Live.on('doc:' + socket.id, docEventFunction);
/* add Live doc pre events */
Live.on('doc:Pre', docPreEventFunction);
/* add Live doc post events */
Live.on('doc:Post', docPostEventFunction);
/* add Live list event */
Live.on('list:' + socket.id, listEventFunction);
user emitters sent to individual sockets
// list emit
socket.emit('list', event);
// document pre events
socket.emit('doc:pre', event);
socket.emit('doc:pre:' + event.type, event);
// document post events
socket.emit('doc:post', event);
socket.emit('doc:post:' + event.type, event);
// document event
socket.emit('doc', event);
socket.emit('doc:' + event.type, event);
global emitters
global events sent to rooms on change events only.
// room unique identifier sent by user - emit doc
if(event.iden) {
emitter.to(event.iden).emit('doc' , event);
emitter.emit(event.iden , event);
emitter.to(event.iden).emit('doc:' + event.type , event);
}
// the doc id - doc:_id
if(event.id) {
emitter.to(event.id).emit('doc', event);
emitter.emit(event.id, event);
emitter.to(event.id).emit('doc:' + event.type, event);
}
// the doc slug - doc:slug
if(event.data && event.data.slug) {
emitter.to(event.data.slug).emit('doc', event);
emitter.to(event.data.slug).emit('doc:' + event.type, event);
emitter.emit(event.iden , event);
}
// the list path - doc:path
if(event.path) {
emitter.to(event.path).emit('doc', event);
emitter.to(event.path).emit('doc:' + event.type, event);
emitter.emit(event.path , event);
}
// individual field listening -
if(event.field && event.id) {
// room event.id:event.field emit doc
emitter.to(event.id + ':' + event.field).emit('doc', event);
emitter.to(event.id + ':' + event.field).emit('doc:' + event.type, event);
emitter.emit(event.id + ':' + event.field , event);
emitter.emit(event.field , event);
// room path emit field:event.id:event.field
emitter.to(event.path).emit('field:' + event.id + ':' + event.field, event);
emitter.to(event.path + ':field').emit('field:' + event.id + ':' + event.field, event);
}
@param keystone {Instance} - Pass keystone in as a dependency
@return this
Useful for development if you want to pass Keystone in
alias
.list
@param list {String} - Keystone List Key
@return this
Leave blank to attach live events to all Lists.
Should be called after Live.apiSockets()
Learn about attached events
List Broadcast Events
  Websocket Broadcast Events
keystone.start({
onStart: function() {
Live.
apiSockets().
listEvents('Post').
listEvents('PostCategory');
}
});
@return this
this.MockRes = require('mock-res');
this.MockReq = require('mock-req');
Live uses the event system to broadcast changes made to registered lists.
Each registered list will broadcast change events.
Socket based events have a finer grain of control and you can listen for specific change events.
A registered list has events attached to the pre and post routines. These are global events that fire anytime a change request happens. You can also listen to the global doc:pre
and doc:post
on the user broadcast (explained below). For greater interactivity and control use the websocket broadcast events.
pre | post | *post |
---|---|---|
init:pre | init:post | |
validate:pre | validate:post | |
save:pre | save:post | save |
remove:pre | remove:post | Â |
*Note that post save has an extra event save:post and save . |
list.schema.pre('validate', function (next) {
var doc = this;
// emit validate event to rooms
changeEvent({
type:'validate:pre',
path:list.path,
data:doc,
success: true
}, Live._live.namespace.lists);
// emit validate input locally
live.emit('doc:Pre',{
type:'validate',
path:list.path,
data:doc,
success: true
});
next();
});
Each method will trigger a local event and a broadcast event.
Each event will send a data object similiar to:
{
type:'save:pre',
path:list.path,
id:doc._id.toString(),
data:doc,
success: true
}
The broadcast event is sent when each action occurs.
changeEvent({
type:'remove:post',
path:list.path,
data:doc,
success: true
}, Live._live.namespace.lists);
changeEvent will send a broadcast to any of the following rooms that are available for listening:
doc._id
list.path
doc.slugEach room emits
doc
anddoc:event.type
// the doc id - event.id
if(event.id) {
emitter.to(event.id).emit('doc', event);
emitter.to(event.id).emit('doc:' + event.type, event);
}
// the doc slug - event.data.slug
if(event.data && event.data.slug) {
emitter.to(event.data.slug).emit('doc', event);
emitter.to(event.data.slug).emit('doc:' + event.type, event);
}
// the list path - event.path
if(event.path) {
emitter.to(event.path).emit('doc', event);
emitter.to(event.path).emit('doc:' + event.type, event);
}
The following are valid event.type
values for List global broadcasts:
init:pre
init:post
validate:pre
validate:post
save:pre
save:post
save
remove:pre
remove:post
The local event will emit doc:Pre
or doc:Post
for the appropriate events
// pre
Live.emit('doc:Pre',{
type:'save',
path:list.path,
id:doc._id.toString(),
data:doc,
success: true
});
// post
Live.emit('doc:Post',{
type:'save',
path:list.path,
id:doc._id.toString(),
data:doc,
success: true
});
We use Live.on
in app to respond and broadcast to the current user.
/* add live doc pre events */
Live.on('doc:Pre', docPreEventFunction);
/* add live doc post events */
Live.on('doc:Post', docPostEventFunction);
function docPreEventFunction(event) {
// send update info to global log
Live.emit('log:doc', event);
/* send the users change events */
socket.emit('doc:pre', event);
socket.emit('doc:pre:' + event.type, event);
}
function docPostEventFunction(event) {
Live.emit('log:doc', event);
/* send the users change events */
socket.emit('doc:post', event);
socket.emit('doc:post:' + event.type, event);
}
Live uses socket.io v~1.3.2 to handle live event transportation. A set of CRUD routes are available and there are several rooms you can subscribe to that emit results.
io is exposed via Live.io
. Our list namespace is Live.io.of('/lists')
.
You will connect to the /lists
namespace in the client to listen to emit events.
There is a generic set of CRUD listeners available to control the database. You do not receive callback results with Websocket CRUD listeners. You will need to pick the best strategy to use to listen for result events from the rooms available. Each listener emits its result to Live.on
. Live.on
will catch each submission and decide who should be notified. View the changeEvent()
behaviour below.
socket.emit('create',{
list: 'Post',
doc: {
title: 'Hello',
},
iden: _uniqueKey_
});
socket.on('create', function(obj) {
live.emit('doc:' + socket.id,{type:'created', path:getList.path, id:doc._id, data:doc, success:true, iden: list.iden});
});
socket.emit(yourCustomRoom,{
list: 'Post', //if available
id: '54c9b9888802680b37003af1', //if available
iden: _uniqueKey_
});
socket.on(*custom*, function(obj) {
live.emit('doc:' + socket.id,{type:'get', path:list.path, data:doc, success:true, iden: list.iden});
});
socket.emit('get',{
list: 'Post',
id: '54c9b9888802680b37003af1',
iden: _uniqueKey_
});
socket.on('get', function(obj) {
live.emit('doc:' + socket.id,{type:'get', path:list.path, id:list.id, data:doc, success:true, iden: list.iden});
});
socket.emit('list',{
list: 'Post',
iden: _uniqueKey_
});
socket.on('list', function(obj) {
live.emit('doc:' + socket.id,{path:list.path, data:docs, success:true});
});
socket.emit('remove',{
list: 'Post',
id: '54c9b9888802680b37003af1',
iden: _uniqueKey_
});
socket.on('remove', function(obj) {
live.emit('doc:' + socket.id,{type:'removed', path:list.path, id:list.id, success:true, iden: list.iden});
});
socket.emit('update',{
list: 'Post',
id: '54c9b9888802680b37003af1',
doc: {
title: 'Bye!',
},
iden: _uniqueKey_
});
socket.on('update', function(obj) {
live.emit('doc:' + socket.id,{type:'updated', path:list.path, id:list.id, data:list.doc, success:true, iden: list.iden});
});
socket.emit('updateField',{
list: 'Post',
id: '54c9b9888802680b37003af1',
field: 'content.brief',
value: 'Help!',
iden: _uniqueKey_
});
// Hello
socket.on('updateField', function(obj) {
live.emit('doc:' + socket.id,{type:'updatedField', path:getList.path, id:list.id, data:list.doc, field:list.field, value:list.value, success:true, iden: list.iden});
});
Instead of returning a http response, each listener emits a local event that the app is waiting for. This event is processed and the correct rooms are chosen to broadcast the result.
There are two emitter namespaces
doc
emitter.to(event.path).emit('doc', event);
emitter.to(event.path).emit('doc:TYPE', event);
list
socket.emit('list', event);
list is only sent to the requesting user
The following are valid event.type
values:
created
get
save
updated
updatedField
custom
Each broadcast is sent to the global doc as well as a computed doc:event.type channel.
changeEvent will send the broadcast to the following rooms that are available for listening:
emitter.to(event.path).emit('doc', event);
emitter.to(event.path).emit('doc:' + event.type, event);
the doc._id
value if available
emitter.to(event.id).emit('doc', event);
emitter.to(event.id).emit('doc:' + event.type, event);
document slug if available
emitter.to(event.data.slug).emit('doc', event);
emitter.to(event.data.slug).emit('doc:' + event.type, event);
field broadcasts to the list.path room and a doc._id:fieldName room
// room event.id:event.field emit doc
emitter.to(event.id + ':' + event.field).emit('doc', event);
emitter.to(event.id + ':' + event.field).emit('doc:' + event.type, event);
// room path emit field:event.id:event.field
emitter.to(event.path).emit('field:' + event.id + ':' + event.field, event);
emitter.to(event.path + ':field').emit('field:' + event.id + ':' + event.field, event);
Dynamic room. Send a unique iden
with each request and the app emits back to a room named after iden
emitter.to(event.iden).emit('doc' , event);
emitter.to(event.iden).emit('doc:' + event.type , event);
To use iden
make sure to kill your event listeners. Here is a simple response trap function:
var trapResponse = function(callback) {
var unique = keystone.utils.randomString();
var cb = function(data) {
socket.removeListener(unique, cb);
callback(data);
}
socket.on(unique, cb);
return unique;
}
var myFn = function(data) {
// do someting
}
socket.emit('create',{
list:'Post',
doc: data,
iden: trapResponse(myFn)
});
The socket instance is exposed at Live.io
.
The /lists
and /
namespaces are reserved. You can create any others of your own.
var sharedsession = require("express-socket.io-session");
/* create namespace */
var myNamespace = Live.io.of('/namespace');
/* session management */
myNamespace.use(sharedsession(keystone.get('express session'), keystone.get('session options').cookieParser));
/* add auth middleware */
myNamespace.use(function(socket, next){
if(!keystone.get('auth')) {
next();
} else {
authFunction(socket, next);
}
});
/* list events */
myNamespace.on("connection", function(socket) {
var req = {
user: socket.handshake.session.user
}
socket.on("disconnect", function(s) {
// delete the socket
delete live._live.namespace.lists;
// remove the events
live.removeListener('doc:' + socket.id, docEventFunction);
live.removeListener('list:' + socket.id, listEventFunction);
live.removeListener('doc:Post', docPostEventFunction);
live.removeListener('doc:Pre', docPreEventFunction);
});
socket.on("join", function(room) {
socket.join(room.room);
});
socket.on("leave", function(room) {
socket.leave(room.room);
});
});
Your client should match up with our server version. Make sure you are using 1.x.x and not 0.x.x versions.
var socketLists = io('/lists');
socketLists.on('connect',function(data) {
console.log('connected');
});
socketLists.on('error',function(err) {
console.log('error',err);
});
socketLists.on('doc:save',function(data) {
console.log('doc:save',data);
});
socketLists.on('doc',function(data) {
console.log('doc',data);
});
socketLists.on('list',function(data) {
console.log('list data',data);
});