File:Bellhop.js

(function(window, undefined)
{
	// Include event dispatcher
	var BellhopEventDispatcher = window.BellhopEventDispatcher;

	/**
	 *  Abstract the communication layer between the iframe
	 *  and the parent DOM
	 *  @class Bellhop
	 *  @extends BellhopEventDispatcher
	 */
	var Bellhop = function()
	{
		BellhopEventDispatcher.call(this);

		/**
		 *  Bound handler for the window message event
		 *  @property {Function} onReceive
		 *  @private
		 */
		this.onReceive = this.receive.bind(this);

		/**
		 *  If we are connected to another instance of the bellhop
		 *  @property {Boolean} connected
		 *  @readOnly
		 *  @default false
		 *  @private
		 */
		this.connected = false;

		/**
		 *  The name of this Bellhop instance, useful for debugging purposes
		 *  @param {String} name
		 */
		this.name = '';

		/**
		 *  If this instance represents an iframe instance
		 *  @property {Boolean} isChild
		 *  @private
		 *  @default true
		 */
		this.isChild = true;

		/**
		 *  If we are current trying to connec
		 *  @property {Boolean} connecting
		 *  @default false
		 *  @private
		 */
		this.connecting = false;

		/**
		 *  If using cross-domain, the domain to post to
		 *  @property {Boolean} origin
		 *  @private
		 *  @default "*"
		 */
		this.origin = "*";

		/**
		 *  Save any sends to wait until after we're done
		 *  @property {Array} _sendLater
		 *  @private
		 */
		this._sendLater = [];

		/**
		 *  Do we have something to connect to, should be called after
		 *  attempting to `connect()`
		 *  @property {Boolean} supported
		 *  @readOnly
		 */
		this.supported = null;

		/**
		 * The iframe element
		 * @property {DOMElement} iframe
		 * @private
		 * @readOnly
		 */
		this.iframe = null;
	};

	// Reference to the prototype
	var s = BellhopEventDispatcher.prototype;
	var p = Bellhop.prototype = Object.create(s);

	/**
	 *  The connection has been established successfully
	 *  @event connected
	 */

	/**
	 *  Connection could not be established
	 *  @event failed
	 */

	/**
	 *  Handle messages in the window
	 *  @method receive
	 *  @private
	 */
	p.receive = function(event)
	{
		// Ignore events that don't originate from the target
		// we're connected to
		if (event.source !== this.target)
		{
			return;
		}

		var data = event.data;

		// This is the initial connection event
		if (data === 'connected')
		{
			this.connecting = false;
			this.connected = true;

			this.trigger('connected');

			// Be polite and respond to the child that we're ready
			if (!this.isChild)
			{
				this.target.postMessage(data, this.origin);
			}

			var i, len = this._sendLater.length;

			// If we have any sends waiting to send
			// we are now connected and it should be okay 
			if (len > 0)
			{
				for (i = 0; i < len; i++)
				{
					var e = this._sendLater[i];
					this.send(e.type, e.data);
				}
				this._sendLater.length = 0;
			}
		}
		else
		{
			// Ignore all other event if we don't have a context
			if (!this.connected) return;

			try
			{
				data = JSON.parse(data, Bellhop.reviver);
			}
			catch (err)
			{
				// If we can't parse the JSON
				// just ignore it, this should
				// only be an object
				return;
			}

			// Only valid objects with a type and matching channel id
			if (typeof data === "object" && data.type)
			{
				this.trigger(data);
			}
		}
	};

	/**
	 *  And override for the toString built-in method
	 *  @method toString
	 *  @return {String} Representation of this instance
	 */
	p.toString = function()
	{
		return "[Bellhop '" + this.name + "']";
	};

	/**
	 *  The target where to send messages
	 *  @property {DOM} target
	 *  @private
	 *  @readOnly
	 */
	Object.defineProperty(p, "target",
	{
		get: function()
		{
			return this.isChild ? window.parent : this.iframe.contentWindow;
		}
	});

	/**
	 *  Setup the connection
	 *  @method connect
	 *  @param {DOM} [iframe] The iframe to communicate with. If no value is set, the assumption
	 *         is that we're the child trying to communcate with our window.parent
	 *  @param {String} [origin="*"] The domain to communicate with if different from the current.
	 *  @return {Bellhop} Return instance of current object
	 */
	p.connect = function(iframe, origin)
	{
		// Ignore if we're already trying to connect
		if (this.connecting) return this;

		// Disconnect from any existing connection
		this.disconnect();

		// We are trying to connect
		this.connecting = true;

		//re-init if we had previously been destroyed
		if (!this._sendLater) this._sendLater = [];

		// The iframe if we're the parent
		this.iframe = iframe || null;

		// The instance of bellhop is inside the iframe
		var isChild = this.isChild = (iframe === undefined);
		var target = this.target;
		this.supported = isChild ? !!target && window != target : !!target;
		this.origin = origin === undefined ? "*" : origin;

		// Listen for incoming messages
		if (window.attachEvent)
		{
			window.attachEvent("onmessage", this.onReceive);
		}
		else
		{
			window.addEventListener("message", this.onReceive);
		}

		if (isChild)
		{
			// No parent, can't connect
			if (window === target)
			{
				this.trigger('failed');
			}
			else
			{
				// If connect is called after the window is ready
				// we can go ahead and send the connect message
				if (window.document.readyState === "complete")
				{
					target.postMessage('connected', this.origin);
				}
				else
				{
					// Or wait until the window is finished loading
					// then send the handshake to the parent
					window.onload = function()
					{
						target.postMessage('connected', this.origin);
					}.bind(this);
				}
			}
		}
		return this;
	};

	/**
	 *  Disconnect if there are any open connections
	 *  @method disconnect
	 */
	p.disconnect = function()
	{
		this.connected = false;
		this.connecting = false;
		this.origin = null;
		this.iframe = null;
		if (this._sendLater) this._sendLater.length = 0;
		this.isChild = true;

		if (window.detachEvent)
		{
			window.detachEvent("onmessage", this.onReceive);
		}
		else
		{
			window.removeEventListener("message", this.onReceive);
		}

		return this;
	};

	/**
	 *  Send an event to the connected instance
	 *  @method send
	 *  @param {String} event The event type to send to the parent
	 *  @param {Object} [data] Additional data to send along with event
	 *  @return {Bellhop} Return instance of current object
	 */
	p.send = function(event, data)
	{
		if (typeof event !== "string")
		{
			throw "The event type must be a string";
		}
		event = {
			type: event
		};

		// Add the additional data, if needed
		if (data !== undefined)
		{
			event.data = data;
		}
		if (this.connecting)
		{
			this._sendLater.push(event);
		}
		else if (!this.connected)
		{
			return this;
		}
		else
		{
			this.target.postMessage(JSON.stringify(event), this.origin);
		}
		return this;
	};

	/**
	 *  A convenience method for sending and the listening to create 
	 *  a singular link to fetching data. This is the same calling send
	 *  and then getting a response right away with the same event.
	 *  @method fetch
	 *  @param {String} event The name of the event
	 *  @param {Function} callback The callback to call after, takes event object as one argument
	 *  @param {Object} [data] Optional data to pass along
	 *  @param {Boolean} [runOnce=false] If we only want to fetch once and then remove the listener
	 *  @return {Bellhop} Return instance of current object
	 */
	p.fetch = function(event, callback, data, runOnce)
	{
		var self = this;

		if (!this.connecting && !this.connected)
		{
			throw "No connection, please call connect() first";
		}

		runOnce = runOnce === undefined ? false : runOnce;
		var internalCallback = function(e)
		{
			if (runOnce) self.off(e.type, internalCallback);
			callback(e);
		};
		this.on(event, internalCallback);
		this.send(event, data);
		return this;
	};

	/**
	 *  A convience method for listening to an event and then responding with some data
	 *  right away. Automatically removes the listener
	 *  @method respond
	 *  @param {String} event The name of the event
	 *  @param {Object} data The object to pass back. 
	 *  	May also be a function; the return value will be sent as data in this case.
	 *  @param {Boolean} [runOnce=false] If we only want to respond once and then remove the listener
	 *  @return {Bellhop} Return instance of current object
	 */
	p.respond = function(event, data, runOnce)
	{
		runOnce = runOnce === undefined ? false : runOnce;
		var self = this;
		var internalCallback = function(e)
		{
			if (runOnce) self.off(e.type, internalCallback);
			self.send(event, typeof data == "function" ? data() : data);
		};
		this.on(event, internalCallback);
		return this;
	};

	/**
	 *  Destroy and don't user after this
	 *  @method destroy
	 */
	p.destroy = function()
	{
		s.destroy.call(this);
		this.disconnect();
		this._sendLater = null;
	};

	/**
	 * When restoring from JSON via `JSON.parse`, we may pass a reviver function.
	 * In our case, this will check if the object has a specially-named property (`__classname`).
	 * If it does, we will attempt to construct a new instance of that class, rather than using a
	 * plain old Object. Note that this recurses through the object.
	 * See <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse">JSON.parse()</a>
	 * @method  reviver
	 * @static
	 * @param  {String} key   each key name
	 * @param  {Object} value Object that we wish to restore
	 * @return {Object}       The object that was parsed - either cast to a class, or not
	 */
	Bellhop.reviver = function(key, value)
	{
		if (value && typeof value.__classname == "string")
		{
			var _class = include(value.__classname);
			if (_class)
			{
				var rtn = new _class();
				//if we may call fromJSON, do so
				if (rtn.fromJSON)
				{
					rtn.fromJSON(value);
					//return the cast Object
					return rtn;
				}
			}
		}
		//return the object we were passed in
		return value;
	};

	/**
	 * Simple return function
	 * @method include
	 * @private
	 * @param {string} classname Qualified class name as a string.
	 *        for example "cloudkid.MyClass" would return a reference
	 *        to the function window.cloudkid.MyClass.
	 */
	var include = function(classname)
	{
		var parts = classname.split('.');
		var parent = window;
		while (parts.length)
		{
			parent = parent[parts.shift()];
			if (!parent) return;
		}
		return parent;
	};

	// Assign to the global namespace
	window.Bellhop = Bellhop;

}(window));