summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLoïc Hoguin <[email protected]>2011-10-17 13:06:52 +0200
committerLoïc Hoguin <[email protected]>2011-10-17 13:08:13 +0200
commiteab4a7b8dcc0a20cdc0fa4bb4bff8f4549d4c6f0 (patch)
treef40a36f646790933265ab291d78cd3831fdca335
downloadbullet-eab4a7b8dcc0a20cdc0fa4bb4bff8f4549d4c6f0.tar.gz
bullet-eab4a7b8dcc0a20cdc0fa4bb4bff8f4549d4c6f0.tar.bz2
bullet-eab4a7b8dcc0a20cdc0fa4bb4bff8f4549d4c6f0.zip
Initial commit
This is mostly a proof of concept. The client-side can be greatly improved (and we should probably take from other projects directly). The server-side is pretty much how it should be though.
-rw-r--r--.gitignore4
-rw-r--r--LICENSE13
-rw-r--r--Makefile37
-rw-r--r--README.md11
-rw-r--r--priv/bullet.js212
-rw-r--r--rebar.config12
-rw-r--r--src/bullet.app.src26
-rw-r--r--src/bullet_handler.erl140
8 files changed, 455 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b32276a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.bullet_dialyzer.plt
+.eunit
+deps
+ebin
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7de99bb
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+Copyright (c) 2011, Loïc Hoguin <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6c4d71f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,37 @@
+# See LICENSE for licensing information.
+
+DIALYZER = dialyzer
+REBAR = rebar
+
+all: app
+
+app: deps
+ @$(REBAR) compile
+
+deps:
+ @$(REBAR) get-deps
+
+clean:
+ @$(REBAR) clean
+ rm -f test/*.beam
+ rm -f erl_crash.dump
+
+tests: clean app eunit ct
+
+eunit:
+ @$(REBAR) eunit skip_deps=true
+
+ct:
+ @$(REBAR) ct skip_deps=true
+
+build-plt:
+ @$(DIALYZER) --build_plt --output_plt .bullet_dialyzer.plt \
+ --apps kernel stdlib deps/cowboy
+
+dialyze:
+ @$(DIALYZER) --src src --plt .bullet_dialyzer.plt \
+ -Wbehaviours -Werror_handling \
+ -Wrace_conditions -Wunmatched_returns # -Wunderspecs
+
+docs:
+ @$(REBAR) doc skip_deps=true
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1060899
--- /dev/null
+++ b/README.md
@@ -0,0 +1,11 @@
+Bullet
+======
+
+Bullet is a Cowboy handler and associated Javascript library for
+maintaining a persistent connection to the server irregardless of
+the browser used and the technologies available.
+
+Bullet defines a common interface both client and server-side to
+facilitate the handling of such connections. Bullet also takes care
+of reconnecting automatically when the connection is lost, and of
+the optional heartbeat managed client-side.
diff --git a/priv/bullet.js b/priv/bullet.js
new file mode 100644
index 0000000..8a4f52f
--- /dev/null
+++ b/priv/bullet.js
@@ -0,0 +1,212 @@
+/*
+ Copyright (c) 2011, Lo�c Hoguin <[email protected]>
+
+ Permission to use, copy, modify, and/or distribute this software for any
+ purpose with or without fee is hereby granted, provided that the above
+ copyright notice and this permission notice appear in all copies.
+
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+*/
+
+/**
+ Bullet is a client-side javascript library AND server-side Cowboy handler
+ to manage continuous streaming. It selects the proper transport in a fully
+ automated way and makes sure to always reconnect to the server on any
+ disconnect. You only need to handle sending messages, receiving them,
+ and managing the heartbeat of the stream.
+
+ Usage: $.bullet(url);
+
+ Then you can register one of the 4 event handlers:
+ onopen, onmessage, onclose, onheartbeat.
+
+ onopen is called once right after starting the bullet stream.
+ onmessage is called once for each message receveid.
+ onclose is called once right after you voluntarily close the socket.
+ onheartbeat is called once every few seconds to allow you to easily setup
+ a ping/pong mechanism. By default a JSON ping is sent.
+*/
+(function($){$.extend({bullet: function(url){
+ const CONNECTING = 0;
+ const OPEN = 1;
+ const CLOSING = 2;
+ const CLOSED = 3;
+
+ var transports = {
+ /**
+ The websocket transport is disabled for Firefox 6.0 because it
+ causes a crash to happen when the connection is closed.
+ @see https://bugzilla.mozilla.org/show_bug.cgi?id=662554
+ */
+ websocket: function(){
+ if (window.WebSocket){
+ return window.WebSocket;
+ }
+
+ if (window.MozWebSocket
+ && navigator.userAgent.indexOf("Firefox/6.0") == -1){
+ return window.MozWebSocket;
+ }
+
+ return false;
+ },
+
+ xhrPolling: function(){
+ var openTimeout;
+ var pollTimeout;
+
+ var fakeurl = url.replace('ws:', 'http:').replace('wss:', 'https:');
+ var fake = {
+ readyState: CONNECTING,
+ send: function(data){
+ $.ajax({
+ async: false,
+ type: 'POST',
+ url: fakeurl,
+ data: data,
+ dataType: 'text',
+ contentType:
+ 'application/x-www-form-urlencoded; charset=utf-8',
+ success: function(data){
+ fake.onmessage({'data': data});
+ },
+ error: function(xhr){
+ // @todo That's bad, assume success?
+ $(fake).triggerHandler('error');
+ }
+ });
+ },
+ close: function(){
+ this.readyState = CLOSED;
+ $(fake).triggerHandler('close');
+ clearTimeout(openTimeout);
+ clearTimeout(pollTimeout);
+ },
+ onopen: function(){},
+ onmessage: function(){},
+ onerror: function(){},
+ onclose: function(){}
+ };
+
+ function poll(){
+ $.ajax({
+ type: 'GET',
+ url: fakeurl,
+ dataType: 'text',
+ data: {},
+ headers: {'X-Socket-Transport': 'AJAX long polling'},
+ success: function(data){
+ fake.onmessage({'data': data});
+ if (fake.readyState == OPEN){
+ pollTimeout = setTimeout(function(){poll();}, 100);
+ }
+ },
+ error: function(xhr){
+ $(fake).triggerHandler('error');
+ }
+ });
+ }
+
+ openTimeout = setTimeout(function(){
+ fake.readyState = OPEN;
+ $(fake).triggerHandler('open');
+ pollTimeout = setTimeout(function(){poll();}, 100);
+ }, 100);
+
+ return function(){ return fake; };
+ }
+ };
+
+ var tn = 0;
+ function next(){
+ var c = 0;
+
+ for (var f in transports){
+ if (tn >= c){
+ var t = transports[f]();
+ if (t){
+ return new t(url);
+ }
+
+ tn++;
+ }
+
+ c++;
+ }
+ }
+
+ var stream = new function(){
+ var readyState = CONNECTING;
+ var connected = false;
+ var heartbeat;
+ var reopenTime = 500;
+
+ var transport = next();
+ function init(){
+ transport.onopen = function(){
+ connected = true;
+ // @todo We don't want to heartbeat all transports.
+ heartbeat = setInterval(function(){stream.onheartbeat();}, 20000);
+ reopenTime = 500;
+
+ if (readyState != OPEN){
+ readyState = OPEN;
+ $(stream).triggerHandler('open');
+ }
+ };
+ transport.onclose = function(){
+ connected = false;
+ clearInterval(heartbeat);
+ reopenTime *= 2;
+
+ if (readyState == CLOSING){
+ readyState = CLOSED;
+ $(stream).triggerHandler('close');
+ } else{
+ // Close happened on connect, select next transport
+ if (readyState == CONNECTING){
+ tn++;
+ }
+
+ // Wait some time between each reconnects.
+ // @todo Improve that.
+ setTimeout(function(){
+ transport = next();
+ init();
+ }, reopenTime);
+ }
+ };
+ transport.onerror = transport.onclose;
+ transport.onmessage = function(e){
+ stream.onmessage(e);
+ };
+ }
+ init();
+
+ this.onopen = function(){};
+ this.onmessage = function(){};
+ this.onclose = function(){};
+ this.onheartbeat = function(){};
+
+ this.send = function(data){
+ if (connected){
+ transport.send(data);
+ } else{
+ // @todo That's bad, assume success?
+ $(stream).triggerHandler('error');
+ }
+ };
+ this.close = function(){
+ readyState = CLOSING;
+ transport.close(data);
+ };
+ };
+
+ return stream;
+}})})(jQuery);
diff --git a/rebar.config b/rebar.config
new file mode 100644
index 0000000..c4afb0f
--- /dev/null
+++ b/rebar.config
@@ -0,0 +1,12 @@
+{cover_enabled, true}.
+{deps, [
+ {cowboy, ".*",
+ {git, "git://github.com/extend/cowboy.git", "master"}}
+]}.
+{eunit_opts, [verbose, {report, {eunit_surefire, [{dir, "."}]}}]}.
+{erl_opts, [
+%% bin_opt_info,
+%% warn_missing_spec,
+ warnings_as_errors,
+ warn_export_all
+]}.
diff --git a/src/bullet.app.src b/src/bullet.app.src
new file mode 100644
index 0000000..bb72c7b
--- /dev/null
+++ b/src/bullet.app.src
@@ -0,0 +1,26 @@
+%% Copyright (c) 2011, Loïc Hoguin <[email protected]>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+{application, bullet, [
+ {description,
+ "Simple, reliable, efficient streaming between JS and Cowboy."},
+ {vsn, "0.1.0"},
+ {modules, []},
+ {registered, []},
+ {applications, [
+ kernel,
+ stdlib,
+ cowboy
+ ]}
+]}.
diff --git a/src/bullet_handler.erl b/src/bullet_handler.erl
new file mode 100644
index 0000000..eb34da7
--- /dev/null
+++ b/src/bullet_handler.erl
@@ -0,0 +1,140 @@
+%% Copyright (c) 2011, Lo�c Hoguin <[email protected]>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+-module(bullet_handler).
+
+-behaviour(cowboy_http_handler).
+-export([init/3, handle/2, info/3, terminate/2]).
+
+-behaviour(cowboy_http_websocket_handler).
+-export([websocket_init/3, websocket_handle/3,
+ websocket_info/3, websocket_terminate/3]).
+
+-record(state, {
+ handler :: module(),
+ handler_state :: term()
+}).
+
+-define(TIMEOUT, 60000). %% @todo Configurable.
+
+%% HTTP.
+
+init(Transport, Req, Opts) ->
+ case cowboy_http_req:header('Upgrade', Req) of
+ {undefined, Req2} ->
+ {Method, Req3} = cowboy_http_req:method(Req2),
+ init(Transport, Req3, Opts, Method);
+ {Bin, Req2} when is_binary(Bin) ->
+ case cowboy_bstr:to_lower(Bin) of
+ <<"websocket">> ->
+ {upgrade, protocol, cowboy_http_websocket};
+ _Any ->
+ {ok, Req3} = cowboy_http_req:reply(501, [], [], Req2),
+ {shutdown, Req3, undefined}
+ end
+ end.
+
+init(Transport, Req, Opts, 'GET') ->
+ {handler, Handler} = lists:keyfind(handler, 1, Opts),
+ State = #state{handler=Handler},
+ case Handler:init(Transport, Req, Opts, once) of
+ {ok, Req2, HandlerState} ->
+ Req3 = cowboy_http_req:compact(Req2),
+ {loop, Req3, State#state{handler_state=HandlerState},
+ ?TIMEOUT, hibernate};
+ {shutdown, Req2, HandlerState} ->
+ {shutdown, Req2, State#state{handler_state=HandlerState}}
+ end;
+init(Transport, Req, Opts, 'POST') ->
+ {handler, Handler} = lists:keyfind(handler, 1, Opts),
+ State = #state{handler=Handler},
+ case Handler:init(Transport, Req, Opts, false) of
+ {ok, Req2, HandlerState} ->
+ {ok, Req2, State#state{handler_state=HandlerState}};
+ {shutdown, Req2, HandlerState} ->
+ {shutdown, Req2, State#state{handler_state=HandlerState}}
+ end;
+init(_Transport, Req, _Opts, _Method) ->
+ {ok, Req2} = cowboy_http_req:reply(405, [], [], Req),
+ {shutdown, Req2, undefined}.
+
+handle(Req, State) ->
+ {Method, Req2} = cowboy_http_req:method(Req),
+ handle(Req2, State, Method).
+
+handle(_Req, _State, 'GET') ->
+ exit(badarg);
+handle(Req, State=#state{handler=Handler, handler_state=HandlerState},
+ 'POST') ->
+ {ok, Data, Req2} = cowboy_http_req:body(Req),
+ case Handler:stream(Data, Req2, HandlerState) of
+ {ok, Req3, HandlerState2} ->
+ {ok, Req3, State#state{handler_state=HandlerState2}};
+ {reply, Reply, Req3, HandlerState2} ->
+ {ok, Req4} = cowboy_http_req:reply(200, [], Reply, Req3),
+ {ok, Req4, State#state{handler_state=HandlerState2}}
+ end.
+
+info(Message, Req,
+ State=#state{handler=Handler, handler_state=HandlerState}) ->
+ case Handler:info(Message, Req, HandlerState) of
+ {ok, Req2, HandlerState2} ->
+ {loop, Req2, State#state{handler_state=HandlerState2}, hibernate};
+ {reply, Data, Req2, HandlerState2} ->
+ {ok, Req3} = cowboy_http_req:reply(200, [], Data, Req2),
+ {ok, Req3, State#state{handler_state=HandlerState2}}
+ end.
+
+terminate(Req, #state{handler=Handler, handler_state=HandlerState}) ->
+ Handler:terminate(Req, HandlerState).
+
+%% Websocket.
+
+websocket_init(Transport, Req, Opts) ->
+ {handler, Handler} = lists:keyfind(handler, 1, Opts),
+ State = #state{handler=Handler},
+ case Handler:init(Transport, Req, Opts, true) of
+ {ok, Req2, HandlerState} ->
+ Req3 = cowboy_http_req:compact(Req2),
+ {ok, Req3, State#state{handler_state=HandlerState},
+ ?TIMEOUT, hibernate};
+ {shutdown, Req2, _HandlerState} ->
+ {shutdown, Req2}
+ end.
+
+websocket_handle({text, Data}, Req,
+ State=#state{handler=Handler, handler_state=HandlerState}) ->
+ case Handler:stream(Data, Req, HandlerState) of
+ {ok, Req2, HandlerState2} ->
+ {ok, Req2, State#state{handler_state=HandlerState2}, hibernate};
+ {reply, Reply, Req2, HandlerState2} ->
+ {reply, {text, Reply}, Req2,
+ State#state{handler_state=HandlerState2}, hibernate}
+ end;
+websocket_handle(_Frame, Req, State) ->
+ {ok, Req, State, hibernate}.
+
+websocket_info(Info, Req, State=#state{
+ handler=Handler, handler_state=HandlerState}) ->
+ case Handler:info(Info, Req, HandlerState) of
+ {ok, Req2, HandlerState2} ->
+ {ok, Req2, State#state{handler_state=HandlerState2}, hibernate};
+ {reply, Reply, Req2, HandlerState2} ->
+ {reply, {text, Reply}, Req2,
+ State#state{handler_state=HandlerState2}, hibernate}
+ end.
+
+websocket_terminate(_Reason, Req,
+ #state{handler=Handler, handler_state=HandlerState}) ->
+ Handler:terminate(Req, HandlerState).