Binding.js

"use strict";

const State = require("./State");

/**
 * The types of Bindings.
 * @memberof Binding
 * @enum {symbol}
 */
const types = {

	/**
	 * @ignore
	 */
	__proto__: null, // Set prototype to null.

	/**
	 * Only allow bindings to unbound objects; throw otherwise. Afterwards the given object is bound ({@link Binding.isBound} will return true).
	 */
	normal: Symbol("normal"),

	/**
	 * Allow any object or function as binding target. Afterwards the given object will not be changed: Unbound if it was unbound before, bound (with the same {@link Binding}) if it was bound before.
	 */
	clone: Symbol("clone"),

	/**
	 * Allow any object or function as binding target. Afterwards the given object is bound ({@link Binding.isBound} will return true). If the object was bound before, the old binding is overridden.
	 */
	rebind: Symbol("rebind")
};

const bindingMap = new WeakMap();

/**
 * Stores the router and closer functions of bound objects.
 */
class Binding {

	/**
	 * @constructor
	 * @param {object|function} object The object that will be bound.
	 * @param {function} router The router function for this binding.
	 * @param {function} closer The closer function for this binding.
	 * @param {types} [type=Binding.types.normal] The {@linkplain Binding.types type of binding} to be used.
	 * @param {object} clonedObject Only set if type=clone. Object the given object is a clone of.
	 */
	constructor(object, router, closer, type, clonedObject) {
		if(type === undefined)
			type = types.normal;
		else if(type !== types.normal && type !== types.clone && type !== types.rebind)
			throw new Error(`Given type '${type}' is invalid.`);

		if(typeof object !== "object" && typeof object !== "function")
			throw new TypeError(`Only objects and functions can be bound. Got '${object}'.`);

		if(Binding.isBound(object) && type !== types.rebind && type !== types.clone)
			throw new Error(`Object '${object}' is already bound.`);

		if(typeof router !== "function" || typeof closer !== "function")
			throw new TypeError("Bindings require a router and a closer function or another binding to copy.");

		if(typeof router[types.clone] === "function")
			router = router[types.clone];

		const usedRouter = type === types.clone && clonedObject !== undefined ? function() {
			return Promise.resolve(router.apply(this, arguments))
				.then(result => result === object ? clonedObject : result);
		} : router;

		if(usedRouter !== router)
			usedRouter[types.clone] = router;

		Object.assign(this, {

			/**
			 * Stores the router function.
			 * @alias Binding#router
			 * @type {function}
			 */
			router: usedRouter,

			/**
			 * Stores the closer function.
			 * @alias Binding#closer
			 * @type {function}
			 */
			closer,

			/**
			 * The object that is bound by this Binding.
			 * @alias Binding#target
			 * @type {object}
			 */
			target: object,

			/**
			 * The binding type that was used to create this Binding.
			 * @alias Binding#type
			 * @type {Binding.types}
			 */
			type
		});
	}

	/**
	 * Returns whether the given object is bound (it has a Binding associated to it).
	 * @static
	 * @param {any} object The object to check. This can be any value. The method will always return `false` for non-objects.
	 * @return {boolean} `true` if the object is bound. `false` if not.
	 */
	static isBound(object) {
		return object && (typeof object === "object" || typeof object === "function") && bindingMap.has(object) || false;
	}

	/**
	 * Returns the Binding object of the given object if it is bound.
	 * undefined elsewise.
	 * @param {object} object The object that should be checked.
	 * @return {?Binding} The binding of object.
	 */
	static getBinding(object) {
		return object && (typeof object === "object" || typeof object === "function") && bindingMap.get(object) || undefined;
	}

	/**
	 * Binds an unbound object.
	 * @static
	 * @param {?object|function} object The object to be bound. If the given object (strictly) equals null, a new empty object will be used as the binding target.
	 * @param {!function} router The router function to be used for the binding.
	 * @param {!function} closer The closer function to be used fro the binding.
	 * @param {symbol} [type=Binding.types.normal] The type of binding to be used.
	 * @return {object|function} The object that was given. Now bound. If null was given, the newly created empty bound object will be returned.
	 */
	static bind(object, router, closer, type) {
		const target = object === null || type === types.clone ? Object.create(null, {
			object: {
				value: object
			}
		}) : object;

		bindingMap.set(target, new this(object, router, closer, type, target));

		return target;
	}

	/**
	 * Removes the binding of the given object.
	 * @static
	 * @param {any} object The bound object that should be unbound. If object is not bound, nothing happens.
	 * @return {any} Returns the object that was given. Now unbound.
	 */
	static unbind(object) {
		if(this.isBound(object))
			bindingMap.delete(object);

		return object;
	}

}

Binding.types = types;

function traverse(type, typeName) {
	return function(route, origin, data) {
		const state = new State(this.target, route, typeName, origin, this);

		return this[type].call(state, data, state);
	};
}

Object.assign(Binding.prototype,

	/**
	 * @lends Binding#
	 */
	{

		/**
		 * Calls {@link Binding#router} with a {@link State} object as its this-context.
		 * @method
		 * @param {any[]} route The value for {@link State#route}
		 * @param {object} origin The value for {@link State#origin}
		 * @param {any} destination The destination to route to.
		 */
		route: traverse("router", "route"),

		/**
		 * Calls {@link Binding#closer} with a {@link State} object as its this-context.
		 * @method
		 * @param {any[]} route The value for {@link State#route}
		 * @param {object} origin The value for {@link State#origin}
		 * @param {any} destination The data to close with.
		 */
		close: traverse("closer", "close")
	});

State.setBinding(Binding);

module.exports = Binding;