Source: impl/client-endpoint.js

//< needs(endpoint)

/**
 * Instantiates a new Trap Client.
 * 
 * @classdesc A Trap.ClientEndpoint is a Trap.Endpoint capable of opening an
 *            outgoing connection, commonly to a TrapListener. It is able to
 *            reconnect transports at will, unlike a ServerTrapEndpoint. This is
 *            the main entry point for JavaScript. Create a Trap.ClientEndpoint
 *            like so
 *            <p>
 * 
 * <pre>
 * client = new Trap.ClientEndpoint("http://trapserver.com:8888");
 * client.onopen = function() {
 * };
 * client.onmessage = function(evt) {
 * };
 * </pre>
 * 
 * <p>
 * The client will open automatically once instantiated. Assign at least the
 * <b>onopen</b> and <b>onmessage</b> callbacks (and preferably <b>onerror</b>)
 * in order to get a working client.
 * @constructor
 * @param {Boolean} configuration.useBinary Enables (default) or disables binary
 *            support.
 * @param {Boolean} configuration.autoConfigure Enables (default) or disables
 *            automatic configuration
 * @param {String} configuration.remote A URI or TrapConfiguration to the remote
 *            host.
 * 
 * @param {String} configuration The alternate means to instantiate a
 *            ClientEndpoint is to supply it with a single string constituting a
 *            complete configuration.
 * @extends Trap.Endpoint
 */
Trap.ClientEndpoint = function(configuration, autoConfigure) {

	if (typeof (configuration) == "object") {
		this.useBinary = typeof (configuration.useBinary) == "boolean" ? configuration.useBinary
				: true;

		this.autoConfigure = (typeof (configuration.autoConfigure) == "boolean" ? configuration.autoConfigure
				: true);

		configuration = configuration.remote;
	} else {
		this.autoConfigure = (typeof (autoConfigure) == "boolean" ? autoConfigure
				: true);
		this.useBinary = typeof (Trap.useBinary) != "undefined" ? Trap.useBinary
				: Trap.supportsBinary;
	}

	if (this.useBinary && !Trap.supportsBinary)
		throw "Cannot enable binary mode; it is unsupported on this client. Either no transport supported it, or Uint8Array is not present.";

	this.cTransport;

	/**
	 * The list of transports that have not been tried before or after all
	 * transports failed and being tried again.
	 */
	this.transportsToConnect = new Trap.Set();

	/**
	 * The transports that have failed in some non-fatal way. There will be an
	 * attempt taken in the future to recover them.
	 */
	this.failedTransports = new Trap.List();

	this.activeTransports = new Trap.List();

	this.recovering = false;

	Trap.Endpoint.prototype.constructor.call(this);
	this._maxActiveTransports = 1;

	this.trapID = Trap.Constants.ENDPOINT_ID_CLIENT; // Allow the server to
														// override our trap ID.

	// Load the appropriate transports
	for ( var tName in Trap.Transports) {
		var t = Trap.Transports[tName];

		if (t.prototype
				&& typeof (t.prototype.setTransportPriority) == "function") // is a
																			// transport
		{
			var transport = new t();
			this.logger.trace("Initialising new Transport for client: {}",
					transport.getTransportName());

			if (!transport.canConnect()) {
				this.logger.trace("Skipping it; it cannot connect");
				continue;
			}

			if (this.useBinary && !transport.supportsBinary) {
				this.logger
						.info("Skipping it; Trap Binary Mode requested, but transport only supports text");
				continue;
			}

			transport.useBinary = this.useBinary;

			// Unlike Java, TrapEndpoint only defines one addTransport, and that
			// one sets
			// this object as delegate. Thus, we're done.
			this.addTransport(transport);
		}
	}

	if (this.transports.size() == 0)
		throw "No transports could be initialised; either no transports could connect, or transports did not support binary mode (if requested)";

	if ((configuration != null) && (configuration.trim().length > 0)) {

		// Check if we need to redo the configuration.
		if (configuration.startsWith("ws://")
				|| configuration.startsWith("wss://")) {
			// WebSocket Transport
			configuration = "trap.transport.websocket.wsuri = " + configuration;
		} else if (configuration.startsWith("socket://")) {
			// TODO: Socket URI
		} else if (configuration.startsWith("http://")
				|| configuration.startsWith("https://")) {
			configuration = "trap.transport.http.url = " + configuration;
		} else if (!configuration.startsWith("trap"))
			throw "Unknown configuration; invalid format or garbage characters entered";

	}

	this.configure(configuration);

	this.transportRecoveryTimeout = 15 * 60 * 1000;

	var mt = this;
	setTimeout(function() {

		mt.logger.trace("##### CLIENT OPEN ####");
		mt.logger.trace("Config is: {}", mt.config.toString());
		for ( var i = 0; i < mt.transports.size(); i++) {
			var t = mt.transports.get(i);
			mt.logger.trace("Transport [{}] is enabled: {}", t
					.getTransportName(), t.isEnabled());
		}

		mt.setState(Trap.Endpoint.State.OPENING);
		mt.doOpen();

		// Also start recovery
		var recoveryFun = function() {

			mt.failedTransports.clear();

			for ( var i = 0; i < mt.transports.size(); i++) {
				if (mt.getState() == Trap.Endpoint.State.CLOSING
						|| mt.getState() == Trap.Endpoint.State.CLOSED
						|| mt.getState() == Trap.Endpoint.State.CLOSED)
					return;

				var t = mt.transports.get(i);

				// Check if t is active
				var active = false;
				for ( var j = 0; j < mt.activeTransports.size(); j++) {
					if (mt.activeTransports.get(j) == t) {
						active = true;
						break;
					}
				}

				if (!active)
					mt.transportsToConnect.add(t);
			}

			if (!mt.recovering)
				mt.kickRecoveryThread();

			setTimeout(recoveryFun, mt.transportRecoveryTimeout);
		}

		setTimeout(recoveryFun, mt.transportRecoveryTimeout);

	}, 0);
};

