├── src ├── main │ └── java │ │ └── com │ │ └── cgbystrom │ │ └── sockjs │ │ ├── SessionCallbackFactory.java │ │ ├── Session.java │ │ ├── SessionCallback.java │ │ ├── SockJsMessage.java │ │ ├── test │ │ └── BroadcastSession.java │ │ ├── transports │ │ ├── XhrPollingTransport.java │ │ ├── TransportMetrics.java │ │ ├── XhrStreamingTransport.java │ │ ├── EventSourceTransport.java │ │ ├── JsonpPollingTransport.java │ │ ├── StreamingTransport.java │ │ ├── HtmlFileTransport.java │ │ ├── XhrSendTransport.java │ │ ├── BaseTransport.java │ │ ├── RawWebSocketTransport.java │ │ └── WebSocketTransport.java │ │ ├── PreflightHandler.java │ │ ├── client │ │ ├── SockJsClient.java │ │ └── WebSocketClient.java │ │ ├── IframePage.java │ │ ├── Service.java │ │ ├── Frame.java │ │ ├── SessionHandler.java │ │ └── ServiceRouter.java └── test │ └── java │ └── com │ └── cgbystrom │ └── sockjs │ ├── CloseSession.java │ ├── EchoSession.java │ ├── DisabledWebSocketEchoSession.java │ ├── AmplifySession.java │ ├── StressTestClient.java │ ├── StressTestSession.java │ ├── StressTest.java │ ├── StressTestServer.java │ └── TestServer.java ├── README.md └── pom.xml /src/main/java/com/cgbystrom/sockjs/SessionCallbackFactory.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | public interface SessionCallbackFactory { 4 | SessionCallback getSession(String id) throws Exception; 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/Session.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | public interface Session { 4 | public void send(String message); 5 | public void close(); 6 | public String getId(); 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/SessionCallback.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | public interface SessionCallback { 4 | public void onOpen(Session session) throws Exception; // FIXME: Request parameter? 5 | public void onClose() throws Exception; 6 | public void onMessage(String message) throws Exception; 7 | /** If return false, then silence the exception */ 8 | public boolean onError(Throwable exception); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/SockJsMessage.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | public class SockJsMessage { 4 | private String message; 5 | 6 | public SockJsMessage(String message) { 7 | this.message = message; 8 | } 9 | 10 | public String getMessage() { 11 | return message; 12 | } 13 | 14 | @Override 15 | public String toString() { 16 | return "SockJsMessage{" + 17 | "message='" + message + '\'' + 18 | '}'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/CloseSession.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | public class CloseSession implements SessionCallback 7 | { 8 | private static final Logger logger = LoggerFactory.getLogger(ServiceRouter.class); 9 | 10 | @Override 11 | public void onOpen(Session session) { 12 | logger.debug("Connected!"); 13 | logger.debug("Closing..."); 14 | session.close(); 15 | } 16 | 17 | @Override 18 | public void onClose() { 19 | logger.debug("Disconnected!"); 20 | } 21 | 22 | @Override 23 | public void onMessage(String message) { 24 | logger.debug("Received message: {}", message); 25 | } 26 | 27 | @Override 28 | public boolean onError(Throwable exception) { 29 | logger.error("Error", exception); 30 | return true; 31 | } 32 | } -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/EchoSession.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | public class EchoSession implements SessionCallback 7 | { 8 | private static final Logger logger = LoggerFactory.getLogger(EchoSession.class); 9 | private Session session; 10 | 11 | @Override 12 | public void onOpen(Session session) { 13 | logger.debug("Connected!"); 14 | this.session = session; 15 | } 16 | 17 | @Override 18 | public void onClose() { 19 | logger.debug("Disconnected!"); 20 | session = null; 21 | } 22 | 23 | @Override 24 | public void onMessage(String message) { 25 | logger.debug("Echoing back message"); 26 | session.send(message); 27 | } 28 | 29 | @Override 30 | public boolean onError(Throwable exception) { 31 | logger.error("Error", exception); 32 | return true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/DisabledWebSocketEchoSession.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | public class DisabledWebSocketEchoSession implements SessionCallback 7 | { 8 | private static final Logger logger = LoggerFactory.getLogger(DisabledWebSocketEchoSession.class); 9 | private Session session; 10 | 11 | @Override 12 | public void onOpen(Session session) { 13 | logger.debug("Connected!"); 14 | this.session = session; 15 | } 16 | 17 | @Override 18 | public void onClose() { 19 | logger.debug("Disconnected!"); 20 | } 21 | 22 | @Override 23 | public void onMessage(String message) { 24 | logger.debug("Echoing back message"); 25 | session.send(message); 26 | } 27 | 28 | @Override 29 | public boolean onError(Throwable exception) { 30 | logger.error("Error", exception); 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/AmplifySession.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | public class AmplifySession implements SessionCallback { 7 | private static final Logger logger = LoggerFactory.getLogger(AmplifySession.class); 8 | 9 | private Session session; 10 | 11 | @Override 12 | public void onOpen(Session session) { 13 | logger.debug("Connected!"); 14 | this.session = session; 15 | } 16 | 17 | @Override 18 | public void onClose() { 19 | logger.debug("Disconnected!"); 20 | } 21 | 22 | @Override 23 | public void onMessage(String message) { 24 | int n = Integer.valueOf(message); 25 | if (n < 0 || n > 19) 26 | n = 1; 27 | 28 | logger.debug("Received: 2^" + n); 29 | int z = ((int) Math.pow(2, n)); 30 | StringBuilder sb = new StringBuilder(); 31 | for (int i = 0; i < z; i++) { 32 | sb.append('x'); 33 | } 34 | session.send(sb.toString()); 35 | } 36 | 37 | @Override 38 | public boolean onError(Throwable exception) { 39 | logger.error("Error", exception); 40 | return true; 41 | } 42 | } -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/StressTestClient.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import com.cgbystrom.sockjs.client.SockJsClient; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.net.URI; 8 | import java.net.URISyntaxException; 9 | 10 | public class StressTestClient implements SessionCallback { 11 | private static final Logger logger = LoggerFactory.getLogger(StressTestClient.class); 12 | 13 | private SockJsClient client; 14 | 15 | public StressTestClient(int port) throws URISyntaxException { 16 | URI url = new URI("http://localhost:" + port + "/stresstest"); 17 | client = SockJsClient.newLocalClient(url, SockJsClient.Protocol.WEBSOCKET, this); 18 | } 19 | 20 | public void connect() throws Exception { 21 | client.connect(); 22 | } 23 | 24 | public void disconnect() { 25 | client.disconnect(); 26 | } 27 | 28 | @Override 29 | public void onOpen(Session session) throws Exception { 30 | logger.debug("onOpen"); 31 | } 32 | 33 | @Override 34 | public void onClose() throws Exception { 35 | logger.debug("onClose"); 36 | } 37 | 38 | @Override 39 | public void onMessage(String message) throws Exception { 40 | logger.debug("onMessage"); 41 | } 42 | 43 | @Override 44 | public boolean onError(Throwable exception) { 45 | logger.error("onError", exception); 46 | return false; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/test/BroadcastSession.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.test; 2 | 3 | import com.cgbystrom.sockjs.Session; 4 | import com.cgbystrom.sockjs.SessionCallback; 5 | import org.jboss.netty.logging.InternalLogger; 6 | import org.jboss.netty.logging.InternalLoggerFactory; 7 | 8 | import java.util.HashSet; 9 | import java.util.Set; 10 | 11 | public class BroadcastSession implements SessionCallback { 12 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(BroadcastSession.class); 13 | private static final Set sessions = new HashSet(); 14 | 15 | private Session session; 16 | private String name; 17 | 18 | @Override 19 | public void onOpen(Session session) { 20 | logger.debug("Connected!"); 21 | sessions.add(session); 22 | this.session = session; 23 | } 24 | 25 | @Override 26 | public void onClose() { 27 | logger.debug("Disconnected!"); 28 | sessions.remove(session); 29 | } 30 | 31 | @Override 32 | public void onMessage(String message) { 33 | logger.debug("Broadcasting received message: " + message); 34 | for (Session s : sessions) { 35 | s.send(message); 36 | } 37 | } 38 | 39 | @Override 40 | public boolean onError(Throwable exception) { 41 | logger.error("Error", exception); 42 | return true; 43 | } 44 | 45 | public String getName() { 46 | return name; 47 | } 48 | 49 | public void setName(String name) { 50 | this.name = name; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/XhrPollingTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.cgbystrom.sockjs.*; 4 | import org.jboss.netty.buffer.ChannelBuffer; 5 | import org.jboss.netty.channel.*; 6 | import org.jboss.netty.handler.codec.http.*; 7 | import org.jboss.netty.logging.InternalLogger; 8 | import org.jboss.netty.logging.InternalLoggerFactory; 9 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; 10 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Values.*; 11 | 12 | public class XhrPollingTransport extends BaseTransport { 13 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(XhrPollingTransport.class); 14 | 15 | public XhrPollingTransport(Service.Metrics metrics) { 16 | super(metrics.getXhrPolling()); 17 | } 18 | 19 | @Override 20 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 21 | if (e.getMessage() instanceof Frame) { 22 | Frame frame = (Frame) e.getMessage(); 23 | ChannelBuffer content = Frame.encode(frame, true); 24 | HttpResponse response = createResponse(CONTENT_TYPE_JAVASCRIPT); 25 | response.setHeader(CONTENT_LENGTH, content.readableBytes()); 26 | response.setHeader(CONNECTION, CLOSE); 27 | response.setContent(content); 28 | e.getFuture().addListener(ChannelFutureListener.CLOSE); 29 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), response, e.getRemoteAddress())); 30 | } else { 31 | super.writeRequested(ctx, e); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/TransportMetrics.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.codahale.metrics.Counter; 4 | import com.codahale.metrics.Histogram; 5 | import com.codahale.metrics.Meter; 6 | import com.codahale.metrics.MetricRegistry; 7 | 8 | /** 9 | * Created with IntelliJ IDEA. 10 | * User: cbystrom 11 | * Date: 2013-05-21 12 | * Time: 16:26 13 | * To change this template use File | Settings | File Templates. 14 | */ 15 | public class TransportMetrics { 16 | public final Counter sessionsOpen; 17 | public final Meter sessionsOpened; 18 | public final Counter connectionsOpen; 19 | public final Meter connectionsOpened; 20 | public final Meter messagesReceived; 21 | public final Histogram messagesReceivedSize; 22 | public final Meter messagesSent; 23 | public final Histogram messagesSentSize; 24 | private final String prefix; 25 | private final String transport; 26 | 27 | public TransportMetrics(String prefix, String transport, MetricRegistry metrics) { 28 | this.prefix = prefix; 29 | this.transport = transport; 30 | sessionsOpen = metrics.counter(getName("sessionsOpen")); 31 | sessionsOpened = metrics.meter(getName("sessionsOpened")); 32 | connectionsOpen = metrics.counter(getName("connectionsOpen")); 33 | connectionsOpened = metrics.meter(getName("connectionsOpened")); 34 | messagesReceived = metrics.meter(getName("messagesReceived")); 35 | messagesReceivedSize = metrics.histogram(getName("messagesReceivedSize")); 36 | messagesSent = metrics.meter(getName("messagesSent")); 37 | messagesSentSize = metrics.histogram(getName("messagesSentSize")); 38 | } 39 | 40 | private String getName(String name) { 41 | return MetricRegistry.name(prefix, transport, name); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/StressTestSession.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | import java.util.concurrent.ConcurrentHashMap; 7 | 8 | public class StressTestSession implements SessionCallback { 9 | private static final Logger logger = LoggerFactory.getLogger(StressTestSession.class); 10 | 11 | /** 12 | * Ensure we track connected/disconencted sessions 13 | * This is to emulate behavior in a real app keeping track of sessions. 14 | * Otherwise we would by accident lose track of the LoadTestSession and the GC will get it. 15 | */ 16 | private static final ConcurrentHashMap clients = new ConcurrentHashMap(100000); 17 | 18 | private Session session; 19 | 20 | /** Try emulating behavior of a real app by hogging some memory */ 21 | private final int[] memoryHogger; 22 | 23 | public StressTestSession() { 24 | memoryHogger = new int[10000]; 25 | for (int i = 0; i < 10000; i++) { 26 | memoryHogger[i] = i; 27 | } 28 | } 29 | 30 | @Override 31 | public void onOpen(Session session) { 32 | logger.debug("Connected!"); 33 | this.session = session; 34 | assert session.getId() != null; 35 | clients.put(session.getId(), this); 36 | } 37 | 38 | @Override 39 | public void onClose() { 40 | logger.debug("Disconnected!"); 41 | clients.remove(session.getId()); 42 | session = null; 43 | } 44 | 45 | @Override 46 | public void onMessage(String message) { 47 | logger.debug("Echoing back message"); 48 | session.send(message); 49 | } 50 | 51 | @Override 52 | public boolean onError(Throwable exception) { 53 | logger.error("Error", exception); 54 | return true; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/StressTest.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import ch.qos.logback.classic.Level; 4 | import ch.qos.logback.classic.Logger; 5 | import org.jboss.netty.logging.InternalLoggerFactory; 6 | import org.jboss.netty.logging.Slf4JLoggerFactory; 7 | import org.junit.Ignore; 8 | import org.slf4j.LoggerFactory; 9 | 10 | import java.util.ArrayList; 11 | import java.util.List; 12 | 13 | /** 14 | * A simple stress test. 15 | * 16 | * Connects X number of clients very quickly and then waits Y seconds, 17 | * then closes all connections. 18 | * 19 | * Can be quite useful for detecting memory leaks or profiling overall performance. 20 | * 21 | * Uses local channels instead of real sockets to avoid dealing with socket headaches. 22 | * May not 100% reproduce the scenario seen in the wild card. But it's good enough for most purposes. 23 | */ 24 | @Ignore 25 | public class StressTest { 26 | public static void main(String[] args) throws Exception { 27 | Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); 28 | rootLogger.setLevel(Level.INFO); 29 | InternalLoggerFactory.setDefaultFactory(new Slf4JLoggerFactory()); 30 | 31 | final int port = 8001; 32 | final int numClients = 10; 33 | 34 | new StressTestServer(port).start(); 35 | System.out.println("Server running.."); 36 | 37 | List clients = new ArrayList(numClients); 38 | for (int i = 0; i < numClients; i++) { 39 | StressTestClient client = new StressTestClient(port); 40 | client.connect(); 41 | clients.add(client); 42 | } 43 | 44 | System.out.println("Waiting to disconnect all clients..."); 45 | Thread.sleep(2 * 1000); 46 | System.out.println("Disconnecting all clients..."); 47 | 48 | for (StressTestClient client : clients) { 49 | client.disconnect(); 50 | } 51 | 52 | System.out.println("All clients disconnected!"); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/XhrStreamingTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.cgbystrom.sockjs.Frame; 4 | import com.cgbystrom.sockjs.Service; 5 | import org.jboss.netty.buffer.ChannelBuffer; 6 | import org.jboss.netty.channel.*; 7 | import org.jboss.netty.handler.codec.http.*; 8 | import org.jboss.netty.logging.InternalLogger; 9 | import org.jboss.netty.logging.InternalLoggerFactory; 10 | 11 | public class XhrStreamingTransport extends StreamingTransport { 12 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(XhrStreamingTransport.class); 13 | 14 | public XhrStreamingTransport(Service.Metrics metrics, int maxResponseSize) { 15 | super(metrics.getXhrStreaming(), maxResponseSize); 16 | } 17 | 18 | @Override 19 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 20 | if (e.getMessage() instanceof Frame) { 21 | if (headerSent.compareAndSet(false, true)) { 22 | HttpResponse response = createResponse(CONTENT_TYPE_JAVASCRIPT); 23 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), Channels.future(e.getChannel()), response, e.getRemoteAddress())); 24 | 25 | // IE requires 2KB prefix: 26 | // http://blogs.msdn.com/b/ieinternals/archive/2010/04/06/comet-streaming-in-internet-explorer-with-xmlhttprequest-and-xdomainrequest.aspx 27 | DefaultHttpChunk message = new DefaultHttpChunk(Frame.encode(Frame.preludeFrame(), true)); 28 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), Channels.future(e.getChannel()), message, e.getRemoteAddress())); 29 | } 30 | final Frame frame = (Frame) e.getMessage(); 31 | ChannelBuffer content = Frame.encode(frame, true); 32 | 33 | if (frame instanceof Frame.CloseFrame) { 34 | e.getFuture().addListener(ChannelFutureListener.CLOSE); 35 | } 36 | 37 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), new DefaultHttpChunk(content), e.getRemoteAddress())); 38 | logResponseSize(e.getChannel(), content); 39 | } else { 40 | super.writeRequested(ctx, e); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/EventSourceTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.cgbystrom.sockjs.Frame; 4 | import com.cgbystrom.sockjs.Service; 5 | import org.jboss.netty.buffer.ChannelBuffer; 6 | import org.jboss.netty.buffer.ChannelBuffers; 7 | import org.jboss.netty.channel.*; 8 | import org.jboss.netty.handler.codec.http.*; 9 | import org.jboss.netty.logging.InternalLogger; 10 | import org.jboss.netty.logging.InternalLoggerFactory; 11 | import org.jboss.netty.util.CharsetUtil; 12 | 13 | public class EventSourceTransport extends StreamingTransport { 14 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(EventSourceTransport.class); 15 | private static final ChannelBuffer NEW_LINE = ChannelBuffers.copiedBuffer("\r\n", CharsetUtil.UTF_8); 16 | private static final ChannelBuffer FRAME_BEGIN = ChannelBuffers.copiedBuffer("data: ", CharsetUtil.UTF_8); 17 | private static final ChannelBuffer FRAME_END = ChannelBuffers.copiedBuffer("\r\n\r\n", CharsetUtil.UTF_8); 18 | private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream; charset=UTF-8"; 19 | 20 | public EventSourceTransport(Service.Metrics metrics, int maxResponseSize) { 21 | super(metrics.getEventSource(), maxResponseSize); 22 | } 23 | 24 | @Override 25 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 26 | if (e.getMessage() instanceof Frame) { 27 | Frame frame = (Frame) e.getMessage(); 28 | if (headerSent.compareAndSet(false, true)) { 29 | HttpResponse response = createResponse(CONTENT_TYPE_EVENT_STREAM); 30 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), response, e.getRemoteAddress())); 31 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), new DefaultHttpChunk(NEW_LINE), e.getRemoteAddress())); 32 | } 33 | 34 | ChannelBuffer wrappedContent = ChannelBuffers.wrappedBuffer(FRAME_BEGIN, Frame.encode(frame, false), FRAME_END); 35 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), new DefaultHttpChunk(wrappedContent), e.getRemoteAddress())); 36 | logResponseSize(e.getChannel(), wrappedContent); 37 | } else { 38 | super.writeRequested(ctx, e); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/StressTestServer.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import com.codahale.metrics.JmxReporter; 4 | import com.codahale.metrics.MetricRegistry; 5 | import org.jboss.netty.bootstrap.ServerBootstrap; 6 | import org.jboss.netty.channel.ChannelPipeline; 7 | import org.jboss.netty.channel.ChannelPipelineFactory; 8 | import org.jboss.netty.channel.local.DefaultLocalServerChannelFactory; 9 | import org.jboss.netty.channel.local.LocalAddress; 10 | import org.jboss.netty.handler.codec.http.HttpChunkAggregator; 11 | import org.jboss.netty.handler.codec.http.HttpRequestDecoder; 12 | import org.jboss.netty.handler.codec.http.HttpResponseEncoder; 13 | import org.junit.Ignore; 14 | 15 | import static org.jboss.netty.channel.Channels.pipeline; 16 | 17 | @Ignore 18 | public class StressTestServer { 19 | private final ServerBootstrap bootstrap = new ServerBootstrap(new DefaultLocalServerChannelFactory()); 20 | private final MetricRegistry registry = new MetricRegistry(); 21 | private final int port; 22 | 23 | public StressTestServer(int port) { 24 | this.port = port; 25 | } 26 | 27 | public void start() throws Exception { 28 | final JmxReporter reporter = JmxReporter.forRegistry(registry).build(); 29 | final ServiceRouter router = new ServiceRouter(); 30 | 31 | reporter.start(); 32 | router.setMetricRegistry(registry); 33 | 34 | Service echoService = new Service("/stresstest", new SessionCallbackFactory() { 35 | @Override 36 | public StressTestSession getSession(String id) throws Exception { 37 | return new StressTestSession(); 38 | } 39 | }); 40 | router.registerService(echoService); 41 | 42 | bootstrap.setPipelineFactory(new ChannelPipelineFactory() { 43 | @Override 44 | public ChannelPipeline getPipeline() throws Exception { 45 | ChannelPipeline pipeline = pipeline(); 46 | pipeline.addLast("decoder", new HttpRequestDecoder()); 47 | pipeline.addLast("chunkAggregator", new HttpChunkAggregator(130 * 1024)); // Required for WS handshaker or else NPE. 48 | pipeline.addLast("encoder", new HttpResponseEncoder()); 49 | pipeline.addLast("preflight", new PreflightHandler()); 50 | pipeline.addLast("router", router); 51 | return pipeline; 52 | } 53 | }); 54 | 55 | bootstrap.bind(new LocalAddress(port)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sockjs-netty 2 | 3 | An implementation of SockJS for Java using JBoss Netty. It is currently not finished and not production ready. 4 | However, all transports offered by SockJS have been implemented and the issues remaining are minor. 5 | 6 | Currently passes all tests from the [0.3.3 protocol specification](http://sockjs.github.io/sockjs-protocol/sockjs-protocol-0.3.3.html) except for ```test_haproxy```. 7 | Only users behind HAProxy should be impacted by that though. 8 | 9 | ## What is SockJS? 10 | SockJS is a browser JavaScript library that provides a WebSocket-like object. SockJS gives you a coherent, cross-browser, Javascript API which creates a low latency, full duplex, cross-domain communication channel between the browser and the web server. 11 | 12 | Under the hood SockJS tries to use native WebSockets first. If that fails it can use a variety of browser-specific transport protocols and presents them through WebSocket-like abstractions. 13 | 14 | SockJS is intended to work for all modern browsers and in environments which don't support WebSocket protcol, for example behind restrictive corporate proxies. 15 | 16 | Read more at http://sockjs.org 17 | 18 | ## Installing 19 | sockjs-netty is packaged as a library for JBoss Netty and not as a standalone server. Rather than installing a prepackaged JAR, you are required to include it as you would with any other Java library. 20 | 21 | Intention is to make it available through Maven Central once it is stable enough. But right now you will have to build it from source with Maven: 22 | 23 | mvn package 24 | 25 | That will output a JAR inside the ```target/``` folder. 26 | 27 | ## Running the test server 28 | Right now everything is packaged as a library rather than a ready-to-run server. 29 | But there is a test server included that serves as an example and is also used for verifying the tests provided by the SockJS project. 30 | 31 | Until a prebuilt Java binary, a JAR, is shipped you'll need to compile and run the project with Maven. 32 | (Maven is a Java build tool used by the project). 33 | 34 | Running the server: 35 | 36 | 1. Install Maven ([Download it](http://maven.apache.org/download.html), 3.x should be fine, even 2.x. Or use your favorite package manager) 37 | 1. Clone the project 38 | 1. Run ```mvn exec:java -Dexec.mainClass="com.cgbystrom.sockjs.TestServer" -Dexec.classpathScope=test -e``` from your cloned project directory. 39 | 40 | ## What's missing? 41 | Currently, not all tests provided by the SockJS protocol pass. As mentioned, it is still work in progress and the goal is naturally to be 100% compatible with the protocol. 42 | The tests currently not passing are the ones testing Web Socket edge cases. -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/PreflightHandler.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.jboss.netty.channel.*; 4 | import org.jboss.netty.handler.codec.http.*; 5 | 6 | public class PreflightHandler extends SimpleChannelHandler { 7 | private String origin = null; 8 | private String corsHeaders = null; 9 | 10 | @Override 11 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 12 | if (e.getMessage() instanceof HttpRequest) { 13 | HttpRequest request = (HttpRequest)e.getMessage(); 14 | 15 | String originHeader = request.getHeader("Origin"); 16 | if (originHeader != null) 17 | origin = originHeader; 18 | 19 | corsHeaders = request.getHeader("Access-Control-Request-Headers"); 20 | 21 | if (request.getMethod().equals(HttpMethod.OPTIONS)) { 22 | HttpResponse response = new DefaultHttpResponse(request.getProtocolVersion(), HttpResponseStatus.NO_CONTENT); 23 | response.setHeader(HttpHeaders.Names.CONTENT_TYPE, "text/plain; charset=UTF-8"); 24 | response.setHeader(HttpHeaders.Names.CACHE_CONTROL, "max-age=31536000, public"); 25 | response.setHeader("Access-Control-Max-Age", "31536000"); 26 | 27 | // FIXME: Dirty, handle per transport? 28 | if (request.getUri().contains("/xhr")) { 29 | response.setHeader("Access-Control-Allow-Methods", "OPTIONS, POST"); 30 | } else { 31 | response.setHeader("Access-Control-Allow-Methods", "OPTIONS, GET"); 32 | } 33 | 34 | response.setHeader("Access-Control-Allow-Headers", "Content-Type"); 35 | response.setHeader("Access-Control-Allow-Credentials", "true"); 36 | response.setHeader(HttpHeaders.Names.EXPIRES, "FIXME"); // FIXME: Fix this 37 | response.setHeader(HttpHeaders.Names.SET_COOKIE, "JSESSIONID=dummy; path=/"); 38 | ctx.getChannel().write(response).addListener(ChannelFutureListener.CLOSE); 39 | return; 40 | } 41 | } 42 | super.messageReceived(ctx, e); 43 | } 44 | 45 | @Override 46 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 47 | if (e.getMessage() instanceof HttpResponse) { 48 | HttpResponse response = (HttpResponse)e.getMessage(); 49 | response.setHeader("Access-Control-Allow-Origin", origin == null || "null".equals(origin) ? "*" : origin); 50 | response.setHeader("Access-Control-Allow-Credentials", "true"); 51 | 52 | if (corsHeaders != null) { 53 | response.setHeader("Access-Control-Allow-Headers", corsHeaders); 54 | } 55 | } 56 | super.writeRequested(ctx, e); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/JsonpPollingTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.cgbystrom.sockjs.Frame; 4 | import com.cgbystrom.sockjs.Service; 5 | import org.jboss.netty.buffer.ChannelBuffer; 6 | import org.jboss.netty.buffer.ChannelBuffers; 7 | import org.jboss.netty.channel.*; 8 | import org.jboss.netty.handler.codec.http.*; 9 | import org.jboss.netty.logging.InternalLogger; 10 | import org.jboss.netty.logging.InternalLoggerFactory; 11 | import org.jboss.netty.util.CharsetUtil; 12 | 13 | import java.util.List; 14 | 15 | public class JsonpPollingTransport extends BaseTransport { 16 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(JsonpPollingTransport.class); 17 | 18 | private String jsonpCallback; 19 | 20 | public JsonpPollingTransport(Service.Metrics metrics) { 21 | super(metrics.getJsonp()); 22 | } 23 | 24 | @Override 25 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 26 | HttpRequest request = (HttpRequest) e.getMessage(); 27 | 28 | QueryStringDecoder qsd = new QueryStringDecoder(request.getUri()); 29 | final List c = qsd.getParameters().get("c"); 30 | if (c == null) { 31 | respond(e.getChannel(), HttpResponseStatus.INTERNAL_SERVER_ERROR, "\"callback\" parameter required."); 32 | return; 33 | } 34 | jsonpCallback = c.get(0); 35 | 36 | super.messageReceived(ctx, e); 37 | } 38 | 39 | @Override 40 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 41 | if (e.getMessage() instanceof Frame) { 42 | final Frame frame = (Frame) e.getMessage(); 43 | HttpResponse response = createResponse(CONTENT_TYPE_JAVASCRIPT); 44 | response.setHeader(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE); 45 | response.setHeader(HttpHeaders.Names.CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"); 46 | 47 | ChannelBuffer escapedContent = ChannelBuffers.dynamicBuffer(); 48 | Frame.escapeJson(Frame.encode(frame, false), escapedContent); 49 | String m = jsonpCallback + "(\"" + escapedContent.toString(CharsetUtil.UTF_8) + "\");\r\n"; 50 | 51 | e.getFuture().addListener(ChannelFutureListener.CLOSE); 52 | 53 | final ChannelBuffer content = ChannelBuffers.copiedBuffer(m, CharsetUtil.UTF_8); 54 | response.setContent(content); 55 | response.setHeader(HttpHeaders.Names.CONTENT_LENGTH, content.readableBytes()); 56 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), response, e.getRemoteAddress())); 57 | transportMetrics.messagesSent.mark(); 58 | transportMetrics.messagesSentSize.update(content.readableBytes()); 59 | } else { 60 | super.writeRequested(ctx, e); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/StreamingTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import org.jboss.netty.buffer.ChannelBuffer; 4 | import org.jboss.netty.channel.*; 5 | import org.jboss.netty.handler.codec.http.*; 6 | 7 | import java.util.concurrent.atomic.AtomicBoolean; 8 | import java.util.concurrent.atomic.AtomicInteger; 9 | 10 | /** 11 | * Base class for streaming transports 12 | * 13 | * Handles HTTP chunking and response size limiting for browser "garbage collection". 14 | */ 15 | public class StreamingTransport extends BaseTransport { 16 | /** 17 | * Max size of response content sent before closing the connection. 18 | * Since browsers buffer chunked/streamed content in-memory the connection must be closed 19 | * at regular intervals. Call it "garbage collection" if you will. 20 | */ 21 | protected final int maxResponseSize; 22 | 23 | /** Track size of content chunks sent to the browser. */ 24 | protected AtomicInteger numBytesSent = new AtomicInteger(0); 25 | 26 | /** For streaming/chunked transports we need to send HTTP header only once (naturally) */ 27 | protected AtomicBoolean headerSent = new AtomicBoolean(false); 28 | 29 | /** Keep track if ending HTTP chunk has been sent */ 30 | private AtomicBoolean lastChunkSent = new AtomicBoolean(false); 31 | 32 | public StreamingTransport(TransportMetrics transportMetrics) { 33 | this(transportMetrics, 128 * 1024); // 128 KiB 34 | } 35 | 36 | public StreamingTransport(TransportMetrics transportMetrics, int maxResponseSize) { 37 | super(transportMetrics); 38 | this.maxResponseSize = maxResponseSize; 39 | } 40 | 41 | @Override 42 | public void closeRequested(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 43 | // request can be null since close can be requested prior to receiving a message. 44 | if (request != null && request.getProtocolVersion() == HttpVersion.HTTP_1_1 && lastChunkSent.compareAndSet(false, true)) { 45 | e.getChannel().write(HttpChunk.LAST_CHUNK).addListener(ChannelFutureListener.CLOSE); 46 | } else { 47 | super.closeRequested(ctx, e); 48 | } 49 | } 50 | 51 | protected void logResponseSize(Channel channel, ChannelBuffer content) { 52 | transportMetrics.messagesSent.mark(); 53 | transportMetrics.messagesSentSize.update(content.readableBytes()); 54 | 55 | numBytesSent.addAndGet(content.readableBytes()); 56 | 57 | if (numBytesSent.get() >= maxResponseSize) { 58 | // Close the connection to allow the browser to flush in-memory buffered content from this XHR stream. 59 | channel.close(); 60 | } 61 | } 62 | 63 | @Override 64 | protected HttpResponse createResponse(String contentType) { 65 | HttpResponse response = super.createResponse(contentType); 66 | if (request.getProtocolVersion().equals(HttpVersion.HTTP_1_1)) { 67 | response.setHeader(HttpHeaders.Names.TRANSFER_ENCODING, HttpHeaders.Values.CHUNKED); 68 | } 69 | return response; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/client/SockJsClient.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.client; 2 | 3 | import com.cgbystrom.sockjs.SessionCallback; 4 | import org.codehaus.jackson.map.ObjectMapper; 5 | import org.jboss.netty.bootstrap.ClientBootstrap; 6 | import org.jboss.netty.channel.*; 7 | import org.jboss.netty.channel.local.DefaultLocalClientChannelFactory; 8 | import org.jboss.netty.channel.local.LocalClientChannelFactory; 9 | import org.jboss.netty.channel.socket.nio.NioClientSocketChannelFactory; 10 | import org.jboss.netty.handler.codec.http.HttpRequestEncoder; 11 | import org.jboss.netty.handler.codec.http.HttpResponseDecoder; 12 | 13 | import java.net.URI; 14 | import java.net.URISyntaxException; 15 | import java.util.concurrent.Executors; 16 | 17 | public abstract class SockJsClient extends SimpleChannelUpstreamHandler { 18 | private static final NioClientSocketChannelFactory socketChannelFactory = new NioClientSocketChannelFactory( 19 | Executors.newCachedThreadPool(), 20 | Executors.newCachedThreadPool()); 21 | private static final LocalClientChannelFactory localSocketChannelFactory = new DefaultLocalClientChannelFactory(); 22 | protected static final ObjectMapper objectMapper = new ObjectMapper(); 23 | 24 | public abstract ChannelFuture connect() throws URISyntaxException; 25 | public abstract ChannelFuture disconnect(); 26 | 27 | public static SockJsClient newClient(URI url, Protocol protocol, final SessionCallback callback) { 28 | ClientBootstrap bootstrap = new ClientBootstrap(socketChannelFactory); 29 | 30 | switch (protocol) { 31 | case WEBSOCKET: 32 | final WebSocketClient clientHandler = new WebSocketClient(bootstrap, url, callback); 33 | 34 | bootstrap.setPipelineFactory(new ChannelPipelineFactory() { 35 | public ChannelPipeline getPipeline() throws Exception { 36 | ChannelPipeline pipeline = Channels.pipeline(); 37 | pipeline.addLast("decoder", new HttpResponseDecoder()); 38 | pipeline.addLast("encoder", new HttpRequestEncoder()); 39 | pipeline.addLast("ws-handler", clientHandler); 40 | return pipeline; 41 | } 42 | }); 43 | 44 | return clientHandler; 45 | } 46 | 47 | throw new IllegalArgumentException("Invalid protocol specified"); 48 | } 49 | 50 | public static SockJsClient newLocalClient(URI url, Protocol protocol, final SessionCallback callback) { 51 | ClientBootstrap bootstrap = new ClientBootstrap(localSocketChannelFactory); 52 | 53 | switch (protocol) { 54 | case WEBSOCKET: 55 | final WebSocketClient clientHandler = new WebSocketClient(bootstrap, url, callback); 56 | 57 | bootstrap.setPipelineFactory(new ChannelPipelineFactory() { 58 | public ChannelPipeline getPipeline() throws Exception { 59 | ChannelPipeline pipeline = Channels.pipeline(); 60 | pipeline.addLast("decoder", new HttpResponseDecoder()); 61 | pipeline.addLast("encoder", new HttpRequestEncoder()); 62 | pipeline.addLast("ws-handler", clientHandler); 63 | return pipeline; 64 | } 65 | }); 66 | 67 | return clientHandler; 68 | } 69 | 70 | throw new IllegalArgumentException("Invalid protocol specified"); 71 | } 72 | 73 | 74 | 75 | public static enum Protocol { 76 | WEBSOCKET 77 | 78 | /* Not yet implemented 79 | XDR_STREAMING, 80 | XHR_STREAMING, 81 | IFRAME_EVENTSOURCE, 82 | IFRAME_HTMLFILE, 83 | XDR_POLLING, 84 | XHR_POLLING, 85 | IFRAME_XHR_POLLING, 86 | JSONP_POLLING 87 | */ 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/IframePage.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.jboss.netty.buffer.ChannelBuffer; 4 | import org.jboss.netty.buffer.ChannelBuffers; 5 | import org.jboss.netty.handler.codec.http.*; 6 | import org.jboss.netty.util.CharsetUtil; 7 | 8 | import java.io.UnsupportedEncodingException; 9 | import java.security.MessageDigest; 10 | import java.security.NoSuchAlgorithmException; 11 | import java.util.Formatter; 12 | 13 | public class IframePage { 14 | private ChannelBuffer content; 15 | private String etag; 16 | 17 | public IframePage(String url) { 18 | content = createContent(url); 19 | } 20 | 21 | public void handle(HttpRequest request, HttpResponse response) { 22 | QueryStringDecoder qsd = new QueryStringDecoder(request.getUri()); 23 | String path = qsd.getPath(); 24 | 25 | if (!path.matches(".*/iframe[0-9-.a-z_]*.html")) { 26 | response.setStatus(HttpResponseStatus.NOT_FOUND); 27 | response.setContent(ChannelBuffers.copiedBuffer("Not found", CharsetUtil.UTF_8)); 28 | return; 29 | } 30 | 31 | response.setHeader(HttpHeaders.Names.SET_COOKIE, "JSESSIONID=dummy; path=/"); 32 | 33 | if (request.containsHeader(HttpHeaders.Names.IF_NONE_MATCH)) { 34 | response.setStatus(HttpResponseStatus.NOT_MODIFIED); 35 | response.removeHeader(HttpHeaders.Names.CONTENT_TYPE); 36 | } else { 37 | response.setHeader(HttpHeaders.Names.CONTENT_TYPE, "text/html; charset=UTF-8"); 38 | response.setHeader(HttpHeaders.Names.CACHE_CONTROL, "max-age=31536000, public"); 39 | response.setHeader(HttpHeaders.Names.EXPIRES, "FIXME"); // FIXME: Fix this 40 | response.removeHeader(HttpHeaders.Names.SET_COOKIE); 41 | response.setContent(content); 42 | } 43 | 44 | response.setHeader(HttpHeaders.Names.ETAG, etag); 45 | } 46 | 47 | private ChannelBuffer createContent(String url) { 48 | String content = "\n" + 49 | "\n" + 50 | "\n" + 51 | " \n" + 52 | " \n" + 53 | " \n" + 57 | " \n" + 58 | "\n" + 59 | "\n" + 60 | "

Don't panic!

\n" + 61 | "

This is a SockJS hidden iframe. It's used for cross domain magic.

\n" + 62 | "\n" + 63 | ""; 64 | 65 | // FIXME: Don't modify attributes here 66 | etag = "\"" + generateMd5(content) + "\""; 67 | 68 | return ChannelBuffers.copiedBuffer(content, CharsetUtil.UTF_8); 69 | } 70 | 71 | private static String generateMd5(String value) { 72 | String encryptedString = null; 73 | byte[] bytesToBeEncrypted; 74 | try { 75 | // convert string to bytes using a encoding scheme 76 | bytesToBeEncrypted = value.getBytes("UTF-8"); 77 | MessageDigest md = MessageDigest.getInstance("MD5"); 78 | byte[] theDigest = md.digest(bytesToBeEncrypted); 79 | // convert each byte to a hexadecimal digit 80 | Formatter formatter = new Formatter(); 81 | for (byte b : theDigest) { 82 | formatter.format("%02x", b); 83 | } 84 | encryptedString = formatter.toString().toLowerCase(); 85 | } catch (UnsupportedEncodingException e) { 86 | // FIXME: Return proper HTTP error 87 | e.printStackTrace(); 88 | } catch (NoSuchAlgorithmException e) { 89 | // FIXME: Return proper HTTP error 90 | e.printStackTrace(); 91 | } 92 | return encryptedString; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | org.sonatype.oss 5 | oss-parent 6 | 7 7 | 8 | 4.0.0 9 | com.cgbystrom 10 | sockjs-netty 11 | 0.1.0-SNAPSHOT 12 | SockJS for JBoss Netty 13 | jar 14 | 15 | A Java-based library for building SockJS compatible servers using JBoss Netty. 16 | 17 | https://github.com/cgbystrom/sockjs-netty 18 | 19 | 24 | 25 | 26 | 27 | cgbystrom 28 | Carl Byström 29 | cgbystrom@gmail.com 30 | http://cgbystrom.com 31 | +1 32 | 33 | 34 | 35 | 36 | 37 | MIT License 38 | 39 | http://www.opensource.org/licenses/mit-license.php 40 | 41 | repo 42 | 43 | 44 | 45 | 46 | 47 | io.netty 48 | netty 49 | 3.6.5.Final 50 | provided 51 | 52 | 53 | com.codahale.metrics 54 | metrics-core 55 | 3.0.0-BETA2 56 | 57 | 58 | org.codehaus.jackson 59 | jackson-core-asl 60 | 1.9.12 61 | 62 | 63 | org.codehaus.jackson 64 | jackson-mapper-asl 65 | 1.9.12 66 | 67 | 68 | 69 | ch.qos.logback 70 | logback-classic 71 | 1.0.0 72 | test 73 | 74 | 75 | junit 76 | junit 77 | 4.8.1 78 | test 79 | 80 | 81 | 82 | 83 | 84 | 85 | org.apache.maven.plugins 86 | maven-compiler-plugin 87 | 2.3.2 88 | 89 | 1.6 90 | 1.6 91 | 92 | 93 | 94 | maven-source-plugin 95 | 2.1.2 96 | 97 | 98 | attach-sources 99 | 100 | jar 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | repository.jboss.org 111 | http://repository.jboss.org/nexus/content/groups/public/ 112 | 113 | false 114 | 115 | 116 | 117 | maven2-repository.dev.java.net 118 | Java.net Repository for Maven 119 | http://download.java.net/maven/2/ 120 | default 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/test/java/com/cgbystrom/sockjs/TestServer.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import ch.qos.logback.classic.Logger; 4 | import ch.qos.logback.classic.LoggerContext; 5 | import ch.qos.logback.classic.encoder.PatternLayoutEncoder; 6 | import ch.qos.logback.classic.spi.ILoggingEvent; 7 | import ch.qos.logback.core.ConsoleAppender; 8 | import com.cgbystrom.sockjs.test.BroadcastSession; 9 | import com.codahale.metrics.JmxReporter; 10 | import com.codahale.metrics.MetricRegistry; 11 | import org.jboss.netty.bootstrap.ServerBootstrap; 12 | import org.jboss.netty.channel.ChannelPipeline; 13 | import org.jboss.netty.channel.ChannelPipelineFactory; 14 | import org.jboss.netty.channel.socket.nio.NioServerSocketChannelFactory; 15 | import org.jboss.netty.handler.codec.http.HttpChunkAggregator; 16 | import org.jboss.netty.handler.codec.http.HttpRequestDecoder; 17 | import org.jboss.netty.handler.codec.http.HttpResponseEncoder; 18 | 19 | import org.jboss.netty.logging.InternalLoggerFactory; 20 | import org.jboss.netty.logging.Slf4JLoggerFactory; 21 | import org.junit.Ignore; 22 | import org.slf4j.LoggerFactory; 23 | 24 | import java.net.InetSocketAddress; 25 | import java.util.concurrent.Executors; 26 | 27 | import static org.jboss.netty.channel.Channels.pipeline; 28 | 29 | @Ignore 30 | public class TestServer { 31 | public static void main(String[] args) { 32 | Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); 33 | LoggerContext loggerContext = rootLogger.getLoggerContext(); 34 | loggerContext.reset(); 35 | PatternLayoutEncoder encoder = new PatternLayoutEncoder(); 36 | encoder.setContext(loggerContext); 37 | encoder.setPattern("%-5level %-20class{0}: %message%n"); 38 | encoder.start(); 39 | 40 | ConsoleAppender appender = new ConsoleAppender(); 41 | appender.setContext(loggerContext); 42 | appender.setEncoder(encoder); 43 | appender.start(); 44 | 45 | rootLogger.addAppender(appender); 46 | InternalLoggerFactory.setDefaultFactory(new Slf4JLoggerFactory()); 47 | 48 | ServerBootstrap bootstrap = new ServerBootstrap( 49 | new NioServerSocketChannelFactory( 50 | Executors.newCachedThreadPool(), 51 | Executors.newCachedThreadPool())); 52 | 53 | final MetricRegistry registry = new MetricRegistry(); 54 | final JmxReporter reporter = JmxReporter.forRegistry(registry).build(); 55 | reporter.start(); 56 | 57 | final ServiceRouter router = new ServiceRouter(); 58 | router.setMetricRegistry(registry); 59 | 60 | router.registerService(new Service("/disabled_websocket_echo", new DisabledWebSocketEchoSession())); 61 | router.registerService(new Service("/close", new CloseSession())); 62 | router.registerService(new Service("/amplify", new AmplifySession())); 63 | router.registerService(new Service("/broadcast", new SessionCallbackFactory() { 64 | @Override 65 | public BroadcastSession getSession(String id) throws Exception { 66 | return new BroadcastSession(); 67 | } 68 | })); 69 | 70 | Service echoService = new Service("/echo", new SessionCallbackFactory() { 71 | @Override 72 | public EchoSession getSession(String id) throws Exception { 73 | return new EchoSession(); 74 | } 75 | }); 76 | echoService.setMaxResponseSize(4096); 77 | router.registerService(echoService); 78 | 79 | Service cookieNeededEcho = new Service("/cookie_needed_echo", new EchoSession()); 80 | cookieNeededEcho.setMaxResponseSize(4096); 81 | cookieNeededEcho.setCookieNeeded(true); 82 | router.registerService(cookieNeededEcho); 83 | 84 | bootstrap.setPipelineFactory(new ChannelPipelineFactory() { 85 | @Override 86 | public ChannelPipeline getPipeline() throws Exception { 87 | ChannelPipeline pipeline = pipeline(); 88 | pipeline.addLast("decoder", new HttpRequestDecoder()); 89 | pipeline.addLast("chunkAggregator", new HttpChunkAggregator(130 * 1024)); // Required for WS handshaker or else NPE. 90 | pipeline.addLast("encoder", new HttpResponseEncoder()); 91 | pipeline.addLast("preflight", new PreflightHandler()); 92 | pipeline.addLast("router", router); 93 | return pipeline; 94 | } 95 | }); 96 | 97 | bootstrap.bind(new InetSocketAddress(8090)); 98 | System.out.println("Server running.."); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/HtmlFileTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.cgbystrom.sockjs.Frame; 4 | import com.cgbystrom.sockjs.Service; 5 | import org.jboss.netty.buffer.ChannelBuffer; 6 | import org.jboss.netty.buffer.ChannelBuffers; 7 | import org.jboss.netty.channel.*; 8 | import org.jboss.netty.handler.codec.http.*; 9 | import org.jboss.netty.logging.InternalLogger; 10 | import org.jboss.netty.logging.InternalLoggerFactory; 11 | import org.jboss.netty.util.CharsetUtil; 12 | 13 | import java.util.List; 14 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; 15 | 16 | public class HtmlFileTransport extends StreamingTransport { 17 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(HtmlFileTransport.class); 18 | private static final ChannelBuffer HEADER_PART1 = ChannelBuffers.copiedBuffer("\n" + 19 | "\n" + 20 | " \n" + 21 | " \n" + 22 | "

Don't panic!

\n" + 23 | " ", CharsetUtil.UTF_8); 31 | private static final ChannelBuffer PREFIX = ChannelBuffers.copiedBuffer("\r\n", CharsetUtil.UTF_8); 33 | 34 | 35 | private ChannelBuffer header; 36 | 37 | public HtmlFileTransport(Service.Metrics metrics, int maxResponseSize) { 38 | super(metrics.getHtmlFile(), maxResponseSize); 39 | } 40 | 41 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 42 | HttpRequest request = (HttpRequest) e.getMessage(); 43 | QueryStringDecoder qsd = new QueryStringDecoder(request.getUri()); 44 | 45 | final List c = qsd.getParameters().get("c"); 46 | if (c == null) { 47 | respond(e.getChannel(), HttpResponseStatus.INTERNAL_SERVER_ERROR, "\"callback\" parameter required."); 48 | return; 49 | } 50 | final String callback = c.get(0); 51 | header = ChannelBuffers.wrappedBuffer(HEADER_PART1, ChannelBuffers.copiedBuffer(callback, CharsetUtil.UTF_8), HEADER_PART2); 52 | 53 | super.messageReceived(ctx, e); 54 | } 55 | 56 | @Override 57 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 58 | if (e.getMessage() instanceof Frame) { 59 | final Frame frame = (Frame) e.getMessage(); 60 | if (headerSent.compareAndSet(false, true)) { 61 | HttpResponse response = createResponse(CONTENT_TYPE_HTML); 62 | response.setHeader(CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"); 63 | 64 | // Safari needs at least 1024 bytes to parse the website. Relevant: 65 | // http://code.google.com/p/browsersec/wiki/Part2#Survey_of_content_sniffing_behaviors 66 | int spaces = 1024 - header.readableBytes(); 67 | ChannelBuffer paddedHeader = ChannelBuffers.buffer(1024 + 50); 68 | 69 | paddedHeader.writeBytes(header); 70 | for (int i = 0; i < spaces + 20; i++) { 71 | paddedHeader.writeByte(' '); 72 | } 73 | paddedHeader.writeByte('\r'); 74 | paddedHeader.writeByte('\n'); 75 | // Opera needs one more new line at the start. 76 | paddedHeader.writeByte('\r'); 77 | paddedHeader.writeByte('\n'); 78 | 79 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), response, e.getRemoteAddress())); 80 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), new DefaultHttpChunk(paddedHeader), e.getRemoteAddress())); 81 | } 82 | 83 | final ChannelBuffer frameContent = Frame.encode(frame, false); 84 | final ChannelBuffer content = ChannelBuffers.dynamicBuffer(frameContent.readableBytes() + 10); 85 | 86 | Frame.escapeJson(frameContent, content); 87 | ChannelBuffer wrappedContent = ChannelBuffers.wrappedBuffer(PREFIX, content, POSTFIX); 88 | ctx.sendDownstream(new DownstreamMessageEvent(e.getChannel(), e.getFuture(), new DefaultHttpChunk(wrappedContent), e.getRemoteAddress())); 89 | 90 | logResponseSize(e.getChannel(), content); 91 | } else { 92 | super.writeRequested(ctx, e); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/XhrSendTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.cgbystrom.sockjs.Service; 4 | import com.cgbystrom.sockjs.SessionHandler; 5 | import com.cgbystrom.sockjs.SockJsMessage; 6 | import org.codehaus.jackson.JsonParseException; 7 | import org.codehaus.jackson.map.ObjectMapper; 8 | import org.jboss.netty.channel.*; 9 | import org.jboss.netty.handler.codec.http.*; 10 | import org.jboss.netty.logging.InternalLogger; 11 | import org.jboss.netty.logging.InternalLoggerFactory; 12 | import org.jboss.netty.util.CharsetUtil; 13 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; 14 | import static org.jboss.netty.handler.codec.http.HttpResponseStatus.*; 15 | 16 | import java.util.List; 17 | 18 | public class XhrSendTransport extends SimpleChannelUpstreamHandler { 19 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(XhrSendTransport.class); 20 | private static final ObjectMapper MAPPER = new ObjectMapper(); 21 | 22 | private boolean isJsonpEnabled = false; 23 | private TransportMetrics transportMetrics; 24 | 25 | public XhrSendTransport(Service.Metrics metrics, boolean isJsonpEnabled) { 26 | this.isJsonpEnabled = isJsonpEnabled; 27 | this.transportMetrics = metrics.getXhrSend(); 28 | } 29 | 30 | @Override 31 | public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 32 | // Overridden method to prevent propagation of channel state event upstream. 33 | } 34 | 35 | @Override 36 | public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 37 | // Overridden method to prevent propagation of channel state event upstream. 38 | transportMetrics.connectionsOpen.inc(); 39 | transportMetrics.connectionsOpened.mark(); 40 | } 41 | 42 | @Override 43 | public void channelDisconnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 44 | // Overridden method to prevent propagation of channel state event upstream. 45 | transportMetrics.connectionsOpen.dec(); 46 | } 47 | 48 | @Override 49 | public void channelClosed(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 50 | // Overridden method to prevent propagation of channel state event upstream. 51 | } 52 | 53 | @Override 54 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 55 | HttpRequest request = (HttpRequest)e.getMessage(); 56 | 57 | if (request.getContent().readableBytes() == 0) { 58 | BaseTransport.respond(e.getChannel(), INTERNAL_SERVER_ERROR, "Payload expected."); 59 | return; 60 | } 61 | 62 | transportMetrics.messagesReceived.mark(); 63 | transportMetrics.messagesReceivedSize.update(request.getContent().readableBytes()); 64 | 65 | //logger.debug("Received {}", request.getContent().toString(CharsetUtil.UTF_8)); 66 | 67 | String contentTypeHeader = request.getHeader(CONTENT_TYPE); 68 | if (contentTypeHeader == null) { 69 | contentTypeHeader = BaseTransport.CONTENT_TYPE_PLAIN; 70 | } 71 | 72 | String decodedContent; 73 | if (BaseTransport.CONTENT_TYPE_FORM.equals(contentTypeHeader)) { 74 | QueryStringDecoder decoder = new QueryStringDecoder("?" + request.getContent().toString(CharsetUtil.UTF_8)); 75 | List d = decoder.getParameters().get("d"); 76 | if (d == null) { 77 | BaseTransport.respond(e.getChannel(), INTERNAL_SERVER_ERROR, "Payload expected."); 78 | return; 79 | } 80 | decodedContent = d.get(0); 81 | } else { 82 | decodedContent = request.getContent().toString(CharsetUtil.UTF_8); 83 | } 84 | 85 | if (decodedContent.length() == 0) { 86 | BaseTransport.respond(e.getChannel(), INTERNAL_SERVER_ERROR, "Payload expected."); 87 | return; 88 | } 89 | 90 | String[] messages = MAPPER.readValue(decodedContent, String[].class); 91 | for (String message : messages) { 92 | SockJsMessage jsMessage = new SockJsMessage(message); 93 | ctx.sendUpstream(new UpstreamMessageEvent(e.getChannel(), jsMessage, e.getRemoteAddress())); 94 | } 95 | 96 | if (isJsonpEnabled) { 97 | BaseTransport.respond(e.getChannel(), OK, "ok"); 98 | } else { 99 | BaseTransport.respond(e.getChannel(), NO_CONTENT, ""); 100 | } 101 | } 102 | 103 | @Override 104 | public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { 105 | if (e.getCause() instanceof JsonParseException) { 106 | BaseTransport.respond(e.getChannel(), HttpResponseStatus.INTERNAL_SERVER_ERROR, "Broken JSON encoding."); 107 | } else if (e.getCause() instanceof SessionHandler.NotFoundException) { 108 | BaseTransport.respond(e.getChannel(), HttpResponseStatus.NOT_FOUND, "Session not found. Cannot send data to non-existing session."); 109 | } else { 110 | super.exceptionCaught(ctx, e); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/client/WebSocketClient.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.client; 2 | 3 | import com.cgbystrom.sockjs.Frame; 4 | import com.cgbystrom.sockjs.Session; 5 | import com.cgbystrom.sockjs.SessionCallback; 6 | import com.cgbystrom.sockjs.SockJsMessage; 7 | import org.jboss.netty.bootstrap.ClientBootstrap; 8 | import org.jboss.netty.buffer.ChannelBuffer; 9 | import org.jboss.netty.channel.*; 10 | import org.jboss.netty.channel.local.LocalAddress; 11 | import org.jboss.netty.channel.local.LocalClientChannelFactory; 12 | import org.jboss.netty.handler.codec.http.*; 13 | import org.jboss.netty.handler.codec.http.websocketx.TextWebSocketFrame; 14 | import org.jboss.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; 15 | import org.jboss.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; 16 | import org.jboss.netty.handler.codec.http.websocketx.WebSocketVersion; 17 | 18 | import java.net.InetSocketAddress; 19 | import java.net.URI; 20 | import java.net.URISyntaxException; 21 | import java.util.List; 22 | import java.util.UUID; 23 | 24 | public class WebSocketClient extends SockJsClient implements Session { 25 | private ClientBootstrap bootstrap; 26 | private Channel channel; 27 | private String sessionId; 28 | private URI uri; 29 | private SessionCallback callback; 30 | private WebSocketClientHandshaker wsHandshaker; 31 | private boolean sockJsHandshakeDone = false; 32 | 33 | public WebSocketClient(ClientBootstrap bootstrap, URI uri, SessionCallback callback) { 34 | this.bootstrap = bootstrap; 35 | this.uri = uri; 36 | this.callback = callback; 37 | } 38 | 39 | @Override 40 | public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 41 | channel = e.getChannel(); 42 | sendWebSocketHandshake(); 43 | super.channelConnected(ctx, e); 44 | } 45 | 46 | @Override 47 | public void channelDisconnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 48 | channel = null; 49 | callback.onClose(); 50 | } 51 | 52 | @Override 53 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 54 | if (!wsHandshaker.isHandshakeComplete()) { 55 | wsHandshaker.finishHandshake(e.getChannel(), (HttpResponse) e.getMessage()); 56 | callback.onOpen(WebSocketClient.this); 57 | return; 58 | } 59 | 60 | if (e.getMessage() instanceof TextWebSocketFrame) { 61 | TextWebSocketFrame wf = (TextWebSocketFrame) e.getMessage(); 62 | String frame = wf.getText(); 63 | char frameType = frame.charAt(0); 64 | 65 | if (!sockJsHandshakeDone) { 66 | if (frameType == 'o') { 67 | sockJsHandshakeDone = true; 68 | } else { 69 | throw new IllegalStateException("Expected open frame 'o' as first frame. Got " + frame); 70 | } 71 | return; 72 | } 73 | 74 | switch (frameType) { 75 | case 'h': 76 | break; 77 | 78 | case 'a': 79 | try { 80 | List messages = objectMapper.readValue(frame.substring(1), List.class); 81 | for (String msg : messages) { 82 | try { 83 | callback.onMessage(msg); 84 | } catch (Exception ex) { 85 | callback.onError(ex); 86 | } 87 | } 88 | } catch (Exception ex) { 89 | throw new IllegalArgumentException("Unable to decode frame: " + frame); 90 | } 91 | break; 92 | 93 | case 'c': 94 | disconnect(); 95 | break; 96 | 97 | default: 98 | throw new IllegalArgumentException("Received unknown frame type '" + frameType + "'"); 99 | } 100 | } 101 | } 102 | 103 | @Override 104 | public ChannelFuture connect() throws URISyntaxException { 105 | this.sessionId = UUID.randomUUID().toString(); 106 | URI sockJsUri = new URI("http", uri.getUserInfo(), uri.getHost(), uri.getPort(), 107 | uri.getPath() + "/999/" + sessionId + "/websocket", uri.getQuery(), uri.getFragment()); 108 | 109 | this.wsHandshaker = new WebSocketClientHandshakerFactory().newHandshaker( 110 | sockJsUri, WebSocketVersion.V13, null, false, null); 111 | 112 | if (bootstrap.getFactory() instanceof LocalClientChannelFactory) { 113 | return bootstrap.connect(new LocalAddress(getPort(uri))); 114 | } else { 115 | return bootstrap.connect(new InetSocketAddress(uri.getHost(), getPort(uri))); 116 | } 117 | } 118 | 119 | @Override 120 | public ChannelFuture disconnect() { 121 | return channel.close(); 122 | } 123 | 124 | @Override 125 | public void send(String message) { 126 | ChannelBuffer cb = Frame.messageFrame(new SockJsMessage(message)).getData(); 127 | cb.readerIndex(1); // Skip the framing char 128 | channel.write(new TextWebSocketFrame(cb)); 129 | } 130 | 131 | @Override 132 | public void close() { 133 | disconnect(); 134 | } 135 | 136 | @Override 137 | public String getId() { 138 | throw new RuntimeException("Not implemented!"); 139 | } 140 | 141 | private void sendWebSocketHandshake() throws Exception { 142 | wsHandshaker.handshake(channel); 143 | } 144 | 145 | private int getPort(URI uri) throws URISyntaxException { 146 | if ("http".equals(uri.getScheme()) && uri.getPort() == -1) { 147 | return 80; 148 | } else if ("https".equals(uri.getScheme()) && uri.getPort() == -1) { 149 | return 443; 150 | } 151 | 152 | return uri.getPort(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/BaseTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | import com.cgbystrom.sockjs.SessionHandler; 4 | import com.cgbystrom.sockjs.Frame; 5 | import org.jboss.netty.buffer.ChannelBuffer; 6 | import org.jboss.netty.buffer.ChannelBuffers; 7 | import org.jboss.netty.channel.*; 8 | import org.jboss.netty.handler.codec.http.*; 9 | import org.jboss.netty.handler.timeout.IdleState; 10 | import org.jboss.netty.handler.timeout.IdleStateAwareChannelHandler; 11 | import org.jboss.netty.handler.timeout.IdleStateEvent; 12 | import org.jboss.netty.logging.InternalLogger; 13 | import org.jboss.netty.logging.InternalLoggerFactory; 14 | import org.jboss.netty.util.CharsetUtil; 15 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; 16 | 17 | import java.util.Set; 18 | 19 | public class BaseTransport extends IdleStateAwareChannelHandler { 20 | public static final String CONTENT_TYPE_JAVASCRIPT = "application/javascript; charset=UTF-8"; 21 | public static final String CONTENT_TYPE_FORM = "application/x-www-form-urlencoded"; 22 | public static final String CONTENT_TYPE_PLAIN = "text/plain; charset=UTF-8"; 23 | public static final String CONTENT_TYPE_HTML = "text/html; charset=UTF-8"; 24 | 25 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(BaseTransport.class); 26 | private static final CookieDecoder COOKIE_DECODER = new CookieDecoder(); 27 | private static final String JSESSIONID = "JSESSIONID"; 28 | private static final String DEFAULT_COOKIE = "JSESSIONID=dummy; path=/"; 29 | 30 | protected String cookie = DEFAULT_COOKIE; 31 | protected TransportMetrics transportMetrics; 32 | 33 | /** Save a reference to the initating HTTP request */ 34 | protected HttpRequest request; 35 | 36 | public BaseTransport(TransportMetrics transportMetrics) { 37 | this.transportMetrics = transportMetrics; 38 | } 39 | 40 | public static void respond(Channel channel, HttpResponseStatus status, String message) throws Exception { 41 | // TODO: Why aren't response data defined in SockJS for error messages? 42 | HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_0, status); 43 | response.setHeader(CONTENT_TYPE, "text/plain; charset=UTF-8"); 44 | 45 | final ChannelBuffer buffer = ChannelBuffers.copiedBuffer(message, CharsetUtil.UTF_8); 46 | response.setContent(buffer); 47 | response.setHeader(CONTENT_LENGTH, buffer.readableBytes()); 48 | response.setHeader(SET_COOKIE, "JSESSIONID=dummy; path=/"); // FIXME: Don't sprinkle cookies in every request 49 | response.setHeader(CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"); 50 | response.setHeader("Access-Control-Allow-Origin", "*"); 51 | response.setHeader("Access-Control-Allow-Credentials", "true"); 52 | 53 | if (channel.isWritable()) 54 | channel.write(response).addListener(ChannelFutureListener.CLOSE); 55 | } 56 | 57 | @Override 58 | public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 59 | // Overridden method to prevent propagation of channel state event upstream. 60 | } 61 | 62 | @Override 63 | public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 64 | // Overridden method to prevent propagation of channel state event upstream. 65 | } 66 | 67 | @Override 68 | public void channelDisconnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 69 | // Metrics for connect is handled by ServiceRouter. 70 | transportMetrics.connectionsOpen.dec(); 71 | super.channelDisconnected(ctx, e); 72 | } 73 | 74 | @Override 75 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 76 | request = (HttpRequest) e.getMessage(); 77 | handleCookie(request); 78 | 79 | // Since we have silenced the usual channel state events for open and connected for the socket, 80 | // we must notify handlers downstream to now consider this connection connected. 81 | // We are responsible for manually dispatching this event upstream 82 | ctx.sendUpstream(new UpstreamChannelStateEvent(e.getChannel(), ChannelState.CONNECTED, Boolean.TRUE)); 83 | } 84 | 85 | @Override 86 | public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { 87 | if (e.getCause() instanceof SessionHandler.NotFoundException) { 88 | respond(e.getChannel(), HttpResponseStatus.NOT_FOUND, "Session not found."); 89 | } else if (e.getCause() instanceof SessionHandler.LockException) { 90 | if (e.getChannel().isWritable()) { 91 | e.getChannel().write(Frame.closeFrame(2010, "Another connection still open")).addListener(ChannelFutureListener.CLOSE); 92 | } 93 | } else { 94 | super.exceptionCaught(ctx, e); 95 | } 96 | } 97 | 98 | protected HttpResponse createResponse(String contentType) { 99 | final HttpVersion version = request.getProtocolVersion(); 100 | HttpResponse response = new DefaultHttpResponse(version, HttpResponseStatus.OK); 101 | response.setHeader(CONTENT_TYPE, contentType); 102 | response.setHeader(CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"); 103 | response.setHeader("Access-Control-Allow-Origin", "*"); 104 | response.setHeader("Access-Control-Allow-Credentials", "true"); 105 | response.setHeader(SET_COOKIE, cookie); // FIXME: Check if cookies are enabled 106 | return response; 107 | } 108 | 109 | protected void handleCookie(HttpRequest request) { 110 | // FIXME: Check if cookies are enabled in the server 111 | cookie = DEFAULT_COOKIE; 112 | String cookieHeader = request.getHeader(COOKIE); 113 | if (cookieHeader != null) { 114 | Set cookies = COOKIE_DECODER.decode(cookieHeader); 115 | for (Cookie c : cookies) { 116 | if (c.getName().equals(JSESSIONID)) { 117 | c.setPath("/"); 118 | CookieEncoder cookieEncoder = new CookieEncoder(true); 119 | cookieEncoder.addCookie(c); 120 | cookie = cookieEncoder.encode(); 121 | } 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/Service.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import com.cgbystrom.sockjs.transports.*; 4 | import com.codahale.metrics.MetricRegistry; 5 | import org.jboss.netty.util.Timer; 6 | 7 | import java.util.concurrent.ConcurrentHashMap; 8 | 9 | import static com.cgbystrom.sockjs.SessionHandler.NotFoundException; 10 | 11 | public class Service { 12 | private String url; 13 | private SessionCallbackFactory factory; 14 | private ConcurrentHashMap sessions = new ConcurrentHashMap(); 15 | private boolean isWebSocketEnabled = true; 16 | private int maxResponseSize = 128 * 1024; 17 | private boolean cookieNeeded = false; 18 | private Timer timer; 19 | /** Timeout for when to kill sessions that have not received a connection */ 20 | private int sessionTimeout = 5; // seconds 21 | private int heartbeatInterval = 25 * 1000; // milliseconds 22 | private MetricRegistry metricRegistry; 23 | private Metrics metrics; 24 | 25 | public Service(String url, SessionCallbackFactory factory) { 26 | this.url = url; 27 | this.factory = factory; 28 | } 29 | 30 | public Service(String url, final SessionCallback session) { 31 | this(url, new SessionCallbackFactory() { 32 | @Override 33 | public SessionCallback getSession(String id) throws Exception { 34 | return session; 35 | } 36 | }); 37 | } 38 | 39 | public String getUrl() { 40 | return url; 41 | } 42 | 43 | public boolean isWebSocketEnabled() { 44 | return isWebSocketEnabled; 45 | } 46 | 47 | public Service setWebSocketEnabled(boolean webSocketEnabled) { 48 | isWebSocketEnabled = webSocketEnabled; 49 | return this; 50 | } 51 | 52 | public int getMaxResponseSize() { 53 | return maxResponseSize; 54 | } 55 | 56 | public Service setMaxResponseSize(int maxResponseSize) { 57 | this.maxResponseSize = maxResponseSize; 58 | return this; 59 | } 60 | 61 | public boolean isCookieNeeded() { 62 | return cookieNeeded; 63 | } 64 | 65 | public Service setCookieNeeded(boolean cookieNeeded) { 66 | this.cookieNeeded = cookieNeeded; 67 | return this; 68 | } 69 | 70 | public Timer getTimer() { 71 | return timer; 72 | } 73 | 74 | public void setTimer(Timer timer) { 75 | this.timer = timer; 76 | } 77 | 78 | public int getSessionTimeout() { 79 | return sessionTimeout; 80 | } 81 | 82 | public void setSessionTimeout(int sessionTimeout) { 83 | this.sessionTimeout = sessionTimeout; 84 | } 85 | 86 | public int getHeartbeatInterval() { 87 | return heartbeatInterval; 88 | } 89 | 90 | public void setHeartbeatInterval(int heartbeatInterval) { 91 | this.heartbeatInterval = heartbeatInterval; 92 | } 93 | 94 | public MetricRegistry getMetricRegistry() { 95 | return metricRegistry; 96 | } 97 | 98 | public void setMetricRegistry(MetricRegistry metricRegistry) { 99 | this.metricRegistry = metricRegistry; 100 | } 101 | 102 | public Metrics getMetrics() { 103 | if (metrics == null) { 104 | metrics = new Metrics("com.cgbystrom.sockjs.transports", metricRegistry); 105 | } 106 | return metrics; 107 | } 108 | 109 | public void setMetrics(Metrics metrics) { 110 | this.metrics = metrics; 111 | } 112 | 113 | public synchronized SessionHandler getOrCreateSession(String sessionId, TransportMetrics tm, 114 | boolean forceCreate) throws Exception { 115 | SessionHandler s = sessions.get(sessionId); 116 | 117 | if (s != null && !forceCreate) { 118 | return s; 119 | } 120 | 121 | SessionCallback callback = factory.getSession(sessionId); 122 | SessionHandler newSession = new SessionHandler(sessionId, callback, this, tm); 123 | SessionHandler existingSession = sessions.putIfAbsent(sessionId, newSession); 124 | return (existingSession == null) ? newSession : existingSession; 125 | } 126 | 127 | public synchronized SessionHandler getSession(String sessionId) throws NotFoundException { 128 | SessionHandler s = sessions.get(sessionId); 129 | 130 | if (s == null) { 131 | throw new NotFoundException(url, sessionId); 132 | } 133 | 134 | return s; 135 | } 136 | 137 | public synchronized SessionHandler destroySession(String sessionId) { 138 | return sessions.remove(sessionId); 139 | } 140 | 141 | public static class Metrics { 142 | final TransportMetrics eventSource; 143 | final TransportMetrics htmlFile; 144 | final TransportMetrics jsonp; 145 | final TransportMetrics rawWebSocket; 146 | final TransportMetrics webSocket; 147 | final TransportMetrics xhrPolling; 148 | final TransportMetrics xhrSend; 149 | final TransportMetrics xhrStreaming; 150 | 151 | public Metrics(String prefix, MetricRegistry metricRegistry) { 152 | eventSource = new TransportMetrics(prefix, "eventSource", metricRegistry); 153 | htmlFile = new TransportMetrics(prefix, "htmlFile", metricRegistry); 154 | jsonp = new TransportMetrics(prefix, "jsonp", metricRegistry); 155 | rawWebSocket = new TransportMetrics(prefix, "rawWebSocket", metricRegistry); 156 | webSocket = new TransportMetrics(prefix, "webSocket", metricRegistry); 157 | xhrPolling = new TransportMetrics(prefix, "xhrPolling", metricRegistry); 158 | xhrSend = new TransportMetrics(prefix, "xhrSend", metricRegistry); 159 | xhrStreaming = new TransportMetrics(prefix, "xhrStreaming", metricRegistry); 160 | } 161 | 162 | public TransportMetrics getEventSource() { 163 | return eventSource; 164 | } 165 | 166 | public TransportMetrics getHtmlFile() { 167 | return htmlFile; 168 | } 169 | 170 | public TransportMetrics getJsonp() { 171 | return jsonp; 172 | } 173 | 174 | public TransportMetrics getRawWebSocket() { 175 | return rawWebSocket; 176 | } 177 | 178 | public TransportMetrics getWebSocket() { 179 | return webSocket; 180 | } 181 | 182 | public TransportMetrics getXhrPolling() { 183 | return xhrPolling; 184 | } 185 | 186 | public TransportMetrics getXhrSend() { 187 | return xhrSend; 188 | } 189 | 190 | public TransportMetrics getXhrStreaming() { 191 | return xhrStreaming; 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/RawWebSocketTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | 4 | import com.cgbystrom.sockjs.*; 5 | import org.codehaus.jackson.JsonParseException; 6 | import org.codehaus.jackson.map.JsonMappingException; 7 | import org.jboss.netty.channel.*; 8 | import org.jboss.netty.handler.codec.http.*; 9 | import org.jboss.netty.handler.codec.http.websocketx.*; 10 | import org.jboss.netty.logging.InternalLogger; 11 | import org.jboss.netty.logging.InternalLoggerFactory; 12 | 13 | import java.io.IOException; 14 | 15 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names; 16 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; 17 | import static org.jboss.netty.handler.codec.http.HttpHeaders.isKeepAlive; 18 | import static org.jboss.netty.handler.codec.http.HttpMethod.GET; 19 | import static org.jboss.netty.handler.codec.http.HttpResponseStatus.METHOD_NOT_ALLOWED; 20 | import static org.jboss.netty.handler.codec.http.HttpVersion.HTTP_1_1; 21 | 22 | public class RawWebSocketTransport extends SimpleChannelHandler { 23 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(RawWebSocketTransport.class); 24 | 25 | private WebSocketServerHandshaker handshaker; 26 | private final String path; 27 | 28 | public RawWebSocketTransport(String path) { 29 | this.path = path; 30 | } 31 | 32 | @Override 33 | public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 34 | // Overridden method to prevent propagation of channel state event upstream. 35 | } 36 | 37 | @Override 38 | public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 39 | // Overridden method to prevent propagation of channel state event upstream. 40 | } 41 | 42 | @Override 43 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 44 | Object msg = e.getMessage(); 45 | if (msg instanceof HttpRequest) { 46 | handleHttpRequest(ctx, e.getChannel(), (HttpRequest) msg); 47 | } else if (msg instanceof WebSocketFrame) { 48 | handleWebSocketFrame(ctx, e.getChannel(), (WebSocketFrame) msg); 49 | } else { 50 | logger.error("Unknown frame type: " + e.getMessage()); 51 | } 52 | } 53 | 54 | @Override 55 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 56 | if (e.getMessage() instanceof Frame) { 57 | if (e.getMessage() instanceof Frame.MessageFrame) { 58 | Frame.MessageFrame f = (Frame.MessageFrame) e.getMessage(); 59 | logger.debug("Write requested for " + f.getClass().getSimpleName()); 60 | for (SockJsMessage m : f.getMessages()) { 61 | TextWebSocketFrame message = new TextWebSocketFrame(m.getMessage()); 62 | super.writeRequested(ctx, new DownstreamMessageEvent(e.getChannel(), e.getFuture(), message, e.getRemoteAddress())); 63 | } 64 | } else if (e.getMessage() instanceof Frame.CloseFrame) { 65 | // FIXME: Should really send close frame here? 66 | // handshaker.close(e.getChannel(), new CloseWebSocketFrame()); ? 67 | e.getChannel().close(); 68 | } else if (e.getMessage() instanceof Frame.OpenFrame) { 69 | logger.debug("Open frame silenced"); 70 | } else { 71 | throw new RuntimeException("Unknown frame: " + e.getMessage()); 72 | } 73 | } else { 74 | super.writeRequested(ctx, e); 75 | } 76 | } 77 | 78 | @Override 79 | public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { 80 | // FIXME: Move to BaseTransport 81 | if (e.getCause() instanceof SessionHandler.NotFoundException) { 82 | BaseTransport.respond(e.getChannel(), HttpResponseStatus.NOT_FOUND, "Session not found."); 83 | } else if (e.getCause() instanceof SessionHandler.LockException) { 84 | if (e.getChannel().isWritable()) { 85 | e.getChannel().write(Frame.closeFrame(2010, "Another connection still open")); 86 | } 87 | } else if (e.getCause() instanceof JsonParseException || e.getCause() instanceof JsonMappingException) { 88 | //NotFoundHandler.respond(e.getChannel(), HttpResponseStatus.INTERNAL_SERVER_ERROR, "Broken JSON encoding."); 89 | e.getChannel().close(); 90 | } else if (e.getCause() instanceof WebSocketHandshakeException) { 91 | if (e.getCause().getMessage().contains("missing upgrade")) { 92 | BaseTransport.respond(e.getChannel(), HttpResponseStatus.BAD_REQUEST, "Can \"Upgrade\" only to \"WebSocket\"."); 93 | } 94 | } else { 95 | super.exceptionCaught(ctx, e); 96 | } 97 | } 98 | 99 | private void handleHttpRequest(final ChannelHandlerContext ctx, final Channel channel, HttpRequest req) throws Exception { 100 | // Allow only GET methods. 101 | if (req.getMethod() != GET) { 102 | DefaultHttpResponse response = new DefaultHttpResponse(HTTP_1_1, METHOD_NOT_ALLOWED); 103 | response.addHeader(ALLOW, GET.toString()); 104 | sendHttpResponse(ctx, req, response); 105 | return; 106 | } 107 | 108 | // Handshake 109 | WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(getWebSocketLocation(req), "chat, superchat", false); 110 | 111 | handshaker = wsFactory.newHandshaker(req); 112 | if (handshaker == null) { 113 | wsFactory.sendUnsupportedWebSocketVersionResponse(ctx.getChannel()); 114 | } else { 115 | handshaker.handshake(ctx.getChannel(), req).addListener(new ChannelFutureListener() { 116 | @Override 117 | public void operationComplete(ChannelFuture future) throws Exception { 118 | if (future.isSuccess()) { 119 | ctx.getPipeline().remove(ServiceRouter.class); 120 | ctx.getPipeline().remove(PreflightHandler.class); 121 | ctx.sendUpstream(new UpstreamChannelStateEvent(channel, ChannelState.CONNECTED, Boolean.TRUE)); 122 | } 123 | } 124 | }); 125 | } 126 | } 127 | 128 | private void handleWebSocketFrame(ChannelHandlerContext ctx, Channel channel, WebSocketFrame frame) throws IOException { 129 | // Check for closing frame 130 | if (frame instanceof CloseWebSocketFrame) { 131 | handshaker.close(ctx.getChannel(), (CloseWebSocketFrame) frame); 132 | return; 133 | } else if (frame instanceof PingWebSocketFrame) { 134 | ctx.getChannel().write(new PongWebSocketFrame(frame.getBinaryData())); 135 | return; 136 | } else if (!(frame instanceof TextWebSocketFrame)) { 137 | throw new UnsupportedOperationException(String.format("%s frame types not supported", frame.getClass().getName())); 138 | } 139 | 140 | String request = ((TextWebSocketFrame) frame).getText(); 141 | logger.debug(String.format("Channel %s received '%s'", ctx.getChannel().getId(), request)); 142 | 143 | SockJsMessage jsMessage = new SockJsMessage(request); 144 | ctx.sendUpstream(new UpstreamMessageEvent(channel, jsMessage, channel.getRemoteAddress())); 145 | } 146 | 147 | private void sendHttpResponse(ChannelHandlerContext ctx, HttpRequest req, HttpResponse res) { 148 | // Generate an error page if response status code is not OK (200). 149 | if (res.getStatus().getCode() != 200) { 150 | //res.setContent(ChannelBuffers.copiedBuffer(res.getStatus().toString(), CharsetUtil.UTF_8)); 151 | //setContentLength(res, res.getContent().readableBytes()); 152 | } 153 | 154 | // Send the response and close the connection if necessary. 155 | ChannelFuture f = ctx.getChannel().write(res); 156 | if (!isKeepAlive(req) || res.getStatus().getCode() != 200) { 157 | f.addListener(ChannelFutureListener.CLOSE); 158 | } 159 | } 160 | 161 | private String getWebSocketLocation(HttpRequest req) { 162 | // FIXME: Handle SSL and non-standard HTTP port? 163 | return "ws://" + req.getHeader(Names.HOST) + path; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/Frame.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import org.codehaus.jackson.io.JsonStringEncoder; 4 | import org.jboss.netty.buffer.ChannelBuffer; 5 | import org.jboss.netty.buffer.ChannelBuffers; 6 | import org.jboss.netty.util.CharsetUtil; 7 | 8 | public abstract class Frame { 9 | private static final OpenFrame OPEN_FRAME_OBJ = new OpenFrame(); 10 | private static final ChannelBuffer OPEN_FRAME = ChannelBuffers.copiedBuffer("o", CharsetUtil.UTF_8); 11 | private static final ChannelBuffer OPEN_FRAME_NL = ChannelBuffers.copiedBuffer("o\n", CharsetUtil.UTF_8); 12 | private static final HeartbeatFrame HEARTBEAT_FRAME_OBJ = new HeartbeatFrame(); 13 | private static final ChannelBuffer HEARTBEAT_FRAME = ChannelBuffers.copiedBuffer("h", CharsetUtil.UTF_8); 14 | private static final ChannelBuffer HEARTBEAT_FRAME_NL = ChannelBuffers.copiedBuffer("h\n", CharsetUtil.UTF_8); 15 | private static final PreludeFrame PRELUDE_FRAME_OBJ = new PreludeFrame(); 16 | private static final ChannelBuffer PRELUDE_FRAME = generatePreludeFrame('h', 2048, false); 17 | private static final ChannelBuffer PRELUDE_FRAME_NL = generatePreludeFrame('h', 2048, true); 18 | private static final ChannelBuffer NEW_LINE = ChannelBuffers.copiedBuffer("\n", CharsetUtil.UTF_8); 19 | 20 | protected ChannelBuffer data; 21 | 22 | public ChannelBuffer getData() { 23 | return data; 24 | } 25 | 26 | public static OpenFrame openFrame() { 27 | return OPEN_FRAME_OBJ; 28 | } 29 | 30 | public static CloseFrame closeFrame(int status, String reason) { 31 | return new CloseFrame(status, reason); 32 | } 33 | 34 | public static HeartbeatFrame heartbeatFrame() { 35 | return HEARTBEAT_FRAME_OBJ; 36 | } 37 | 38 | /** Used by XHR streaming */ 39 | public static PreludeFrame preludeFrame() { 40 | return PRELUDE_FRAME_OBJ; 41 | } 42 | 43 | public static MessageFrame messageFrame(SockJsMessage... messages) { 44 | return new MessageFrame(messages); 45 | } 46 | 47 | public static ChannelBuffer encode(Frame frame, boolean appendNewline) { 48 | if (frame instanceof OpenFrame) { 49 | return appendNewline ? OPEN_FRAME_NL : OPEN_FRAME; 50 | } else if (frame instanceof HeartbeatFrame) { 51 | return appendNewline ? HEARTBEAT_FRAME_NL : HEARTBEAT_FRAME; 52 | } else if (frame instanceof PreludeFrame) { 53 | return appendNewline ? PRELUDE_FRAME_NL : PRELUDE_FRAME; 54 | } else if (frame instanceof MessageFrame || frame instanceof CloseFrame) { 55 | return appendNewline ? ChannelBuffers.wrappedBuffer(frame.getData(), NEW_LINE) : frame.getData(); 56 | } else { 57 | throw new IllegalArgumentException("Unknown frame type passed: " + frame.getClass().getSimpleName()); 58 | } 59 | } 60 | 61 | private static ChannelBuffer generatePreludeFrame(char c, int num, boolean appendNewline) { 62 | ChannelBuffer cb = ChannelBuffers.buffer(num + 1); 63 | for (int i = 0; i < num; i++) { 64 | cb.writeByte(c); 65 | } 66 | if (appendNewline) 67 | cb.writeByte('\n'); 68 | return cb; 69 | } 70 | 71 | public static String escapeCharacters(char[] value) { 72 | StringBuilder buffer = new StringBuilder(); 73 | for (int i = 0; i < value.length; i++) { 74 | char ch = value[i]; 75 | if ((ch >= '\u0000' && ch <= '\u001F') || 76 | (ch >= '\uD800' && ch <= '\uDFFF') || 77 | (ch >= '\u200C' && ch <= '\u200F') || 78 | (ch >= '\u2028' && ch <= '\u202F') || 79 | (ch >= '\u2060' && ch <= '\u206F') || 80 | (ch >= '\uFFF0' && ch <= '\uFFFF')) { 81 | String ss = Integer.toHexString(ch); 82 | buffer.append('\\'); 83 | buffer.append('u'); 84 | for (int k = 0; k < 4 - ss.length(); k++) { 85 | buffer.append('0'); 86 | } 87 | buffer.append(ss.toLowerCase()); 88 | } else { 89 | buffer.append(ch); 90 | } 91 | } 92 | return buffer.toString(); 93 | } 94 | 95 | 96 | public static void escapeJson(ChannelBuffer input, ChannelBuffer buffer) { 97 | for (int i = 0; i < input.readableBytes(); i++) { 98 | byte ch = input.getByte(i); 99 | switch(ch) { 100 | case '"': buffer.writeByte('\\'); buffer.writeByte('\"'); break; 101 | case '/': buffer.writeByte('\\'); buffer.writeByte('/'); break; 102 | case '\\': buffer.writeByte('\\'); buffer.writeByte('\\'); break; 103 | case '\b': buffer.writeByte('\\'); buffer.writeByte('b'); break; 104 | case '\f': buffer.writeByte('\\'); buffer.writeByte('f'); break; 105 | case '\n': buffer.writeByte('\\'); buffer.writeByte('n'); break; 106 | case '\r': buffer.writeByte('\\'); buffer.writeByte('r'); break; 107 | case '\t': buffer.writeByte('\\'); buffer.writeByte('t'); break; 108 | 109 | default: 110 | // Reference: http://www.unicode.org/versions/Unicode5.1.0/ 111 | if ((ch >= '\u0000' && ch <= '\u001F') || 112 | (ch >= '\uD800' && ch <= '\uDFFF') || 113 | (ch >= '\u200C' && ch <= '\u200F') || 114 | (ch >= '\u2028' && ch <= '\u202F') || 115 | (ch >= '\u2060' && ch <= '\u206F') || 116 | (ch >= '\uFFF0' && ch <= '\uFFFF')) { 117 | String ss = Integer.toHexString(ch); 118 | buffer.writeByte('\\'); 119 | buffer.writeByte('u'); 120 | for (int k = 0; k < 4 - ss.length(); k++) { 121 | buffer.writeByte('0'); 122 | } 123 | buffer.writeBytes(ss.toLowerCase().getBytes()); 124 | } else { 125 | buffer.writeByte(ch); 126 | } 127 | } 128 | } 129 | } 130 | 131 | public static class OpenFrame extends Frame { 132 | @Override 133 | public ChannelBuffer getData() { 134 | return OPEN_FRAME; 135 | } 136 | } 137 | 138 | public static class CloseFrame extends Frame { 139 | private int status; 140 | private String reason; 141 | 142 | private CloseFrame(int status, String reason) { 143 | this.status = status; 144 | this.reason = reason; 145 | // FIXME: Must escape status and reason 146 | data = ChannelBuffers.copiedBuffer("c[" + status + ",\"" + reason + "\"]", CharsetUtil.UTF_8); 147 | } 148 | 149 | public int getStatus() { 150 | return status; 151 | } 152 | 153 | public String getReason() { 154 | return reason; 155 | } 156 | } 157 | 158 | public static class MessageFrame extends Frame { 159 | private SockJsMessage[] messages; 160 | 161 | private MessageFrame(SockJsMessage... messages) { 162 | this.messages = messages; 163 | data = ChannelBuffers.dynamicBuffer(); 164 | data.writeByte('a'); 165 | data.writeByte('['); 166 | for (int i = 0; i < messages.length; i++) { 167 | SockJsMessage message = messages[i]; 168 | data.writeByte('"'); 169 | char[] escaped = new JsonStringEncoder().quoteAsString(message.getMessage()); 170 | data.writeBytes(ChannelBuffers.copiedBuffer(escapeCharacters(escaped), CharsetUtil.UTF_8)); 171 | data.writeByte('"'); 172 | if (i < messages.length - 1) { 173 | data.writeByte(','); 174 | } 175 | } 176 | 177 | data.writeByte(']'); 178 | } 179 | 180 | public SockJsMessage[] getMessages() { 181 | return messages; 182 | } 183 | } 184 | 185 | public static class HeartbeatFrame extends Frame { 186 | @Override 187 | public ChannelBuffer getData() { 188 | return HEARTBEAT_FRAME; 189 | } 190 | } 191 | 192 | public static class PreludeFrame extends Frame { 193 | @Override 194 | public ChannelBuffer getData() { 195 | return PRELUDE_FRAME; 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/SessionHandler.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import com.cgbystrom.sockjs.transports.TransportMetrics; 4 | import org.jboss.netty.channel.*; 5 | import org.jboss.netty.logging.InternalLogger; 6 | import org.jboss.netty.logging.InternalLoggerFactory; 7 | import org.jboss.netty.util.CharsetUtil; 8 | import org.jboss.netty.util.Timeout; 9 | import org.jboss.netty.util.TimerTask; 10 | 11 | import java.util.ArrayList; 12 | import java.util.LinkedList; 13 | import java.util.concurrent.TimeUnit; 14 | import java.util.concurrent.atomic.AtomicBoolean; 15 | 16 | /** 17 | * Responsible for handling SockJS sessions. 18 | * It is a stateful channel handler and tied to each session. 19 | * Only session specific logic and is unaware of underlying transport. 20 | * This is by design and Netty enables a clean way to do this through the pipeline and handlers. 21 | */ 22 | public class SessionHandler extends SimpleChannelHandler implements Session { 23 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(SessionHandler.class); 24 | public enum State { CONNECTING, OPEN, CLOSED, INTERRUPTED } 25 | 26 | private String id; 27 | private SessionCallback sessionCallback; 28 | private Channel channel; 29 | private State state = State.CONNECTING; 30 | private final LinkedList messageQueue = new LinkedList(); 31 | private final AtomicBoolean serverHasInitiatedClose = new AtomicBoolean(false); 32 | private Frame.CloseFrame closeReason; 33 | private Service service; 34 | private TransportMetrics transportMetrics; 35 | private Timeout sessionTimeout; 36 | 37 | protected SessionHandler(String id, SessionCallback sessionCallback, Service sm, 38 | TransportMetrics tm) { 39 | this.id = id; 40 | this.sessionCallback = sessionCallback; 41 | this.service = sm; 42 | this.transportMetrics = tm; 43 | if (logger.isDebugEnabled()) 44 | logger.debug("Session " + id + " created"); 45 | } 46 | 47 | @Override 48 | public synchronized void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 49 | if (logger.isDebugEnabled()) 50 | logger.debug("Session " + id + " connected " + e.getChannel()); 51 | 52 | // FIXME: Check if session has expired 53 | // FIXME: Check if session is locked (another handler already uses it), all but WS can do this 54 | 55 | 56 | if (state == State.CONNECTING) { 57 | serverHasInitiatedClose.set(false); 58 | setState(State.OPEN); 59 | closeReason = null; 60 | setChannel(e.getChannel()); 61 | e.getChannel().write(Frame.openFrame()); 62 | // FIXME: Ability to reject a connection here by returning false in callback to onOpen? 63 | sessionCallback.onOpen(this); 64 | // FIXME: Either start the heartbeat or flush pending messages in queue 65 | flush(); 66 | } else if (state == State.OPEN) { 67 | if (channel != null) { 68 | logger.debug("Session " + id + " already have a channel connected."); 69 | throw new LockException(e.getChannel()); 70 | } 71 | serverHasInitiatedClose.set(false); 72 | setChannel(e.getChannel()); 73 | logger.debug("Session " + id + " is open, flushing.."); 74 | flush(); 75 | } else if (state == State.CLOSED) { 76 | logger.debug("Session " + id + " is closed, go away."); 77 | final Frame.CloseFrame frame = closeReason == null ? Frame.closeFrame(3000, "Go away!") : closeReason; 78 | e.getChannel().write(frame); 79 | } else if (state == State.INTERRUPTED) { 80 | logger.debug("Session " + id + " has been interrupted by network error, cannot accept channel."); 81 | e.getChannel().write(Frame.closeFrame(1002, "Connection interrupted"));//.addListener(ChannelFutureListener.CLOSE); 82 | } else { 83 | throw new Exception("Invalid channel state: " + state); 84 | } 85 | } 86 | 87 | @Override 88 | public synchronized void closeRequested(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 89 | if (channel == e.getChannel()) { 90 | // This may be a bad practice of determining close initiator. 91 | // See http://stackoverflow.com/questions/8254060/how-to-know-if-a-channeldisconnected-comes-from-the-client-or-server-in-a-netty 92 | logger.debug("Session " + id + " requested close by server " + e.getChannel()); 93 | serverHasInitiatedClose.set(true); 94 | } 95 | super.closeRequested(ctx, e); 96 | } 97 | 98 | @Override 99 | public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 100 | if (e.getMessage() instanceof Frame) { 101 | Frame f = (Frame) e.getMessage(); 102 | String data = f.getData().toString(CharsetUtil.UTF_8); 103 | logger.debug("Session " + id + " for channel " + e.getChannel() + " sending: " + data); 104 | } 105 | super.writeRequested(ctx, e); 106 | } 107 | 108 | @Override 109 | public synchronized void channelClosed(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 110 | if (state == State.OPEN && !serverHasInitiatedClose.get()) { 111 | logger.debug("Session " + id + " underlying channel closed unexpectedly. Flagging session as interrupted." + e.getChannel()); 112 | setState(State.INTERRUPTED); 113 | } else { 114 | logger.debug("Session " + id + " underlying channel closed " + e.getChannel()); 115 | } 116 | // FIXME: Stop any heartbeat 117 | // FIXME: Timer to expire the connection? Should not close session here. 118 | // FIXME: Notify the sessionCallback? Unless timeout etc, disconnect it? 119 | unsetChannel(e.getChannel()); 120 | super.channelClosed(ctx, e); 121 | } 122 | 123 | @Override 124 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 125 | SockJsMessage msg = (SockJsMessage)e.getMessage(); 126 | logger.debug("Session " + id + " received message: " + msg.getMessage()); 127 | sessionCallback.onMessage(msg.getMessage()); 128 | } 129 | 130 | @Override 131 | public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { 132 | boolean isSilent = sessionCallback.onError(e.getCause()); 133 | if (!isSilent) { 134 | super.exceptionCaught(ctx, e); 135 | } 136 | } 137 | 138 | @Override 139 | public synchronized void send(String message) { 140 | final SockJsMessage msg = new SockJsMessage(message); 141 | // Check and see if we can send the message straight away 142 | if (channel != null && channel.isWritable() && messageQueue.isEmpty()) { 143 | channel.write(Frame.messageFrame(msg)); 144 | } else { 145 | messageQueue.addLast(msg); 146 | flush(); 147 | } 148 | } 149 | 150 | @Override 151 | public void close() { 152 | close(3000, "Go away!"); 153 | } 154 | 155 | @Override 156 | public String getId() { 157 | return id; 158 | } 159 | 160 | public synchronized void close(int code, String message) { 161 | if (state != State.CLOSED) { 162 | logger.debug("Session " + id + " server initiated close, closing..."); 163 | setState(State.CLOSED); 164 | 165 | if (channel != null && channel.isWritable()) { 166 | channel.write(Frame.closeFrame(code, message)); 167 | } 168 | 169 | // FIXME: Should we really call onClose here? Potentially calling it twice for same session close? 170 | try { 171 | sessionCallback.onClose(); 172 | } catch (Exception e) { 173 | if (sessionCallback.onError(e)) { 174 | throw new RuntimeException(e); 175 | } 176 | } 177 | } 178 | } 179 | 180 | public void setState(State state) { 181 | switch (state) { 182 | case OPEN: 183 | transportMetrics.sessionsOpen.inc(); 184 | transportMetrics.sessionsOpened.mark(); 185 | break; 186 | 187 | case CLOSED: 188 | case INTERRUPTED: 189 | if (this.state == State.OPEN) { 190 | transportMetrics.sessionsOpen.dec(); 191 | } 192 | 193 | } 194 | this.state = state; 195 | 196 | logger.debug("Session " + id + " state changed to " + state); 197 | } 198 | 199 | private void setChannel(Channel channel) { 200 | this.channel = channel; 201 | stopSessionTimeout(); 202 | logger.debug("Session " + id + " channel added"); 203 | } 204 | 205 | private synchronized void unsetChannel(Channel channel) { 206 | if (this.channel != channel && this.channel != null) { 207 | return; 208 | } 209 | this.channel = null; 210 | 211 | startSessionTimeout(); 212 | logger.debug("Session " + id + " channel removed. " + channel); 213 | } 214 | 215 | private synchronized void flush() { 216 | if (channel == null || !channel.isWritable()) { 217 | return; 218 | } 219 | 220 | if (!messageQueue.isEmpty()) { 221 | logger.debug("Session " + id + " flushing queue"); 222 | channel.write(Frame.messageFrame(new ArrayList(messageQueue).toArray(new SockJsMessage[messageQueue.size()]))); 223 | messageQueue.clear(); 224 | } 225 | } 226 | 227 | private void startSessionTimeout() { 228 | sessionTimeout = service.getTimer().newTimeout(new TimerTask() { 229 | @Override 230 | public void run(Timeout timeout) throws Exception { 231 | if (timeout.isCancelled()) { 232 | return; 233 | } 234 | logger.debug("Session " + id + " timed out. Closing and destroying..."); 235 | SessionHandler.this.close(1002, "Connection interrupted"); 236 | service.destroySession(id); 237 | } 238 | }, service.getSessionTimeout(), TimeUnit.SECONDS); 239 | } 240 | 241 | private void stopSessionTimeout() { 242 | if (sessionTimeout != null) { 243 | sessionTimeout.cancel(); 244 | } 245 | } 246 | 247 | public static class NotFoundException extends Exception { 248 | public NotFoundException(String baseUrl, String sessionId) { 249 | super("Session '" + sessionId + "' not found in sessionCallback '" + baseUrl + "'"); 250 | } 251 | } 252 | 253 | public static class LockException extends Exception { 254 | public LockException(Channel channel) { 255 | super("Session is locked by channel " + channel + ". Please disconnect other channel first before trying to register it with a session."); 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/ServiceRouter.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs; 2 | 3 | import com.cgbystrom.sockjs.transports.*; 4 | import com.codahale.metrics.MetricRegistry; 5 | import org.jboss.netty.buffer.ChannelBuffer; 6 | import org.jboss.netty.buffer.ChannelBuffers; 7 | import org.jboss.netty.channel.*; 8 | import org.jboss.netty.handler.codec.http.*; 9 | import org.jboss.netty.logging.InternalLogger; 10 | import org.jboss.netty.logging.InternalLoggerFactory; 11 | import org.jboss.netty.util.CharsetUtil; 12 | import org.jboss.netty.util.HashedWheelTimer; 13 | import org.jboss.netty.util.Timer; 14 | 15 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; 16 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Values.KEEP_ALIVE; 17 | 18 | import java.io.IOException; 19 | import java.util.LinkedHashMap; 20 | import java.util.Map; 21 | import java.util.Random; 22 | import java.util.regex.Matcher; 23 | import java.util.regex.Pattern; 24 | 25 | public class ServiceRouter extends SimpleChannelHandler { 26 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(ServiceRouter.class); 27 | private static final Pattern SERVER_SESSION = Pattern.compile("^/([^/.]+)/([^/.]+)/"); 28 | private static final String DEFAULT_CLIENT_URL = "http://cdn.sockjs.org/sockjs-0.3.4.min.js"; 29 | private static final Random RANDOM = new Random(); 30 | private enum SessionCreation { CREATE_OR_REUSE, FORCE_REUSE, FORCE_CREATE } 31 | 32 | private final Map services = new LinkedHashMap(); 33 | private IframePage iframe; 34 | private MetricRegistry metricRegistry = new MetricRegistry(); 35 | private Timer timer = new HashedWheelTimer(); 36 | 37 | public ServiceRouter() { 38 | setClientUrl(DEFAULT_CLIENT_URL); 39 | } 40 | 41 | public synchronized Service registerService(Service service) { 42 | services.put(service.getUrl(), service); 43 | 44 | if (service.getMetricRegistry() == null) { 45 | service.setMetricRegistry(metricRegistry); 46 | } 47 | 48 | if (service.getTimer() == null) { 49 | service.setTimer(timer); 50 | } 51 | 52 | return service; 53 | } 54 | 55 | public MetricRegistry getMetricRegistry() { 56 | return metricRegistry; 57 | } 58 | 59 | public void setMetricRegistry(MetricRegistry metricRegistry) { 60 | this.metricRegistry = metricRegistry; 61 | } 62 | 63 | /** 64 | * 65 | * @param clientUrl URL to SockJS JavaScript client. Needed by the iframe to properly load. 66 | * (Hint: SockJS has a CDN, http://cdn.sockjs.org/) 67 | */ 68 | public void setClientUrl(String clientUrl) { 69 | iframe = new IframePage(clientUrl); 70 | } 71 | 72 | @Override 73 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 74 | HttpRequest request = (HttpRequest)e.getMessage(); 75 | if (logger.isDebugEnabled()) 76 | logger.debug("URI " + request.getUri()); 77 | 78 | for (Service service : services.values()) { 79 | // Check if there's a service registered with this URL 80 | if (request.getUri().startsWith(service.getUrl())) { 81 | handleService(ctx, e, service); 82 | super.messageReceived(ctx, e); 83 | return; 84 | } 85 | } 86 | 87 | // No match for service found, return 404 88 | HttpResponse response = new DefaultHttpResponse(request.getProtocolVersion(), HttpResponseStatus.NOT_FOUND); 89 | response.setContent(ChannelBuffers.copiedBuffer("Not found", CharsetUtil.UTF_8)); 90 | writeResponse(e.getChannel(), request, response); 91 | } 92 | 93 | @Override 94 | public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { 95 | final String connectionClosedMsg = "An existing connection was forcibly closed by the remote host"; 96 | final Throwable t = e.getCause(); 97 | 98 | if (t instanceof IOException && t.getMessage().equalsIgnoreCase(connectionClosedMsg)) { 99 | logger.debug("Unexpected close (may be safe to ignore)."); 100 | } else { 101 | super.exceptionCaught(ctx, e); 102 | } 103 | } 104 | 105 | private void handleService(ChannelHandlerContext ctx, MessageEvent e, Service service) throws Exception { 106 | HttpRequest request = (HttpRequest)e.getMessage(); 107 | request.setUri(request.getUri().replaceFirst(service.getUrl(), "")); 108 | QueryStringDecoder qsd = new QueryStringDecoder(request.getUri()); 109 | String path = qsd.getPath(); 110 | 111 | HttpResponse response = new DefaultHttpResponse(request.getProtocolVersion(), HttpResponseStatus.OK); 112 | if (path.equals("") || path.equals("/")) { 113 | response.setHeader(CONTENT_TYPE, BaseTransport.CONTENT_TYPE_PLAIN); 114 | response.setContent(ChannelBuffers.copiedBuffer("Welcome to SockJS!\n", CharsetUtil.UTF_8)); 115 | writeResponse(e.getChannel(), request, response); 116 | } else if (path.startsWith("/iframe")) { 117 | iframe.handle(request, response); 118 | writeResponse(e.getChannel(), request, response); 119 | } else if (path.startsWith("/info")) { 120 | response.setHeader(CONTENT_TYPE, "application/json; charset=UTF-8"); 121 | response.setHeader(CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"); 122 | response.setContent(getInfo(service)); 123 | writeResponse(e.getChannel(), request, response); 124 | } else if (path.startsWith("/websocket")) { 125 | // Raw web socket 126 | ctx.getPipeline().addLast("sockjs-websocket", new RawWebSocketTransport(path)); 127 | SessionHandler sessionHandler = service.getOrCreateSession( 128 | "rawwebsocket-" + RANDOM.nextLong(), 129 | service.getMetrics().getRawWebSocket(), true); 130 | ctx.getPipeline().addLast("sockjs-session-handler", sessionHandler); 131 | } else { 132 | if (!handleSession(ctx, e, path, service)) { 133 | response.setStatus(HttpResponseStatus.NOT_FOUND); 134 | response.setContent(ChannelBuffers.copiedBuffer("Not found", CharsetUtil.UTF_8)); 135 | writeResponse(e.getChannel(), request, response); 136 | } 137 | } 138 | } 139 | 140 | private boolean handleSession(ChannelHandlerContext ctx, MessageEvent e, String path, Service sm) throws Exception { 141 | HttpRequest request = (HttpRequest)e.getMessage(); 142 | Matcher m = SERVER_SESSION.matcher(path); 143 | 144 | if (!m.find()) { 145 | return false; 146 | } 147 | 148 | String server = m.group(1); 149 | String sessionId = m.group(2); 150 | String transport = path.replaceFirst("/" + server + "/" + sessionId, ""); 151 | final ChannelPipeline pipeline = ctx.getPipeline(); 152 | SessionCreation sessionCreation = SessionCreation.CREATE_OR_REUSE; 153 | 154 | TransportMetrics tm; 155 | if (transport.equals("/xhr_send")) { 156 | tm = sm.getMetrics().getXhrSend(); 157 | pipeline.addLast("sockjs-xhr-send", new XhrSendTransport(sm.getMetrics(), false)); 158 | sessionCreation = SessionCreation.FORCE_REUSE; // Expect an existing session 159 | } else if (transport.equals("/jsonp_send")) { 160 | tm = sm.getMetrics().getXhrSend(); 161 | pipeline.addLast("sockjs-jsonp-send", new XhrSendTransport(sm.getMetrics(), true)); 162 | sessionCreation = SessionCreation.FORCE_REUSE; // Expect an existing session 163 | } else if (transport.equals("/xhr_streaming")) { 164 | tm = sm.getMetrics().getXhrStreaming(); 165 | pipeline.addLast("sockjs-xhr-streaming", new XhrStreamingTransport(sm.getMetrics(), sm.getMaxResponseSize())); 166 | } else if (transport.equals("/xhr")) { 167 | tm = sm.getMetrics().getXhrPolling(); 168 | pipeline.addLast("sockjs-xhr-polling", new XhrPollingTransport(sm.getMetrics())); 169 | } else if (transport.equals("/jsonp")) { 170 | tm = sm.getMetrics().getJsonp(); 171 | pipeline.addLast("sockjs-jsonp-polling", new JsonpPollingTransport(sm.getMetrics())); 172 | } else if (transport.equals("/htmlfile")) { 173 | tm = sm.getMetrics().getHtmlFile(); 174 | pipeline.addLast("sockjs-htmlfile-polling", new HtmlFileTransport(sm.getMetrics(), sm.getMaxResponseSize())); 175 | } else if (transport.equals("/eventsource")) { 176 | tm = sm.getMetrics().getEventSource(); 177 | pipeline.addLast("sockjs-eventsource", new EventSourceTransport(sm.getMetrics(), sm.getMaxResponseSize())); 178 | } else if (transport.equals("/websocket")) { 179 | tm = sm.getMetrics().getWebSocket(); 180 | pipeline.addLast("sockjs-websocket", new WebSocketTransport(sm.getUrl() + path, sm)); 181 | // Websockets should re-create a session every time 182 | sessionCreation = SessionCreation.FORCE_CREATE; 183 | } else { 184 | return false; 185 | } 186 | 187 | tm.connectionsOpen.inc(); 188 | tm.connectionsOpened.mark(); 189 | 190 | SessionHandler sessionHandler = null; 191 | switch (sessionCreation) { 192 | case CREATE_OR_REUSE: 193 | sessionHandler = sm.getOrCreateSession(sessionId, tm, false); 194 | break; 195 | case FORCE_REUSE: 196 | sessionHandler = sm.getSession(sessionId); 197 | break; 198 | case FORCE_CREATE: 199 | sessionHandler = sm.getOrCreateSession(sessionId, tm, true); 200 | break; 201 | default: 202 | throw new Exception("Unknown sessionCreation value: " + sessionCreation); 203 | } 204 | 205 | pipeline.addLast("sockjs-session-handler", sessionHandler); 206 | 207 | return true; 208 | } 209 | 210 | /** Handle conditional connection close depending on keep-alive */ 211 | private void writeResponse(Channel channel, HttpRequest request, HttpResponse response) { 212 | response.setHeader(CONTENT_LENGTH, response.getContent().readableBytes()); 213 | 214 | boolean hasKeepAliveHeader = KEEP_ALIVE.equalsIgnoreCase(request.getHeader(CONNECTION)); 215 | if (!request.getProtocolVersion().isKeepAliveDefault() && hasKeepAliveHeader) { 216 | response.setHeader(CONNECTION, KEEP_ALIVE); 217 | } 218 | 219 | ChannelFuture wf = channel.write(response); 220 | if (!HttpHeaders.isKeepAlive(request)) { 221 | wf.addListener(ChannelFutureListener.CLOSE); 222 | } 223 | } 224 | 225 | private ChannelBuffer getInfo(Service metadata) { 226 | StringBuilder sb = new StringBuilder(100); 227 | sb.append("{"); 228 | sb.append("\"websocket\": "); 229 | sb.append(metadata.isWebSocketEnabled()); 230 | sb.append(", "); 231 | sb.append("\"origins\": [\"*:*\"], "); 232 | sb.append("\"cookie_needed\": "); 233 | sb.append(metadata.isCookieNeeded()); 234 | sb.append(", "); 235 | sb.append("\"entropy\": "); 236 | sb.append(RANDOM.nextInt(Integer.MAX_VALUE) + 1); 237 | sb.append("}"); 238 | return ChannelBuffers.copiedBuffer(sb.toString(), CharsetUtil.UTF_8); 239 | } 240 | 241 | 242 | } 243 | -------------------------------------------------------------------------------- /src/main/java/com/cgbystrom/sockjs/transports/WebSocketTransport.java: -------------------------------------------------------------------------------- 1 | package com.cgbystrom.sockjs.transports; 2 | 3 | 4 | import static org.jboss.netty.handler.codec.http.HttpHeaders.*; 5 | import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; 6 | import static org.jboss.netty.handler.codec.http.HttpMethod.*; 7 | import static org.jboss.netty.handler.codec.http.HttpResponseStatus.*; 8 | import static org.jboss.netty.handler.codec.http.HttpVersion.*; 9 | 10 | import com.cgbystrom.sockjs.*; 11 | import com.cgbystrom.sockjs.PreflightHandler; 12 | import org.codehaus.jackson.JsonParseException; 13 | import org.codehaus.jackson.map.JsonMappingException; 14 | import org.codehaus.jackson.map.ObjectMapper; 15 | import org.jboss.netty.buffer.ChannelBuffer; 16 | import org.jboss.netty.buffer.ChannelBufferInputStream; 17 | import org.jboss.netty.channel.*; 18 | import org.jboss.netty.handler.codec.http.*; 19 | import org.jboss.netty.handler.codec.http.websocketx.*; 20 | import org.jboss.netty.handler.ssl.SslHandler; 21 | import org.jboss.netty.logging.InternalLogger; 22 | import org.jboss.netty.logging.InternalLoggerFactory; 23 | import org.jboss.netty.util.Timeout; 24 | import org.jboss.netty.util.TimerTask; 25 | 26 | import java.io.IOException; 27 | import java.util.concurrent.TimeUnit; 28 | 29 | // FIMXE: Mark as sharable? 30 | public class WebSocketTransport extends SimpleChannelHandler { 31 | private static final InternalLogger logger = InternalLoggerFactory.getInstance(WebSocketTransport.class); 32 | private static final ObjectMapper mapper = new ObjectMapper(); 33 | 34 | private WebSocketServerHandshaker handshaker; 35 | private final String path; 36 | private TransportMetrics transportMetrics; 37 | private Service service; 38 | private Timeout pingPongFrameTimeout; 39 | private Channel channel; 40 | 41 | public WebSocketTransport(String path, Service metadata) { 42 | this.path = path; 43 | this.service = metadata; 44 | transportMetrics = metadata.getMetrics().getWebSocket(); 45 | } 46 | 47 | @Override 48 | public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 49 | // Overridden method to prevent propagation of channel state event upstream. 50 | // Depending on pipeline this may or may not be called. 51 | } 52 | 53 | @Override 54 | public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 55 | // Overridden method to prevent propagation of channel state event upstream. 56 | // Depending on pipeline this may or may not be called. 57 | } 58 | 59 | @Override 60 | public void channelDisconnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { 61 | // Metrics for connect is handled by ServiceRouter since we are not attached 62 | // to pipeline when channelConnected fires. 63 | cancelHeartbeatTask(); 64 | channel = null; 65 | transportMetrics.connectionsOpen.dec(); 66 | super.channelDisconnected(ctx, e); 67 | } 68 | 69 | @Override 70 | public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { 71 | Object msg = e.getMessage(); 72 | if (msg instanceof HttpRequest) { 73 | handleHttpRequest(ctx, e.getChannel(), (HttpRequest) msg); 74 | } else if (msg instanceof WebSocketFrame) { 75 | WebSocketFrame wsf = (WebSocketFrame) msg; 76 | transportMetrics.messagesReceived.mark(); 77 | if (wsf.getBinaryData() != null) { 78 | transportMetrics.messagesReceivedSize.update(wsf.getBinaryData().readableBytes()); 79 | } 80 | handleWebSocketFrame(ctx, e.getChannel(), wsf); 81 | } else { 82 | throw new IOException("Unknown frame type: " + msg.getClass().getSimpleName()); 83 | } 84 | } 85 | 86 | @Override 87 | public void writeRequested(ChannelHandlerContext ctx, final MessageEvent e) throws Exception { 88 | if (e.getMessage() instanceof Frame) { 89 | Frame f = (Frame) e.getMessage(); 90 | logger.debug("Write requested for " + f.getClass().getSimpleName()); 91 | if (f instanceof Frame.CloseFrame) { 92 | e.getFuture().addListener(new ChannelFutureListener() { 93 | @Override 94 | public void operationComplete(ChannelFuture future) throws Exception { 95 | // FIXME: Should really send close frame here? 96 | // handshaker.close(e.getChannel(), new CloseWebSocketFrame()); ? 97 | e.getChannel().close(); 98 | } 99 | }); 100 | } 101 | 102 | ChannelBuffer frame = Frame.encode((Frame) e.getMessage(), false); 103 | transportMetrics.messagesSent.mark(); 104 | transportMetrics.messagesSentSize.update(frame.readableBytes()); 105 | TextWebSocketFrame message = new TextWebSocketFrame(frame); 106 | super.writeRequested(ctx, new DownstreamMessageEvent(e.getChannel(), e.getFuture(), message, e.getRemoteAddress())); 107 | } else { 108 | super.writeRequested(ctx, e); 109 | } 110 | } 111 | 112 | @Override 113 | public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { 114 | // FIXME: Move to BaseTransport 115 | if (e.getCause() instanceof SessionHandler.NotFoundException) { 116 | BaseTransport.respond(e.getChannel(), HttpResponseStatus.NOT_FOUND, "Session not found."); 117 | } else if (e.getCause() instanceof SessionHandler.LockException) { 118 | if (e.getChannel().isWritable()) { 119 | e.getChannel().write(Frame.closeFrame(2010, "Another connection still open")); 120 | } 121 | } else if (e.getCause() instanceof JsonParseException || e.getCause() instanceof JsonMappingException) { 122 | //NotFoundHandler.respond(e.getChannel(), HttpResponseStatus.INTERNAL_SERVER_ERROR, "Broken JSON encoding."); 123 | e.getChannel().close(); 124 | } else if (e.getCause() instanceof WebSocketHandshakeException) { 125 | if (e.getCause().getMessage().contains("missing upgrade")) { 126 | BaseTransport.respond(e.getChannel(), HttpResponseStatus.BAD_REQUEST, "Can \"Upgrade\" only to \"WebSocket\"."); 127 | } 128 | //NotFoundHandler.respond(e.getChannel(), HttpResponseStatus.INTERNAL_SERVER_ERROR, "Broken JSON encoding."); 129 | //e.getChannel().close(); 130 | } else { 131 | super.exceptionCaught(ctx, e); 132 | } 133 | } 134 | 135 | private void handleHttpRequest(final ChannelHandlerContext ctx, final Channel channel, HttpRequest req) throws Exception { 136 | // Allow only GET methods. 137 | if (req.getMethod() != GET) { 138 | DefaultHttpResponse response = new DefaultHttpResponse(HTTP_1_1, METHOD_NOT_ALLOWED); 139 | response.addHeader(ALLOW, GET.toString()); 140 | sendHttpResponse(ctx, req, response); 141 | return; 142 | } 143 | 144 | // Compatibility hack for Firefox 6.x 145 | String connectionHeader = req.getHeader(CONNECTION); 146 | if (connectionHeader != null && connectionHeader.equals("keep-alive, Upgrade")) { 147 | req.setHeader(CONNECTION, UPGRADE); 148 | } 149 | 150 | // If we get WS version 7, treat it as 8 as they are almost identical. (Really true?) 151 | String wsVersionHeader = req.getHeader(SEC_WEBSOCKET_VERSION); 152 | if (wsVersionHeader != null && wsVersionHeader.equals("7")) { 153 | req.setHeader(SEC_WEBSOCKET_VERSION, "8"); 154 | } 155 | 156 | // Handshake 157 | String wsLocation = getWebSocketLocation(channel.getPipeline(), req); 158 | WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(wsLocation, null, false); 159 | 160 | handshaker = wsFactory.newHandshaker(req); 161 | if (handshaker == null) { 162 | wsFactory.sendUnsupportedWebSocketVersionResponse(ctx.getChannel()); 163 | } else { 164 | handshaker.handshake(ctx.getChannel(), req).addListener(new ChannelFutureListener() { 165 | @Override 166 | public void operationComplete(ChannelFuture future) throws Exception { 167 | if (future.isSuccess()) { 168 | WebSocketTransport.this.channel = ctx.getChannel(); 169 | ctx.getPipeline().remove(ServiceRouter.class); 170 | ctx.getPipeline().remove(PreflightHandler.class); 171 | ctx.sendUpstream(new UpstreamChannelStateEvent(channel, ChannelState.CONNECTED, Boolean.TRUE)); 172 | scheduleHeartbeatTask(); 173 | } 174 | } 175 | }); 176 | } 177 | } 178 | 179 | private void handleWebSocketFrame(ChannelHandlerContext ctx, Channel channel, WebSocketFrame frame) throws IOException { 180 | // Check for closing frame 181 | if (frame instanceof CloseWebSocketFrame) { 182 | handshaker.close(ctx.getChannel(), (CloseWebSocketFrame) frame); 183 | return; 184 | } else if (frame instanceof PingWebSocketFrame) { 185 | ctx.getChannel().write(new PongWebSocketFrame(frame.getBinaryData())); 186 | return; 187 | } else if (frame instanceof TextWebSocketFrame) { 188 | // Send the uppercase string back. 189 | String request = ((TextWebSocketFrame) frame).getText(); 190 | ChannelBuffer payload = frame.getBinaryData(); 191 | 192 | if (logger.isDebugEnabled()) { 193 | logger.debug(String.format("Channel %s received '%s'", ctx.getChannel().getId(), request)); 194 | } 195 | 196 | if (frame.getBinaryData().readableBytes() == 0) { 197 | return; 198 | } 199 | 200 | ChannelBufferInputStream cbis = new ChannelBufferInputStream(payload); 201 | String[] messages; 202 | if (payload.getByte(0) == '[') { 203 | // decode array 204 | messages = mapper.readValue(cbis, String[].class); 205 | } else if (payload.getByte(0) == '"') { 206 | // decode string 207 | messages = new String[1]; 208 | messages[0] = mapper.readValue(cbis, String.class); 209 | } else { 210 | throw new IOException("Expected message as string or string[]"); 211 | } 212 | 213 | for (String message : messages) { 214 | SockJsMessage jsMessage = new SockJsMessage(message); 215 | ctx.sendUpstream(new UpstreamMessageEvent(channel, jsMessage, channel.getRemoteAddress())); 216 | } 217 | } else if (frame instanceof PongWebSocketFrame) { 218 | // Ignore 219 | } else { 220 | logger.error("Unhandled frame type: " + frame.getClass().getSimpleName()); 221 | } 222 | } 223 | 224 | private void sendHttpResponse(ChannelHandlerContext ctx, HttpRequest req, HttpResponse res) { 225 | // Send the response and close the connection if necessary. 226 | if (!isKeepAlive(req) || res.getStatus().getCode() != 200) { 227 | res.setHeader(CONNECTION, Values.CLOSE); 228 | ctx.getChannel().write(res).addListener(ChannelFutureListener.CLOSE); 229 | } else { 230 | ctx.getChannel().write(res); 231 | } 232 | } 233 | 234 | private String getWebSocketLocation(ChannelPipeline pipeline, HttpRequest req) { 235 | boolean isSsl = pipeline.get(SslHandler.class) != null; 236 | if (isSsl) { 237 | return "wss://" + req.getHeader(HttpHeaders.Names.HOST) + path; 238 | } else { 239 | return "ws://" + req.getHeader(HttpHeaders.Names.HOST) + path; 240 | } 241 | } 242 | 243 | private void scheduleHeartbeatTask() { 244 | int interval = service.getHeartbeatInterval(); 245 | pingPongFrameTimeout = service.getTimer().newTimeout(new HeartbeatTimerTask(), interval, TimeUnit.MILLISECONDS); 246 | } 247 | 248 | private void cancelHeartbeatTask() { 249 | if (pingPongFrameTimeout != null) { 250 | pingPongFrameTimeout.cancel(); 251 | } 252 | } 253 | 254 | /** 255 | * Sends a Web Socket ping frame to the client to ensure TCP connection stays alive. 256 | * Especially important on the public internet where many can/will interfere, such as proxies/load balancers. 257 | * */ 258 | private class HeartbeatTimerTask implements TimerTask { 259 | @Override 260 | public void run(Timeout timeout) throws Exception { 261 | if (!timeout.isCancelled() && channel != null && channel.isWritable()) { 262 | logger.debug("Sending heartbeat/ping frame"); 263 | channel.write(new PingWebSocketFrame()); 264 | scheduleHeartbeatTask(); 265 | } 266 | } 267 | } 268 | } 269 | --------------------------------------------------------------------------------