From eab4a7b8dcc0a20cdc0fa4bb4bff8f4549d4c6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Mon, 17 Oct 2011 13:06:52 +0200 Subject: 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. --- .gitignore | 4 + LICENSE | 13 +++ Makefile | 37 +++++++++ README.md | 11 +++ priv/bullet.js | 212 +++++++++++++++++++++++++++++++++++++++++++++++++ rebar.config | 12 +++ src/bullet.app.src | 26 ++++++ src/bullet_handler.erl | 140 ++++++++++++++++++++++++++++++++ 8 files changed, 455 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 priv/bullet.js create mode 100644 rebar.config create mode 100644 src/bullet.app.src create mode 100644 src/bullet_handler.erl 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 + +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 + + 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 +%% +%% 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 +%% +%% 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). -- cgit v1.2.3