Trap.ClientEndpoint.prototype = new Trap.Endpoint;
Trap.ClientEndpoint.prototype.constructor = Trap.ClientEndpoint;

Trap.ClientEndpoint.prototype.parseConfiguration = function(configuration) {
	return new Trap.CustomConfiguration(configuration);
};

Trap.ClientEndpoint.prototype.open = function() {

};

//> (void)fn()
Trap.ClientEndpoint.prototype.doOpen = function() {
	// If the list of transports that still can be used and is empty -> die!
	if (this.transports.size() == 0) {
		this.setState(Trap.Endpoint.State.ERROR);
		throw "No transports available";
	}
	// Clean all the failed transports so far, we'll retry all of them anyway.
	this.failedTransports.clear();
	this.activeTransports.clear();
	this.availableTransports.clear();
	this.transportsToConnect.clear();

	// Let transportsToConnect be the list of transports that we haven't tried.
	this.transportsToConnect.addAll(this.transports);

	// Pick the first untested transport (the one with the highest priority)
	this.kickRecoveryThread();
};

// One of our transports has changed the state, let's see what happened...
//> (void) fn(Trap.Endpoint.State, Trap.Endpoint.State, Trap.Transport)
Trap.ClientEndpoint.prototype.ttStateChanged = function(newState, oldState,
		transport) {

	this.logger.debug("Transport {} changed state to {}", transport
			.getTransportName(), newState);
	// Super will manage available transports. All we need to consider is what
	// action to take.
	Trap.Endpoint.prototype.ttStateChanged.call(this, newState, oldState,
			transport);

	if (this.getState() == Trap.Endpoint.State.CLOSED
			|| this.getState() == Trap.Endpoint.State.CLOSING
			|| this.getState() == Trap.Endpoint.State.ERROR)
		return;

	// This is fine. We're not interested in disconnecting transports; super has
	// already managed this for us.
	if (oldState == Trap.Transport.State.DISCONNECTING) {
		this.activeTransports.remove(transport);
		this.availableTransports.remove(transport);
		return;
	}

	// What to do if we lose a transport
	if (newState == Trap.Transport.State.DISCONNECTED
			|| newState == Trap.Transport.State.ERROR) {

		this.activeTransports.remove(transport);

		// This was an already connected transport. If we have other transports
		// available, we should silently try and reconnect it in the background
		if (oldState == Trap.Transport.State.AVAILABLE
				|| oldState == Trap.Transport.State.UNAVAILABLE
				|| oldState == Trap.Transport.State.CONNECTED) {

			if (this.activeTransports.size() != 0) {
				this.transportsToConnect.add(transport);
				this.kickRecoveryThread();
				return;
			}

			if (this.getState() == Trap.Endpoint.State.OPENING) {
				// The current transport failed. Just drop it in the failed
				// transports pile.
				// (Failed transports are cycled in at regular intervals)
				this.failedTransports.add(transport);

				// Also notify recovery that we have lost a transport. This may
				// schedule another to be reconnected.
				this.kickRecoveryThread();
				return;
			} else {

				var openTimeout = 1000;

				if (this.getState() == Trap.Endpoint.State.OPEN) {
					// We have to report that we've lost all our transports.
					this.setState(Trap.Endpoint.State.SLEEPING);

					// Adjust reconnect timeout
					this.canReconnectUntil = new Date().valueOf()
							+ this.reconnectTimeout;

					// This is the first time, just reconnect immediately
					openTimeout = 0;
				}

				if (this.getState() != Trap.Endpoint.State.SLEEPING) {
					// We have nothing to do here
					return;
				}

				var mt = this;

				if (new Date().valueOf() < this.canReconnectUntil) {
					setTimeout(
							function() {

								try {
									mt.doOpen();
								} catch (e) {
									mt.logger
											.error(
													"Error while reconnecting after all transports failed",
													e);
									return;
								}

							}, openTimeout);

				}

			}
		} else if (oldState == Trap.Transport.State.CONNECTING) {
			this.cycleTransport(transport, "connectivity failure");
		} else {
			// disconnecting, so do nothing
		}
	}

	if (newState == Trap.Transport.State.CONNECTED) {
		if (oldState == Trap.Transport.State.CONNECTING) {
			this.sendOpen(transport);
		} else {
			this.logger
					.error("Reached Trap.Transport.State.CONNECTED from a non-CONNECTING state. We don't believe in this.");
		}
	}
};

