'use strict'; /** * Dependencies * @ignore */ var createPort = require('./port-adaptors'); var Emitter = require('../emitter'); var utils = require('../utils'); var defer = utils.deferred; var uuid = utils.uuid; /** * Exports * @ignore */ exports = module.exports = type => new Message(type); exports.receiver = (id, n) => new Receiver(id, n); exports.Receiver = Receiver; exports.Message = Message; /** * Mini Logger * * 0: off * 1: performance * 2: console.log * * @type {Function} * @private */ var debug = { 0: () => {}, 1: arg => performance.mark(`[${self.constructor.name}][Message] - ${arg}`), 2: (arg1, ...args) => { var type = `[${self.constructor.name}][${location.pathname}]`; console.log(`[Message]${type} - "${arg1}"`, ...args); } }[0]; /** * Default response timeout. * @type {Number} * @private */ var TIMEOUT = 1000; /** * Initialize a new `Message` * * @class Message * @borrows Emitter#on as #on * @borrows Emitter#off as #off * @borrows Emitter#emit as #emit * @param {String} type Message type */ function Message(type) { this.cancelled = false; this.listeners = []; this.deferred = defer(); this.onMessage = this.onMessage.bind(this); this.onTimeout = this.onTimeout.bind(this); if (typeof type === 'object') this.setupInbound(type); else this.setupOutbound(type); debug('initialized', type); } Message.prototype = { setupOutbound(type) { this.id = uuid(); this.type = type; this.sent = false; this.recipient = '*'; }, setupInbound (e) { debug('inbound'); this.hasResponded = false; this.setSourcePort(e.source || e.target); // Keep a reference to the MessageEvent this.event = e; // Mixin the properties of the original message Object.assign(this, e.data); }, setSourcePort(endpoint) { debug('set source', endpoint.constructor.name); this.sourcePort = createPort(endpoint, { ready: true }); return this; }, set(key, value) { debug('set', key, value); if (typeof key == 'object') Object.assign(this, key); else this[key] = value; return this; }, serialize() { return { id: this.id, type: this.type, data: this.data, recipient: this.recipient, noRespond: this.noRespond }; }, preventDefault() { debug('prevent default'); this.defaultPrevented = true; }, /** * Send the message to an endpoint. * * @param {(Iframe|Window|Worker|MessagePort)} endpoint * @return {Promise} */ send(endpoint) { debug('send', this.type); if (this.sent) throw error(1); var serialized = this.serialize(); var expectsResponse = !this.noRespond; // A port is resolved from either a predefined // port, or an endpoint given as first argument this.port = endpoint ? createPort(endpoint) : this.port; if (!this.port) throw error(3); // If we're expecting a response listen // on the port else resolve promise instantly if (expectsResponse) { this.listen(this.port); this.setResponseTimeout(); } else this.deferred.resolve(); this.port.postMessage(serialized, this.getTransfer()); debug('sent', serialized); return this.deferred.promise; }, /** * Set the response timeout. * * When set to `false` no timeout * is installed. * * @private */ setResponseTimeout() { if (this.timeout === false) return; var ms = this.timeout || TIMEOUT; this._timer = setTimeout(this.onTimeout, ms); }, /** * Clear the response timeout. * * @private */ clearResponseTimeout() { clearTimeout(this._timer); }, getTransfer() { return this.transfer || this.event && this.event.ports; }, onMessage(e) { var valid = !!e.data.response && e.data.id === this.id && !this.cancelled; if (valid) this.onResponse(e); }, onTimeout() { debug('response timeout', this.type); if (!this.silentTimeout) this.deferred.reject(error(4)); this.teardown(); }, listen(thing) { debug('add response listener', thing); var port = createPort(thing); port.addListener(this.onMessage); this.listeners.push(port); return this; }, unlisten() { debug('remove response listeners'); this.listeners.forEach(port => port.removeListener(this.onMessage)); this.listeners = []; }, /** * Cancel a pending Message. * * @example * * var msg = message('foo') * * msg.send(new Worker('my-worker.js')) * .then(response => { * // this will never run * }) * * msg.cancel(); * * @public */ cancel() { this.teardown(); this.cancelled = true; this.emit('cancel'); }, teardown() { this.clearResponseTimeout(); this.unlisten(); }, /** * Respond to a message. * * @example * * receiver.on('hello', message => { * message.respond('world'); * }); * * @public * @param {*} [result] Data to send back with the response */ respond(result) { debug('respond', result); if (this.hasResponded) throw error(2); if (!this.sourcePort) return; if (this.noRespond) return; var self = this; this.hasResponded = true; // Repsond with rejection when result is an `Error` if (result instanceof Error) reject(result); // Call the handler and make // sure return value is a promise. // If the returned value is unclonable // then the send() method will throw, // the .catch() will reject in this case. Promise.resolve(result) .then(resolve, reject) .catch(reject); function resolve(value) { debug('resolve', value); respond({ type: 'resolve', value: value }); } function reject(err) { var msg = err && err.message || err; debug('reject', msg); respond({ type: 'reject', value: msg }); } function respond(response) { self.response = response; self.sourcePort.postMessage({ id: self.id, response: response }, self.transfer); debug('responded with:', response); } }, /** * Forward a `Message` onto another endpoint. * * The `silentTrue` option prevents the * message request timing out should * the response come back via an * alternative route. * * @param {(HTMLIframeElement|MessagePort|Window)} endpoint * @public */ forward(endpoint) { debug('forward'); return this .set('silentTimeout', true) .send(endpoint) .then(result => this.respond(result.value)); }, onResponse(e) { debug('on response', e.data); var response = e.data.response; var type = response.type; var value = type == 'reject' ? response.value : response; response.event = e; this.response = response; this.teardown(); this.deferred[this.response.type](value); this.emit('response', response); } }; // Mixin Emitter methods Emitter(Message.prototype); /** * Initialize a new `Receiver`. * * @class Receiver * @extends Emitter * @param {String} name - corresponds to `Message.recipient` */ function Receiver(name) { this.name = name; this.ports = new Set(); this.onMessage = this.onMessage.bind(this); this['listen'] = this['listen'].bind(this); this['unlisten'] = this['unlisten'].bind(this); debug('receiver initialized', name); } Receiver.prototype = { /** * Begin listening for inbound messages. * * @example * * // When no arguments are given * // messages will be listened for * // on the default global scope * .listen(); * * // When an endpoint is out of reach * // BroadcastChannel can be used. * .listen(new BroadcastChannel('foo')); * * @param {(HTMLIframeElement|Worker|MessagePort| * BroadcastChannel|Window|Object)} [thing] * @public */ listen(thing) { debug('listen'); var _port = createPort(thing || self, { receiver: true }); if (this.ports.has(_port)) return; _port.addListener(this.onMessage, this.listen); this.ports.add(_port); return this; }, /** * Stop listening for inbound messages * on all endpoints listened to prior. * * @public */ unlisten() { debug('unlisten'); this.ports.forEach(port => { port.removeListener(this.onMessage, this.unlisten); }); }, /** * Callback to handle inbound messages. * @param {MessageEvent} e * @private */ onMessage(e) { if (!e.data.id) return; if (!e.data.type) return; if (!this.isRecipient(e.data.recipient)) return; debug('receiver on message', e.data); var message = new Message(e); // Before hook this.emit('message', message); if (message.defaultPrevented) return; try { this.emit(message.type, message); } catch (e) { message.respond(e); throw e; } }, isRecipient(recipient) { return recipient == this.name || recipient == '*' || this.name == '*'; }, destroy: function() { this.unlisten(); delete this.name; return this; } }; // Mixin Emitter methods Emitter(Receiver.prototype); /** * Creates new `Error` from registry. * * @param {Number} id Error Id * @return {Error} * @private */ function error(id, ...args) { return new Error({ 1: '.send() can only be called once', 2: 'response already sent for this message', 3: 'a port must be defined', 4: 'timeout' }[id]); }