'use strict'; /** * Dependencies * @ignore */ var uuid = require('./utils').uuid; var message = require('./message'); var Receiver = message.Receiver; /** * Exports * @ignore */ module.exports = Service; /** * Debug logger * * 0: off * 1: performance * 2: console.log * * @type {Function} * @private */ var debug = { 0: () => {}, 1: arg => performance.mark(`[${self.constructor.name}][Service] - ${arg}`), 2: (arg1, ...args) => { var type = `[${self.constructor.name}][${location.pathname}]`; console.log(`[Service]${type} - "${arg1}"`, ...args); } }[0]; /** * Extends `Receiver` * @ignore */ Service.prototype = Object.create(Receiver.prototype); /** * A `Service` is a collection of methods * exposed to a `Client`. Methods can be * sync or async (using Promises). * * @example * * bridge.service('my-service') * .method('ping', param => 'pong: ' + param) * .listen(); * * @class Service * @extends Receiver * @param {String} name The service name * @public */ function Service(name) { if (!(this instanceof Service)) return new Service(name); message.Receiver.call(this, name); // call super this.clients = {}; this.methods = {}; this .on('_disconnect', this.onDisconnect.bind(this)) .on('_connect', this.onConnect.bind(this)) .on('_method', this.onMethod.bind(this)) .on('_off', this.onOff.bind(this)) .on('_on', this.onOn.bind(this)); this.destroy = this.destroy.bind(this); debug('initialized', name); } Service.prototype.inWindow = constructor.name === 'Window'; /** * Define a method to expose to Clients. * The return value of the result of a * returned Promise will be sent back * to the Client. * * @example * * bridge.service('my-service') * * // sync return value * .method('myMethod', function(param) { * return 'hello: ' + param; * }) * * // or async Promise * .method('myOtherMethod', function() { * return new Promise(resolve => { * setTimeout(() => resolve('result'), 1000); * }); * }) * * .listen(); * * @param {String} name * @param {Function} fn * @return {this} for chaining */ Service.prototype.method = function(name, fn) { this.methods[name] = fn; return this; }; /** * Broadcast's an event from a `Service` * to connected `Client`s. * * The third argument can be used to * target selected clients by their * `client.id`. * * @example * * service.broadcast('my-event', { some: data }); // all clients * service.broadcast('my-event', { some: data }, [ clientId ]); // one client * * @memberof Service * @param {String} type The message type/name * @param {*} [data] Data to send with the event * @param {Array} [only] A select list of clients to message * @return {this} */ Service.prototype.broadcast = function(type, data, only) { debug('broadcast', type, data, only); this.eachClient(client => { if (only && !~only.indexOf(client.id)) return; debug('broadcasting to', client.id); this.push(type, data, client.id, { noRespond: true }); }); return this; }; /** * Push message to a single connected Client. * * @example * * client.on('my-event', data => ...) * * ... * * service.push('my-event', { some: data}, clientId) * .then(() => console.log('sent')); * * @public * @param {String} type * @param {Object} data * @param {String} clientId The Id of the Client to push to * @param {Object} options Optional parameters * @param {Boolean} options.noResponse Tell the Client not to respond * (Promise resolves instantly) * @return {Promise} */ Service.prototype.push = function(type, data, clientId, options) { var noRespond = options && options.noRespond; var client = this.getClient(clientId); return message('_push') .set({ recipient: clientId, noRespond: noRespond, data: { type: type, data: data } }).send(client.port); }; Service.prototype.eachClient = function(fn) { for (var id in this.clients) fn(this.clients[id]); }; Service.prototype.getClient = function(id) { return this.clients[id]; }; /** * @fires Service#before-connect * @fires Service#connected * @param {Message} message * @private */ Service.prototype.onConnect = function(message) { debug('connection attempt', message.data, this.name); var data = message.data; var clientId = data.clientId; if (!clientId) return; if (data.service !== this.name) return; if (this.clients[clientId]) return; // before hook this.emit('before-connect', message); if (message.defaultPrevented) return; this.upgradeChannel(message); this.addClient(clientId, message.sourcePort); message.respond(); this.emit('connected', clientId); debug('connected', clientId); }; /** * When a Client attempt to connect we * can sometimes upgrade the to a direct * MessageChannel 'pipe' to prevent * hopping threads. * * We only do this if both: * * A. `MessagePort` was supplied with the 'connect' event. * B. The Client and Service are not both in `Window` contexts * (it's faster to use sync messaging window -> window). * * @param {Message} message the 'connect' message * @private */ Service.prototype.upgradeChannel = function(message) { if (this.inWindow && message.data.originEnv === 'Window') return; var ports = message.event.ports; var channel = ports && ports[0]; if (channel) { message.setSourcePort(channel); this.listen(channel); channel.start(); } debug('channel upgraded'); }; /** * @fires Service#before-disconnect * @fires Service#disconnected * @param {Message} message * @private */ Service.prototype.onDisconnect = function(message) { var client = this.clients[message.data]; if (!client) return; // before hook this.emit('before-disconnect', message); if (message.defaultPrevented) return; this.removeClient(client.id); message.respond(); this.emit('disconnected', client.id); debug('disconnected', client.id); }; /** * @fires Service#before-method * @param {Message} message * @private */ Service.prototype.onMethod = function(message) { debug('on method', message.data); this.emit('before-method', message); if (message.defaultPrevented) return; var method = message.data; var name = method.name; var result; var fn = this.methods[name]; if (!fn) throw error(4, name); try { result = fn.apply(this, method.args); } catch (err) { result = err; } message.respond(result); }; /** * @fires Service#on * @param {Message} message * @private */ Service.prototype.onOn = function(message) { debug('on on', message.data); this.emit('on', message.data); }; /** * @fires Service#off * @param {Message} message * @private */ Service.prototype.onOff = function(message) { debug('on off'); this.emit('off', message.data); }; Service.prototype.addClient = function(id, port) { this.clients[id] = { id: id, port: port }; }; Service.prototype.removeClient = function(id) { delete this.clients[id]; }; /** * Use a plugin with this Service. * @param {Function} fn Plugin function * @return {this} for chaining * @public */ Service.prototype.plugin = function(fn) { fn(this, { 'uuid': uuid }); return this; }; /** * Disconnect a Client from the Service. * @param {Object} client * @private */ Service.prototype.disconnect = function(client) { this.removeClient(client.id); message('disconnect') .set({ recipient: client.id, noRespond: true }) .send(client.port); }; /** * Destroy the Service. * @public */ Service.prototype.destroy = function() { delete this.clients; this.unlisten(); this.off(); }; var sp = Service.prototype; sp['broadcast'] = sp.broadcast; sp['destroy'] = sp.destroy; sp['method'] = sp.method; sp['plugin'] = sp.plugin; /** * Creates new `Error` from registery. * * @param {Number} id Error Id * @return {Error} * @private */ function error(id) { var args = [].slice.call(arguments, 1); return new Error({ 4: 'method "' + args[0] + '" doesn\'t exist' }[id]); } /** * Fires before the default 'connect' logic. * This event acts as a hook for plugin authors * to override default 'connect' behaviour. * * @example * * service.on('before-connect', message => { * message.preventDefault(); * // alternative connection logic ... * }); * * @event Service#before-connect * @param {Message} message - The connect message */ /** * Signals that a Client has connected. * * @example * * service.on('connected', clientId => { * console.log('client (%s) has connected', clientId); * }); * * @event Service#connected * @param {String} clientId - The id of the connected Client */ /** * Fires before the default 'disconnect' logic. * This event acts as a hook for plugin authors * to override default 'disconnect' behaviour. * * @example * * service.on('before-disconnect', message => { * message.preventDefault(); * // alternative disconnection logic ... * }); * * @event Service#before-disconnect * @param {Message} message - The disconnect message */ /** * Signals that a Client has disconnected. * * @example * * service.on('disconnected', clientId => { * console.log('client (%s) has disconnected', clientId); * }); * * @event Service#disconnected * @param {String} clientId - The id of the disconnected Client */ /** * Signals that a Client has begun * listening to a broadcast event. * * @example * * service.on('on', data => { * console.log('client (%s) is listening to %s', data.clientId, data.name); * }); * * @event Service#on * @type {Object} * @property {String} name - The broadcast name * @property {String} clientId - The id of the Client that started listening */ /** * Signals that a Client has stopped * listening to a broadcast event. * * @example * * service.on('off', data => { * console.log('client (%s) stopped listening to %s', data.clientId, data.name); * }); * * @event Service#off * @param {Object} data * @param {String} data.name - The broadcast name * @param {String} data.clientId - The id of the Client that stopped listening */