Trap.ClientEndpoint.prototype.ttMessageReceived = function(message, transport) {
	if (transport == this.cTransport) {
		if (message.getOp() == Trap.Message.Operation.OPENED) {
			this.cTransport = null;
			// received configuration from the server
			if (this.autoConfigure && message.getData().length > 0)
				this.config.setStaticConfiguration(message.getData());
		} else {
			this.cycleTransport(transport, "illegal open reply message op");
			return;
		}
	}
	Trap.Endpoint.prototype.ttMessageReceived.call(this, message, transport);
};

Trap.ClientEndpoint.prototype.sendOpen = function(transport) {
	var m = this.createMessage().setOp(Trap.Message.Operation.OPEN);
	var body = new Trap.Configuration();
	if (this.autoConfigure) {
		try {
			var str = this.getConfiguration().toString();
			var hashed = Trap.MD5(str); // Represented as hex string
			body.setOption(Trap.Configuration.CONFIG_HASH_PROPERTY, hashed);
		} catch (e) {
			this.logger.warn("Could not compute client configuration hash", e);
		}
		;
	}

	if (this.connectionToken == null)
		this.connectionToken = Trap._uuid();

	body.setOption("trap.connection-token", this.connectionToken);
	body.setOption(Trap.Constants.OPTION_MAX_CHUNK_SIZE, "" + (16 * 1024));
	body.setOption(Trap.Constants.OPTION_ENABLE_COMPRESSION, "true"); // Forcibly
																		// enable
																		// compression
																		// for
																		// the
																		// time
																		// being

	if (!!this.config.getOption(Trap.Constants.OPTION_AUTO_HOSTNAME))
		body.setOption(Trap.Constants.OPTION_AUTO_HOSTNAME, this.config
				.getOption(Trap.Constants.OPTION_AUTO_HOSTNAME));

	m.setData(body.toString());

	try {
		transport.send(m, false);
	} catch (e) {
		this.cycleTransport(transport, "open message send failure");
	}
};

Trap.ClientEndpoint.prototype.cycleTransport = function(transport, reason) {
	this.logger.debug("Cycling transports due to {} {}...", transport
			.getTransportName(), reason);
	transport.onmessage = function() {
	};
	transport.onstatechange = function() {
	};
	transport.onfailedsending = function() {
	};
	transport.onmessagesent = function() {
	};
	transport.disconnect();

	this.activeTransports.remove(transport);
	this.failedTransports.add(transport);

	// Recover only if we have active transports. Otherwise do open...
	if (this.transportsToConnect.size() == 0) {

		if (this.activeTransports.size() > 0) {
			// Let recovery take care of reopening.
			return;
		}

		if (this.getState() == Trap.Endpoint.State.OPENING) {
			this.logger
					.error("Could not open a connection on any transport...");
			this.setState(Trap.Endpoint.State.ERROR);
			return;
		}

		var mt = this;

		// Don't recycle!
		if (this._cycling)
			return;

		this._cycling = setTimeout(function() {
			try {
				this._cycling = null;
				mt.doOpen();
			} catch (e) {
				mt.logger.warn(e);
			}
		}, 1000);
	} else
		this.kickRecoveryThread();
};

