/*
* Copyright (c) 2017 PonySDK
* Owners:
* Luciano Broussal <luciano.broussal AT gmail.com>
* Mathieu Barbier <mathieu.barbier AT gmail.com>
* Nicolas Ciaravola <nicolas.ciaravola.pro AT gmail.com>
*
* WebSite:
* http://code.google.com/p/pony-sdk/
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.ponysdk.core.server.servlet;
import java.io.StringReader;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.servlet.http.HttpSession;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.StatusCode;
import org.eclipse.jetty.websocket.api.WebSocketListener;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ponysdk.core.model.ClientToServerModel;
import com.ponysdk.core.model.ServerToClientModel;
import com.ponysdk.core.server.application.AbstractApplicationManager;
import com.ponysdk.core.server.application.Application;
import com.ponysdk.core.server.application.UIContext;
import com.ponysdk.core.server.stm.TxnContext;
import com.ponysdk.core.useragent.UserAgent;
public class WebSocket implements WebSocketListener, WebsocketEncoder {
private static final Logger log = LoggerFactory.getLogger(WebSocket.class);
private final ServletUpgradeRequest request;
private final WebsocketMonitor monitor;
private WebSocketPusher websocketPusher;
private final AbstractApplicationManager applicationManager;
private TxnContext context;
private Session session;
WebSocket(final ServletUpgradeRequest request, final WebsocketMonitor monitor,
final AbstractApplicationManager applicationManager) {
this.request = request;
this.monitor = monitor;
this.applicationManager = applicationManager;
}
@Override
public void onWebSocketConnect(final Session session) {
final HttpSession httpSession = request.getSession();
if (log.isInfoEnabled()) log.info("WebSocket connected from {}, sessionID {}, userAgent {}", session.getRemoteAddress(),
httpSession, request.getHeader("User-Agent"));
this.session = session;
// 1K for max chunk size and 1M for total buffer size
// Don't set max chunk size > 8K because when using Jetty Websocket compression, the chunks are limited to 8K
this.websocketPusher = new WebSocketPusher(session, 1 << 20, 1 << 12, TimeUnit.SECONDS.toMillis(60));
this.context = new TxnContext(this);
final String applicationId = httpSession.getId();
Application application = SessionManager.get().getApplication(applicationId);
if (application == null) {
application = new Application(applicationId, httpSession, applicationManager.getOptions(),
UserAgent.parseUserAgentString(request.getHeader("User-Agent")));
SessionManager.get().registerApplication(application);
}
context.setApplication(application);
if (log.isInfoEnabled()) log.info("Creating a new application, {}", application.toString());
try {
final UIContext uiContext = new UIContext(context);
context.setUIContext(uiContext);
application.registerUIContext(uiContext);
uiContext.begin();
try {
beginObject();
encode(ServerToClientModel.CREATE_CONTEXT, uiContext.getID());
endObject();
flush();
} catch (final Throwable e) {
log.error("Cannot send server heart beat to client", e);
} finally {
uiContext.end();
}
applicationManager.startApplication(context);
} catch (final Exception e) {
log.error("Cannot process WebSocket instructions", e);
}
}
@Override
public void onWebSocketError(final Throwable throwable) {
log.error("WebSocket Error", throwable);
}
@Override
public void onWebSocketClose(final int statusCode, final String reason) {
log.info("WebSocket closed {}, reason : {}", NiceStatusCode.getMessage(statusCode), reason != null ? reason : "");
if (isLiving()) context.getUIContext().onDestroy();
}
/**
* Receive from the terminal
*/
@Override
public void onWebSocketText(final String text) {
final UIContext uiContext = context.getUIContext();
if (isLiving()) {
if (monitor != null) monitor.onMessageReceived(WebSocket.this, text);
try {
uiContext.notifyMessageReceived();
if (ClientToServerModel.HEARTBEAT.toStringValue().equals(text)) {
if (log.isDebugEnabled()) log.debug("Heartbeat received from terminal #" + uiContext.getID());
} else {
final JsonObject jsonObject = Json.createReader(new StringReader(text)).readObject();
if (jsonObject.containsKey(ClientToServerModel.PING_SERVER.toStringValue())) {
final long start = jsonObject.getJsonNumber(ClientToServerModel.PING_SERVER.toStringValue()).longValue();
final long end = System.currentTimeMillis();
if (log.isDebugEnabled())
log.debug("Ping measurement : " + (end - start) + " ms from terminal #" + uiContext.getID());
uiContext.addPingValue(end - start);
} else if (jsonObject.containsKey(ClientToServerModel.APPLICATION_INSTRUCTIONS.toStringValue())) {
final Application applicationSession = context.getApplication();
if (applicationSession == null)
throw new Exception("Invalid session, please reload your application (" + uiContext + ").");
final String applicationInstructions = ClientToServerModel.APPLICATION_INSTRUCTIONS.toStringValue();
uiContext.execute(() -> {
final JsonArray appInstructions = jsonObject.getJsonArray(applicationInstructions);
for (int i = 0; i < appInstructions.size(); i++) {
uiContext.fireClientData(appInstructions.getJsonObject(i));
}
});
} else if (jsonObject.containsKey(ClientToServerModel.INFO_MSG.toStringValue())) {
log.info("Message from terminal #" + uiContext.getID() + " : "
+ jsonObject.getJsonString(ClientToServerModel.INFO_MSG.toStringValue()));
} else if (jsonObject.containsKey(ClientToServerModel.ERROR_MSG.toStringValue())) {
log.error("Message from terminal #" + uiContext.getID() + " : "
+ jsonObject.getJsonString(ClientToServerModel.ERROR_MSG.toStringValue()));
} else {
log.error("Unknow message from terminal #" + uiContext.getID() + " : " + text);
}
}
} catch (final Throwable e) {
log.error("Cannot process message from terminal #" + uiContext.getID() + " : {}", text, e);
} finally {
if (monitor != null) monitor.onMessageProcessed(WebSocket.this);
}
} else {
if (log.isInfoEnabled()) log.info("UI Context is destroyed, message dropped from terminal : " + text);
}
}
/**
* Receive from the terminal
*/
@Override
public void onWebSocketBinary(final byte[] payload, final int offset, final int len) {
// Can't receive binary data from terminal (GWT limitation)
}
public String getHistoryToken() {
final List<String> historyTokens = this.request.getParameterMap().get(ClientToServerModel.TYPE_HISTORY.toStringValue());
return !historyTokens.isEmpty() ? historyTokens.get(0) : null;
}
/**
* Send heart beat to the client
*/
public void sendHeartBeat() {
if (isLiving() && isSessionOpen()) {
beginObject();
encode(ServerToClientModel.HEARTBEAT, null);
endObject();
flush();
}
}
/**
* Send round trip to the client
*/
public void sendRoundTrip() {
if (isLiving() && isSessionOpen()) {
beginObject();
encode(ServerToClientModel.PING_SERVER, System.currentTimeMillis());
endObject();
flush();
}
}
@Override
public void flush() {
websocketPusher.flush();
}
public void close() {
if (isSessionOpen()) {
log.info("Closing websocket programaticly");
session.close();
}
}
private boolean isLiving() {
return context != null && context.getUIContext() != null && context.getUIContext().isLiving();
}
private boolean isSessionOpen() {
return session != null && session.isOpen();
}
@Override
public void beginObject() {
}
@Override
public void endObject() {
encode(ServerToClientModel.END, null);
}
@Override
public void encode(final ServerToClientModel model, final Object value) {
websocketPusher.encode(model, value);
}
private static enum NiceStatusCode {
NORMAL(StatusCode.NORMAL, "Normal closure"),
SHUTDOWN(StatusCode.SHUTDOWN, "Shutdown"),
PROTOCOL(StatusCode.PROTOCOL, "Protocol error"),
BAD_DATA(StatusCode.BAD_DATA, "Received bad data"),
UNDEFINED(StatusCode.UNDEFINED, "Undefined"),
NO_CODE(StatusCode.NO_CODE, "No code present"),
NO_CLOSE(StatusCode.NO_CLOSE, "Abnormal connection closed"),
ABNORMAL(StatusCode.ABNORMAL, "Abnormal connection closed"),
BAD_PAYLOAD(StatusCode.BAD_PAYLOAD, "Not consistent message"),
POLICY_VIOLATION(StatusCode.POLICY_VIOLATION, "Received message violates policy"),
MESSAGE_TOO_LARGE(StatusCode.MESSAGE_TOO_LARGE, "Message too big"),
REQUIRED_EXTENSION(StatusCode.REQUIRED_EXTENSION, "Required extension not sent"),
SERVER_ERROR(StatusCode.SERVER_ERROR, "Server error"),
SERVICE_RESTART(StatusCode.SERVICE_RESTART, "Server restart"),
TRY_AGAIN_LATER(StatusCode.TRY_AGAIN_LATER, "Server overload"),
FAILED_TLS_HANDSHAKE(StatusCode.POLICY_VIOLATION, "Failure handshake");
private int statusCode;
private String message;
private NiceStatusCode(final int statusCode, final String message) {
this.statusCode = statusCode;
this.message = message;
}
public static final String getMessage(final int statusCode) {
final List<NiceStatusCode> codes = Arrays.stream(values())
.filter(niceStatusCode -> niceStatusCode.statusCode == statusCode).collect(Collectors.toList());
if (!codes.isEmpty()) {
return codes.get(0).toString();
} else {
log.error("No matching status code found for {}", statusCode);
return String.valueOf(statusCode);
}
}
@Override
public String toString() {
return message + " (" + statusCode + ")";
}
}
}