Skip to content

snowkeeper/keystone-live

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Live data events for KeystoneJS

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();
  }
});

API

Demo

A complete demo with live testbed is included in the git repo (not npm).
View the README for installation.

Method Reference

The following is a list of methods available.

.init ( keystone )

@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.

.apiRoutes ( [ list ], [ options ] )

@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 of req.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 paths

create - {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 routes

Each route can be a single route function or an object that contains:

route - {Function} - your route function
auth - {...Boolean|Function} - auth for the route. use true for the built in req.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

.apiSockets ( [ options ], callback )

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 stack function(socket, data, next)
listConfig - {Object} - configuration for lists

only - {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 or true for default auth for all paths
middleware - {...Array|Function} - global middleware function stack for all paths function(socket, data, next)

routes - {Object} - override the default routes

create - {...Object|Function}
get - {...Object|Function} returns Object
find - {...Object|Function} alias of list
list - {...Object|Function} returns Array of Objects
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 or true 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);
Socket emitters

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);
	
}

.init ( [ keystone ] )

@param keystone {Instance} - Pass keystone in as a dependency
@return this

Useful for development if you want to pass Keystone in


.listEvents ( [ list ] )

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');
    }
});

.router ()

@return this

	this.MockRes = require('mock-res');
	this.MockReq = require('mock-req');

Events

Overview

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.

List Broadcast 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.slug

Each room emits doc and doc: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);
}

Websocket Broadcast Events

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.

CRUD Listeners

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.

create
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});
});
custom
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});
});
get
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});
});
list
socket.emit('list',{
	list: 'Post',
    iden: _uniqueKey_
});

socket.on('list', function(obj) {
	live.emit('doc:' + socket.id,{path:list.path, data:docs, success:true});
});
remove
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});
});
update
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});
});
updateField
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});
});
Broadcast Results

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:

path
emitter.to(event.path).emit('doc', event);
emitter.to(event.path).emit('doc:' + event.type, event);
id

the doc._id value if available

emitter.to(event.id).emit('doc', event);
emitter.to(event.id).emit('doc:' + event.type, event);
slug

document slug if available

emitter.to(event.data.slug).emit('doc', event);
emitter.to(event.data.slug).emit('doc:' + event.type, event);
id:field

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);
iden

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) 
});

Additional io namespaces

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);
    }); 


});
	

Client

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);
		
	});