package org.swellrt.beta.client.wave;
import java.util.Queue;
import org.swellrt.beta.client.js.Config;
import org.swellrt.beta.client.js.Console;
import org.swellrt.beta.client.js.WebSocket;
import org.swellrt.beta.client.js.WebSocket.Function;
import org.swellrt.beta.client.js.event.CloseEvent;
import org.swellrt.beta.client.js.event.Event;
import org.swellrt.beta.client.js.event.MessageEvent;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.SchedulerInstance;
import org.waveprotocol.wave.model.util.CollectionUtils;
/**
* Adapt the native browser WebSocket to WaveSocket interface.
* <p>
* <br>
* <li>Implements heart beat on the WebSocket to detect network turbulence.
* <li>Implements a transparent reconnection mechanism with message
* reconciliation.
*
* <br>
* <br>
* Reconciliation mechanism is implemented as follows:
* <li>We assume web socket messages preserve order
* <li>Queue each message to be sent in sentMessages
* <li>Increment recvCount for each incoming message.
* <li>On send heart beat message with the value of recvCount (the other side
* will remove oldest recvCount messages from its queue)
* <li>On Receive heart beat response: reset recvCount and remove oldest values
* from sentMessages according to received value. <br>
* On reconnection:
* <li>Send reconnection message with recvCount, and reset recvCount
* <li>On received reconnection message: discard the specified n oldest messages
* from the setMessages queue. Sent rest of the queue.
*
*
* @author pablojan (pablojan@gmail.com)
*
*/
public class WaveSocketWS implements WaveSocket {
private final static String WEBSOCKET_CONTEXT = "socket";
private final static String CONNECTION_TOKEN_PARAM = "ct";
/** The heart beat signal string */
private static final String HEARTBEAT_DATA_PREFIX = "hb:";
private static final String RECONNECTION_DATA_PREFIX = "rc:";
private static final int HEARTBEAT_INTERVAL = Config.websocketHeartbeatInterval();
private static final int HEARTBEAT_TIMEOUT = Config.websocketHeartbeatTimeout();
private static final boolean DEBUG_LOG = Config.websocketDebugLog();
private static void log(String message) {
if (DEBUG_LOG) {
Console.log("[Websocket] " + message);
}
}
private final Scheduler.IncrementalTask heartbeatTask = new Scheduler.IncrementalTask() {
@Override
public boolean execute() {
if (ws.readyState == WebSocket.OPEN) {
ws.send(HEARTBEAT_DATA_PREFIX + recvCount);
heartbeatAck = false;
SchedulerInstance.getLowPriorityTimer().scheduleDelayed(hearbeatCheckTask,
HEARTBEAT_TIMEOUT);
} else {
return false;
}
return true;
}
};
private final Scheduler.Task hearbeatCheckTask = new Scheduler.Task() {
@Override
public void execute() {
if (!heartbeatAck) {
heartbeatTurbulence = true;
log("turbulence detected");
callback.onTurbulence(false);
}
}
};
private final String serverUrl;
/** Callback for the websocket */
private final WaveSocketCallback callback;
private WebSocket ws;
private boolean heartbeatOn = HEARTBEAT_INTERVAL != 0;
private boolean heartbeatAck = true;
private boolean heartbeatTurbulence = false;
private boolean connectedAtLeastOnce = false;
private final Queue<String> sentMessages = CollectionUtils.createQueue();
private int recvCount = 0;
public WaveSocketWS(String serverUrl, String connectionToken, WaveSocketCallback callback) {
if (serverUrl.charAt(serverUrl.length() - 1) != '/')
serverUrl += "/" + WEBSOCKET_CONTEXT;
else
serverUrl += WEBSOCKET_CONTEXT;
if (connectionToken != null) {
serverUrl += "?" + CONNECTION_TOKEN_PARAM + "=" + connectionToken;
}
this.serverUrl = serverUrl;
this.callback = callback;
}
protected void startHeartbeat() {
if (heartbeatOn)
SchedulerInstance.getLowPriorityTimer().scheduleRepeating(heartbeatTask, HEARTBEAT_INTERVAL,
HEARTBEAT_INTERVAL);
}
protected void stopHeartbeat() {
if (heartbeatOn) {
SchedulerInstance.getLowPriorityTimer().cancel(heartbeatTask);
stopHeartbeatCheck();
}
}
protected void stopHeartbeatCheck() {
SchedulerInstance.getLowPriorityTimer().cancel(hearbeatCheckTask);
}
@Override
public void connect() {
try {
ws = new WebSocket(serverUrl);
log("created");
} catch (Exception e) {
log("create exception: " + e.getMessage());
callback.onError("WebSockets not available ");
return;
}
ws.onopen = new WebSocket.Function<Event>() {
@Override
public void exec(Event e) {
// This is a reconnection
if (connectedAtLeastOnce) {
ws.send(RECONNECTION_DATA_PREFIX + recvCount);
log("reconnection: sent ACK for " + recvCount + " messages");
recvCount = 0;
}
connectedAtLeastOnce = true;
startHeartbeat();
log("open");
callback.onConnect();
}
};
ws.onclose = new WebSocket.Function<CloseEvent>() {
@Override
public void exec(CloseEvent e) {
stopHeartbeat();
log("close (" + e.code + "," + e.reason + ")");
callback.onDisconnect();
if (e.code == 1002 || e.code == 1011)
callback.onError(e.reason);
}
};
ws.onerror = new Function<Event>() {
@Override
public void exec(Event e) {
stopHeartbeat();
log("error");
// Only notify fatal errors, e.g. when we can't connect first time
if (!connectedAtLeastOnce)
callback.onError("Error opening WebSocket");
}
};
ws.onmessage = new Function<MessageEvent>() {
@Override
public void exec(MessageEvent e) {
String data = (String) e.data;
if (data != null) {
if (data.startsWith(RECONNECTION_DATA_PREFIX)) {
handleReconnectionMessage(data);
return;
}
if (data.startsWith(HEARTBEAT_DATA_PREFIX)) {
handleHeartbeatMessage(data);
return;
}
recvCount++;
callback.onMessage((String) e.data);
}
}
};
}
@Override
public void disconnect() {
ws.close();
}
@Override
public void sendMessage(String message) {
sentMessages.add(message);
ws.send(message);
}
/**
* @param message
* the message starting with {@link #RECONNECTION_DATA_PREFIX}
*/
protected void handleReconnectionMessage(String message) {
try {
String tmp = message.substring(3);
int n = Integer.parseInt(tmp);
for (int i = 0; i < n; i++)
sentMessages.poll();
int rs = 0;
while (!sentMessages.isEmpty()) {
ws.send(sentMessages.poll());
rs++;
}
log("reconnection: received ACK for " + n + " messages / resent " + rs + " pending messages");
} catch (Exception ex) {
log("Error processing reconnection message: " + ex.getMessage());
}
}
/**
* @param message
* the message starting with {@link #HEARTBEAT_DATA_PREFIX}
*/
protected void handleHeartbeatMessage(String message) {
//
// Heart beat data format is
// hb:<n>
// where n = number of messages ACK'ed by the server
//
// remove oldest n messages in the queue
//
try {
String tmp = message.substring(3);
int n = Integer.parseInt(tmp);
for (int i = 0; i < n; i++)
sentMessages.poll();
} catch (Exception ex) {
log("Error processing heart beat message: " + ex.getMessage());
}
recvCount = 0;
heartbeatAck = true;
stopHeartbeatCheck();
if (heartbeatTurbulence) {
log("turbulence finished");
heartbeatTurbulence = false;
callback.onTurbulence(true);
}
}
}