Trap.ClientEndpoint.prototype.kickRecoveryThread = function() {
	if (this.recovering)
		return;

	var mt = this;

	this.recovering = setTimeout(
			function() {

				// Don't reconnect transports if the endpoint doesn't want them
				// to be.
				if (mt.state == Trap.Endpoint.State.CLOSED
						|| mt.state == Trap.Endpoint.State.CLOSING
						|| mt.state == Trap.Endpoint.State.ERROR)
					return;

				try {
					for (;;) {

						// Sort the connecting transports
						// This ensures we always get the first transport

						mt.transportsToConnect.sort(function(o1, o2) {
							return o1.getTransportPriority()
									- o2.getTransportPriority();
						});

						var first = null;
						if (mt.transportsToConnect.size() > 0) {
							try {

								first = mt.transportsToConnect.remove(0);

								if (first != null) {

									var t = first;

									mt.logger
											.trace(
													"Now trying to connect transport {}",
													t.getTransportName());

									if (!t.canConnect()) {
										mt.logger
												.trace("Skipping: Transport cannot connect");
										continue;
									}

									if (!t.isEnabled()) {
										mt.logger
												.trace("Skipping: Transport is disabled");
										continue;
									}

									// Abort connection attempts if head
									// transport is downprioritised.
									var downPrioritised = false;
									for ( var i = 0; i < mt.availableTransports
											.size(); i++)
										if (mt.availableTransports.get(i)
												.getTransportPriority() < t
												.getTransportPriority())
											downPrioritised = true;

									if (downPrioritised) {
										mt.transportsToConnect.add(0, t);
										mt.logger
												.trace("Skipping: Transport is downprioritised (we'll try a higher prio transport first)");
										break;
									} // */

									t.init(); // Hook the delegate methods

									t.onmessage = function(e) {
										mt.ttMessageReceived(e.message,
												e.target, null);
									};
									t.onstatechange = function(e) {
										mt.ttStateChanged(e.newState,
												e.oldState, e.target, null);
									};
									t.onfailedsending = function(e) {
										mt.ttMessagesFailedSending(e.messages,
												e.target, null);
									};
									t.onmessagesent = function(e) {
										mt.ttMessageSent(e.message, e.target,
												null);
									};

									t.setAuthentication(mt.authentication);
									t.setConfiguration(mt.config);
									t.setFormat(mt.getFormat());

									mt.activeTransports.add(t);
									t.connect();

								}
							} catch (e) {
								if (!!first) {
									mt.failedTransports.add(first);
									mt.activeTransports.remove(first);
								}
							}

						}

						if (mt.transportsToConnect.size() == 0 || first == null) {
							mt.recovering = null;
							return;
						}
					}
				} catch (t) {
					mt.logger.warn(t);
					mt.recovering = null;
				}
				mt.recovering = null;
			}, 0);
};

Trap.ClientEndpoint.prototype.reconnect = function(timeout) {
	// On the client, we'll use the transports list in order to reconnect, so we
	// have to just d/c and clear available transports.
	for ( var i = 0; i < this.transports.size(); i++)
		this.transports.get(i).disconnect();

	// After having jettisonned all transports, create new data structs for them
	this.availableTransports = new Trap.List();

	// Restart connection attempts
	this.doOpen();

	// Set a timeout reconnect
	var mt = this;
	if (timeout > 0)
		this.reconnectFunTimer = setTimeout(function() {

			if (mt.getState() != Trap.Endpoint.State.OPEN)
				mt.setState(Trap.Endpoint.State.CLOSED);

			mt.reconnectFunTimer = null;

		}, timeout);
};

Trap.ClientEndpoint.prototype.onOpened = function(message, transport) {

	var rv = Trap.Endpoint.prototype.onOpened.call(this, message, transport);

	if (this.trapID != Trap.Constants.ENDPOINT_ID_CLIENT && this.autoConfigure) {
		if (!!message.string && message.string.length > 0) {
			// Message data should contain new configuration
			this.logger.debug("Received new configuration from server...");
			this.logger.debug("Configuration was [{}]", message.string);
			this.configure(message.string);

			// Any transport that is currently non-active should be scheduled to
			// connect
			// This includes transports that weren't connected in the first
			// place (transport priorities may have changed)
			this.failedTransports.clear();

			for ( var i = 0; i < this.transports.size(); i++) {
				var t = this.transports.get(i);

				// Check if t is active
				var active = false;
				for ( var j = 0; j < this.activeTransports.size(); j++) {
					if (this.activeTransports.get(j) == t) {
						active = true;
						break;
					}
				}

				if (!active)
					this.transportsToConnect.add(t);
			}

			// Now make them connect
			this.kickRecoveryThread();

		}
	}

	return rv;

};