├── .DS_Store ├── vendor └── cache │ ├── rack-1.3.2.gem │ ├── rake-0.9.2.gem │ ├── tilt-1.3.3.gem │ ├── watchr-0.7.gem │ ├── execjs-1.1.2.gem │ ├── sinatra-1.2.6.gem │ ├── sprockets-1.0.2.gem │ ├── uglifier-0.5.4.gem │ ├── jslintrb_v8-1.0.1.gem │ ├── multi_json-1.0.3.gem │ └── therubyracer-0.8.0.gem ├── packaging ├── start_ec2.sh ├── chef-cookbook │ └── cookbooks │ │ └── chloe │ │ ├── README.rdoc │ │ ├── metadata.rb │ │ ├── recipes │ │ └── default.rb │ │ └── metadata.json ├── common.sh ├── build_ubuntu.sh └── sample_user_data.sh ├── apps └── chloe │ ├── src │ ├── chloe.hrl │ ├── chloe_yaws_websocket.erl │ ├── chloe.app.src │ ├── chloe_app.erl │ ├── lib_md5.erl │ ├── chloe.erl │ ├── chloe_websocket_sup.erl │ ├── chloe_session_sup.erl │ ├── chloe_xhr_stream_sup.erl │ ├── chloe_jsonp_stream_sup.erl │ ├── chloe_yaws_send.erl │ ├── chloe_yaws.erl │ ├── chloe_sup.erl │ ├── chloe_message.erl │ ├── chloe_socketio_protocol.erl │ ├── chloe_session_manager.erl │ ├── chloe_xhr_stream.erl │ ├── chloe_channel_store.erl │ ├── chloe_yaws_xhr.erl │ ├── chloe_jsonp_stream.erl │ ├── chloe_yaws_jsonp.erl │ ├── chloe_websocket.erl │ └── chloe_session.erl │ ├── test │ ├── lib_md5_test.erl │ ├── chloe_session_test.erl │ ├── chloe_socketio_protocol_test.erl │ └── gen_server_mock.erl │ └── docs │ ├── how_it_works.graph │ └── architecture.dot ├── .gitignore ├── Gemfile ├── rebar.config ├── javascripts ├── chloe.js ├── chloe-xdomain.js ├── chloe-websocket.js ├── chloe-client.js ├── chloe-message.js ├── chloe-jsonp.js ├── chloe-xhr.js └── json2.js ├── support ├── views │ ├── index.erb │ └── demo.erb └── echo_server.rb ├── public └── index.yaws ├── rel ├── files │ ├── vm.args │ ├── app.config │ ├── erl │ ├── nodetool │ └── chloe └── reltool.config ├── CHANGELOG ├── Gemfile.lock ├── LICENSE ├── provisioning_notes.txt ├── Rakefile └── README.md /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/.DS_Store -------------------------------------------------------------------------------- /vendor/cache/rack-1.3.2.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/rack-1.3.2.gem -------------------------------------------------------------------------------- /vendor/cache/rake-0.9.2.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/rake-0.9.2.gem -------------------------------------------------------------------------------- /vendor/cache/tilt-1.3.3.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/tilt-1.3.3.gem -------------------------------------------------------------------------------- /vendor/cache/watchr-0.7.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/watchr-0.7.gem -------------------------------------------------------------------------------- /vendor/cache/execjs-1.1.2.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/execjs-1.1.2.gem -------------------------------------------------------------------------------- /vendor/cache/sinatra-1.2.6.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/sinatra-1.2.6.gem -------------------------------------------------------------------------------- /vendor/cache/sprockets-1.0.2.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/sprockets-1.0.2.gem -------------------------------------------------------------------------------- /vendor/cache/uglifier-0.5.4.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/uglifier-0.5.4.gem -------------------------------------------------------------------------------- /vendor/cache/jslintrb_v8-1.0.1.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/jslintrb_v8-1.0.1.gem -------------------------------------------------------------------------------- /vendor/cache/multi_json-1.0.3.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/multi_json-1.0.3.gem -------------------------------------------------------------------------------- /vendor/cache/therubyracer-0.8.0.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashion/chloe/HEAD/vendor/cache/therubyracer-0.8.0.gem -------------------------------------------------------------------------------- /packaging/start_ec2.sh: -------------------------------------------------------------------------------- 1 | basedir=$(dirname $0) 2 | 3 | source $basedir/common.sh 4 | echo "Host is $(start_ubuntu_64_bit)" 5 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe.hrl: -------------------------------------------------------------------------------- 1 | -record(socketio_msg, {type, data}). 2 | -record(message, {data, version, type, channel, id, session_id}). 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | deps 2 | apps/chloe/ebin 3 | NOTES.markdown 4 | .eunit 5 | *.log 6 | foobar.access 7 | chloe.config 8 | rel/chloe* 9 | public/*.js 10 | pkgs 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem 'rake' 4 | gem 'jslintrb_v8' 5 | gem 'watchr' 6 | gem 'therubyracer', '0.8.0' 7 | gem 'sprockets', '1.0.2' 8 | gem 'uglifier' 9 | gem 'sinatra' 10 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {lib_dirs, ["deps"]}. 2 | 3 | {deps, [ 4 | %% yaws-1.91 5 | {yaws, ".*", {git, "https://github.com/mashion/yaws.git", {branch, "klacke-yaws-1.91"}}} 6 | ]}. 7 | 8 | {sub_dirs, ["apps/chloe", "rel"]}. 9 | -------------------------------------------------------------------------------- /javascripts/chloe.js: -------------------------------------------------------------------------------- 1 | //= require "json2.js" 2 | //= require "chloe-client.js" 3 | //= require "chloe-websocket.js" 4 | //= require "chloe-xhr.js" 5 | //= require "chloe-xdomain.js" 6 | //= require "chloe-jsonp.js" 7 | //= require "chloe-message.js" 8 | -------------------------------------------------------------------------------- /apps/chloe/test/lib_md5_test.erl: -------------------------------------------------------------------------------- 1 | -module(lib_md5_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | hexdigest_test() -> 6 | "49f68a5c8493ec2c0bf489821c21fc3b" = lib_md5:hexdigest("hi"), 7 | "c58d6a0c84499d3f992fb23d1348af52" = lib_md5:hexdigest("trotter"). 8 | -------------------------------------------------------------------------------- /packaging/chef-cookbook/cookbooks/chloe/README.rdoc: -------------------------------------------------------------------------------- 1 | = DESCRIPTION: 2 | 3 | Installs / Configures chloe, a realtime webserver that doesn't suck. 4 | 5 | = REQUIREMENTS: 6 | 7 | Known to work on Debian / Ubuntu. May work on others, but is untested. 8 | 9 | = ATTRIBUTES: 10 | 11 | = USAGE: 12 | 13 | -------------------------------------------------------------------------------- /apps/chloe/docs/how_it_works.graph: -------------------------------------------------------------------------------- 1 | [ Browser ] -- 1. /index.html --> [ Your App ] 2 | [ Browser ] -- 2. Send data over websockets --> [ Chloe ] 3 | [ Chloe ] -- 3. Data from the browser --> [ Your App ] 4 | [ Your App ] -- 4. POST /send (data for browser) --> [ Chloe ] 5 | [ Chloe ] -- 5. Data from your app --> [ Browser ] 6 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_yaws_websocket.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_yaws_websocket). 2 | 3 | -include("../../../deps/yaws/include/yaws_api.hrl"). 4 | 5 | -export([out/1]). 6 | 7 | -define(ACTIVE, true). 8 | 9 | out(A) -> 10 | {ok, WebSocketOwner} = chloe_websocket_sup:start_child(), 11 | yaws_websockets:handshake(A, WebSocketOwner, true). 12 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe.app.src: -------------------------------------------------------------------------------- 1 | {application, chloe, 2 | [ 3 | {description, "A websocket server"}, 4 | {vsn, "0.0.5"}, 5 | {registered, [chloe_app]}, 6 | {applications, [ 7 | kernel, 8 | stdlib, 9 | inets 10 | ]}, 11 | {mod, { chloe_app, []}}, 12 | {env, []} 13 | ]}. 14 | -------------------------------------------------------------------------------- /support/views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Chloe 5 | 6 | 7 |

Hello, I am Chloe.

8 |

Look at my source to see how to use chloe.

9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /javascripts/chloe-xdomain.js: -------------------------------------------------------------------------------- 1 | Chloe.XDomainTransport = function (options) { 2 | Chloe.XhrTransport.apply(this, [options]); 3 | }; 4 | 5 | Chloe.XDomainTransport.isEnabled = function () { 6 | return 'XDomainRequest' in window; 7 | }; 8 | 9 | Chloe.XDomainTransport.prototype = Chloe.extend(Chloe.XhrTransport.prototype, { 10 | makeXhr: function () { 11 | return new XDomainRequest(); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /packaging/chef-cookbook/cookbooks/chloe/metadata.rb: -------------------------------------------------------------------------------- 1 | maintainer "Trotter Cashion" 2 | maintainer_email "cashion@gmail.com" 3 | license "MIT" 4 | description "Installs/Configures chloe" 5 | long_description IO.read(File.join(File.dirname(__FILE__), 'README.rdoc')) 6 | version "0.0.1" 7 | depends "erlang" 8 | depends "git" 9 | supports "ubuntu" 10 | supports "debian" 11 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_app.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_app). 2 | 3 | -behaviour(application). 4 | 5 | %% Application callbacks 6 | -export([start/2, stop/1]). 7 | 8 | %% =================================================================== 9 | %% Application callbacks 10 | %% =================================================================== 11 | 12 | start(_StartType, _StartArgs) -> 13 | chloe_sup:start_link(). 14 | 15 | stop(_State) -> 16 | ok. 17 | -------------------------------------------------------------------------------- /public/index.yaws: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Chloe 5 | 6 | 7 |

Hello, I am Chloe.

8 |

Look at my source to see how to use chloe.

9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apps/chloe/src/lib_md5.erl: -------------------------------------------------------------------------------- 1 | -module(lib_md5). 2 | 3 | -export([ 4 | hexdigest/1 5 | ]). 6 | 7 | %%-------------------------------------------------------------------- 8 | %% API functions 9 | %%-------------------------------------------------------------------- 10 | 11 | hexdigest(String) -> 12 | Accumulator = fun (Byte, Digest) -> 13 | Digest ++ lists:flatten(io_lib:format("~2.16.0b", [Byte])) 14 | end, 15 | lists:foldl(Accumulator, "", binary_to_list(erlang:md5(String))). 16 | -------------------------------------------------------------------------------- /rel/files/vm.args: -------------------------------------------------------------------------------- 1 | 2 | ## Name of the node 3 | -name chloe@127.0.0.2 4 | 5 | ## Cookie for distributed erlang 6 | -setcookie chloe 7 | 8 | ## Heartbeat management; auto-restarts VM if it dies or becomes unresponsive 9 | ## (Disabled by default..use with caution!) 10 | ##-heart 11 | 12 | ## Enable kernel poll and a few async threads 13 | +K true 14 | +A 5 15 | 16 | ## Increase number of concurrent ports/sockets 17 | -env ERL_MAX_PORTS 4096 18 | 19 | ## Tweak GC to run more often 20 | -env ERL_FULLSWEEP_AFTER 10 21 | 22 | -------------------------------------------------------------------------------- /support/views/demo.erb: -------------------------------------------------------------------------------- 1 | var chloe = new Chloe({host: "<%= server_name %>", port: 8901}); 2 | 3 | chloe.onmessage(function (message) { 4 | alert('I got a message too: ' + message); 5 | }); 6 | 7 | chloe.onclose(function () { 8 | alert("And... we're closed."); 9 | }); 10 | 11 | chloe.connect(function () { 12 | alert('Holy crap, connected!'); 13 | chloe.send('Ohai Chloe!'); 14 | 15 | chloe.subscribe('pumpkin', function (message) { 16 | alert('Someone was eating pumpkins: ' + message); 17 | }); 18 | }); 19 | 20 | -------------------------------------------------------------------------------- /packaging/common.sh: -------------------------------------------------------------------------------- 1 | function start_instance() { 2 | instance_id=$(ec2-run-instances $@ | grep INSTANCE | cut -f2) 3 | host="" 4 | 5 | while [ -z "$host" ] 6 | do 7 | host=$(ec2-describe-instances | grep $instance_id | cut -f4) 8 | sleep 1 9 | done 10 | 11 | echo "$host" 12 | } 13 | 14 | function start_ubuntu_32_bit() { 15 | start_instance ami-a6f504cf -k trotter-personal-ec2 16 | } 17 | 18 | function start_ubuntu_64_bit() { 19 | start_instance ami-08f40561 -t m1.large -k trotter-personal-ec2 20 | } 21 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | == HEAD 2 | 3 | - Sign requests coming from the Chloe server 4 | 5 | == 0.0.5 6 | 7 | - Fix sendfile issue with Ubuntu 8 | 9 | == 0.0.4 10 | 11 | - Fixed a memory leak 12 | - Allowed transports is now configurable 13 | - Optionally require messages from app server to be signed 14 | - Discontinue XHR support 15 | - Discontinue Chrome WebSockets (will be added back soon) 16 | - Support Safari WebSockets 17 | 18 | == 0.0.3 19 | 20 | - Deployment fixes 21 | - New YAWS version 22 | - Fixed file streaming on ubuntu 23 | 24 | == 0.0.2 25 | 26 | - Xhr Support 27 | - IE fixes 28 | -------------------------------------------------------------------------------- /apps/chloe/docs/architecture.dot: -------------------------------------------------------------------------------- 1 | graph architecture { 2 | chloe_yaws_updates -- websocket; 3 | chloe_yaws_updates -- longpoll; 4 | chloe_yaws_updates -- flash; 5 | chloe_yaws_updates -- others; 6 | 7 | subgraph cluster_transports { 8 | label = "transports"; 9 | websocket; 10 | longpoll; 11 | flash; 12 | others; 13 | } 14 | 15 | websocket -- session; 16 | longpoll -- session; 17 | flash -- session; 18 | others -- session; 19 | 20 | chloe_yaws_send -- session; 21 | session -- channel; 22 | 23 | session -- socket_io_parser; 24 | 25 | chloe_yaws_send -- channel; 26 | 27 | } 28 | -------------------------------------------------------------------------------- /apps/chloe/test/chloe_session_test.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_session_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | -include_lib("../src/chloe.hrl"). 5 | 6 | send_to_browser_test() -> 7 | {ok, MockPid} = gen_server_mock:new(), 8 | gen_server_mock:expect_cast(MockPid, fun({send, [[Message]]}, State) -> 9 | "/all" = Message#message.channel, 10 | "some test data" = Message#message.data, 11 | {ok, State} 12 | end), 13 | 14 | {ok, SessionPid} = chloe_session:start_link(MockPid), 15 | chloe_session:send_to_browser(SessionPid, "/all", "some test data"), 16 | gen_server_mock:assert_expectations(MockPid). 17 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | execjs (1.1.2) 5 | multi_json (~> 1.0) 6 | jslintrb_v8 (1.0.1) 7 | therubyracer (= 0.8.0) 8 | multi_json (1.0.3) 9 | rack (1.3.2) 10 | rake (0.9.2) 11 | sinatra (1.2.6) 12 | rack (~> 1.1) 13 | tilt (< 2.0, >= 1.2.2) 14 | sprockets (1.0.2) 15 | therubyracer (0.8.0) 16 | tilt (1.3.3) 17 | uglifier (0.5.4) 18 | execjs (>= 0.3.0) 19 | multi_json (>= 1.0.2) 20 | watchr (0.7) 21 | 22 | PLATFORMS 23 | ruby 24 | 25 | DEPENDENCIES 26 | jslintrb_v8 27 | rake 28 | sinatra 29 | sprockets (= 1.0.2) 30 | therubyracer (= 0.8.0) 31 | uglifier 32 | watchr 33 | -------------------------------------------------------------------------------- /rel/files/app.config: -------------------------------------------------------------------------------- 1 | [ 2 | %% SASL config 3 | {sasl, [ 4 | {sasl_error_logger, {file, "log/sasl-error.log"}}, 5 | {errlog_type, error}, 6 | {error_logger_mf_dir, "log/sasl"}, % Log directory 7 | {error_logger_mf_maxbytes, 10485760}, % 10 MB max file size 8 | {error_logger_mf_maxfiles, 5} % 5 files max 9 | ]}, 10 | 11 | %% Chloe config 12 | {chloe, 13 | [ 14 | {application_server, "http://localhost:4567"}, 15 | {application_server_url, "http://localhost:4567/updates"}, 16 | {port, 8901}, 17 | {doc_root, "./public"}, 18 | %% {secret, "YOUR_SECRET_GOES_HERE"}, 19 | {log_dir, "/var/log/chloe"} 20 | ]} 21 | ]. 22 | 23 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe.erl: -------------------------------------------------------------------------------- 1 | -module(chloe). 2 | 3 | %% API 4 | -export([ 5 | start/0, 6 | stop/0 7 | ]). 8 | 9 | %%-------------------------------------------------------------------- 10 | %% API 11 | %%-------------------------------------------------------------------- 12 | 13 | start() -> 14 | ensure_started(inets), 15 | application:start(chloe). 16 | 17 | stop() -> 18 | application:stop(chloe). 19 | 20 | %%-------------------------------------------------------------------- 21 | %% Internal functions 22 | %%-------------------------------------------------------------------- 23 | 24 | ensure_started(App) -> 25 | case application:start(App) of 26 | ok -> 27 | ok; 28 | {error, {already_started, App}} -> 29 | ok 30 | end. 31 | -------------------------------------------------------------------------------- /packaging/chef-cookbook/cookbooks/chloe/recipes/default.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Cookbook Name:: chloe 3 | # Recipe:: default 4 | # 5 | # Copyright 2011, Mashion, LLC 6 | # 7 | # MIT Licensed 8 | # 9 | 10 | directory "/opt" 11 | 12 | gem_package "sinatra" 13 | 14 | execute "untar chloe" do 15 | command "cd /opt && tar xzvf /tmp/chloe.tgz" 16 | end 17 | 18 | remote_file "/tmp/chloe.tgz" do 19 | source "https://s3.amazonaws.com/chloe-trotter/chloe-0.0.1-ubuntu32.tgz" 20 | mode "0644" 21 | notifies :run, resource(:execute => "untar chloe") 22 | end 23 | 24 | remote_file "/tmp/chloe_chat_example.tgz" do 25 | source "https://s3.amazonaws.com/chloe-trotter/chloe_chat_example.tgz" 26 | mode "0644" 27 | end 28 | 29 | execute "untar echo server" do 30 | command "cd /opt && tar xzvf /tmp/chloe_chat_example.tgz" 31 | not_if { File.exist?("/opt/echo_server.tgz") } 32 | end 33 | -------------------------------------------------------------------------------- /apps/chloe/test/chloe_socketio_protocol_test.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_socketio_protocol_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | -include_lib("../src/chloe.hrl"). 5 | 6 | parse_message_with_empty_realm_test() -> 7 | {ok, Msg} = chloe_socketio_protocol:parse(<<"1:6::hello,">>), 8 | message = Msg#socketio_msg.type, 9 | <<"hello">> = Msg#socketio_msg.data. 10 | 11 | parse_message_with_javascript_payload_test() -> 12 | Payload = "1:32:j\n:{\"name\":\"yup\",\"message\":\"hi\"},", 13 | {ok, Msg} = chloe_socketio_protocol:parse(list_to_binary(Payload)), 14 | message = Msg#socketio_msg.type, 15 | <<"{\"name\":\"yup\",\"message\":\"hi\"}">> = Msg#socketio_msg.data. 16 | 17 | pack_message_test() -> 18 | "1:6::hello," = chloe_socketio_protocol:pack(message, "", "hello"). 19 | 20 | pack_handshake_test() -> 21 | "3:3:256," = chloe_socketio_protocol:pack(handshake, "256"). 22 | -------------------------------------------------------------------------------- /packaging/chef-cookbook/cookbooks/chloe/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chloe", 3 | "description": "Installs/Configures chloe", 4 | "long_description": "= DESCRIPTION:\n\nInstalls / Configures chloe, a realtime webserver that doesn't suck.\n\n= REQUIREMENTS:\n\nKnown to work on Debian / Ubuntu. May work on others, but is untested.\n\n= ATTRIBUTES:\n\n= USAGE:\n\n", 5 | "maintainer": "Trotter Cashion", 6 | "maintainer_email": "cashion@gmail.com", 7 | "license": "MIT", 8 | "platforms": { 9 | "ubuntu": [ 10 | 11 | ], 12 | "debian": [ 13 | 14 | ] 15 | }, 16 | "dependencies": { 17 | "erlang": [ 18 | 19 | ], 20 | "git": [ 21 | 22 | ] 23 | }, 24 | "recommendations": { 25 | }, 26 | "suggestions": { 27 | }, 28 | "conflicting": { 29 | }, 30 | "providing": { 31 | }, 32 | "replacing": { 33 | }, 34 | "attributes": { 35 | }, 36 | "groupings": { 37 | }, 38 | "recipes": { 39 | }, 40 | "version": "0.0.1" 41 | } -------------------------------------------------------------------------------- /rel/reltool.config: -------------------------------------------------------------------------------- 1 | {sys, [ 2 | {lib_dirs, ["../apps", "../deps"]}, 3 | {rel, "chloe", "0.0.5", 4 | [ 5 | kernel, 6 | stdlib, 7 | sasl, 8 | inets, 9 | chloe 10 | ]}, 11 | {rel, "start_clean", "", 12 | [ 13 | kernel, 14 | stdlib, 15 | inets 16 | ]}, 17 | {boot_rel, "chloe"}, 18 | {profile, embedded}, 19 | {excl_sys_filters, ["^bin/.*", 20 | "^erts.*/bin/(dialyzer|typer)"]}, 21 | {app, chloe, [{incl_cond, include}]} 22 | ]}. 23 | 24 | {overlay, [ 25 | {mkdir, "log/sasl"}, 26 | {copy, "files/erl", "{{erts_vsn}}/bin/erl"}, 27 | {copy, "files/nodetool", "{{erts_vsn}}/bin/nodetool"}, 28 | {copy, "files/chloe", "bin/chloe"}, 29 | {copy, "files/app.config", "etc/app.config"}, 30 | {copy, "files/vm.args", "etc/vm.args"}, 31 | {copy, "../public", "public"} 32 | ]}. 33 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_websocket_sup.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_websocket_sup). 2 | 3 | -behaviour(supervisor). 4 | 5 | %% API 6 | -export([ 7 | start_link/0, 8 | start_child/0 9 | ]). 10 | 11 | %% Supervisor callbacks 12 | -export([init/1]). 13 | 14 | -define(SERVER, ?MODULE). 15 | 16 | %%-------------------------------------------------------------------- 17 | %% API functions 18 | %%-------------------------------------------------------------------- 19 | 20 | start_link() -> 21 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 22 | 23 | start_child() -> 24 | supervisor:start_child(?SERVER, []). 25 | 26 | %%-------------------------------------------------------------------- 27 | %% Supervisor callbacks 28 | %%-------------------------------------------------------------------- 29 | 30 | init([]) -> 31 | Websocket = {chloe_websocket, {chloe_websocket, start_link, []}, 32 | temporary, brutal_kill, worker, [chloe_websocket]}, 33 | Children = [Websocket], 34 | RestartStrategy = {simple_one_for_one, 0, 1}, 35 | {ok, {RestartStrategy, Children}}. 36 | -------------------------------------------------------------------------------- /packaging/build_ubuntu.sh: -------------------------------------------------------------------------------- 1 | basedir=$(dirname $0) 2 | source $basedir/common.sh 3 | 4 | host="${1:-"ubuntu@$(start_ubuntu_32_bit)"}" 5 | 6 | ssh $host < 21 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 22 | 23 | start_child(TransportPid) -> 24 | supervisor:start_child(?SERVER, [TransportPid]). 25 | 26 | %%-------------------------------------------------------------------- 27 | %% Supervisor callbacks 28 | %%-------------------------------------------------------------------- 29 | 30 | init([]) -> 31 | Session = {chloe_session, {chloe_session, start_link, []}, 32 | temporary, brutal_kill, worker, [chloe_session]}, 33 | Children = [Session], 34 | RestartStrategy = {simple_one_for_one, 0, 1}, 35 | {ok, {RestartStrategy, Children}}. 36 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_xhr_stream_sup.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_xhr_stream_sup). 2 | 3 | -behaviour(supervisor). 4 | 5 | %% API 6 | -export([ 7 | start_link/0, 8 | start_child/2 9 | ]). 10 | 11 | %% Supervisor callbacks 12 | -export([init/1]). 13 | 14 | -define(SERVER, ?MODULE). 15 | 16 | %%-------------------------------------------------------------------- 17 | %% API functions 18 | %%-------------------------------------------------------------------- 19 | 20 | start_link() -> 21 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 22 | 23 | start_child(Socket, Message) -> 24 | supervisor:start_child(?SERVER, [Socket, Message]). 25 | 26 | %%-------------------------------------------------------------------- 27 | %% Supervisor callbacks 28 | %%-------------------------------------------------------------------- 29 | 30 | init([]) -> 31 | Stream = {chloe_xhr_stream, {chloe_xhr_stream, start_link, []}, 32 | temporary, brutal_kill, worker, [chloe_session]}, 33 | Children = [Stream], 34 | RestartStrategy = {simple_one_for_one, 0, 1}, 35 | {ok, {RestartStrategy, Children}}. 36 | -------------------------------------------------------------------------------- /support/echo_server.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'sinatra' 3 | require 'net/http' 4 | require 'uri' 5 | require 'digest/md5' 6 | 7 | # SECRET = "YOUR_SECRET_GOES_HERE" 8 | 9 | set :public, File.dirname(__FILE__) + '/public' 10 | set :views, File.dirname(__FILE__) + '/views' 11 | 12 | get '/' do 13 | erb :index 14 | end 15 | 16 | get '/demo.js' do 17 | content_type :js 18 | erb :demo 19 | end 20 | 21 | post '/updates' do 22 | puts request.inspect 23 | signature = request.env["HTTP_X_CHLOE_SIGNATURE"] 24 | raw_data = request.body.read 25 | data = "Handled by Sinatra: #{raw_data}" 26 | 27 | if SECRET && signature != Digest::MD5.hexdigest(raw_data + SECRET) 28 | puts "Signature invalid: signature=#{signature};calculated=#{Digest::MD5.hexdigest(raw_data + SECRET)}" 29 | return "failure" 30 | end 31 | 32 | sig = Digest::MD5.hexdigest(data + SECRET) 33 | Net::HTTP.post_form(URI.parse("http://#{server_name}:8901/send"), 34 | {"data" => data, "sig" => sig}) 35 | puts "I got some data: #{data}" 36 | "success" 37 | end 38 | 39 | def server_name 40 | @request.env["SERVER_NAME"] 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2011 Trotter Cashion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_jsonp_stream_sup.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_jsonp_stream_sup). 2 | 3 | -behaviour(supervisor). 4 | 5 | %% API 6 | -export([ 7 | start_link/0, 8 | start_child/2 9 | ]). 10 | 11 | %% Supervisor callbacks 12 | -export([init/1]). 13 | 14 | -define(SERVER, ?MODULE). 15 | 16 | %%-------------------------------------------------------------------- 17 | %% API functions 18 | %%-------------------------------------------------------------------- 19 | 20 | start_link() -> 21 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 22 | 23 | start_child(Socket, Message) -> 24 | supervisor:start_child(?SERVER, [Socket, Message]). 25 | 26 | %%-------------------------------------------------------------------- 27 | %% Supervisor callbacks 28 | %%-------------------------------------------------------------------- 29 | 30 | init([]) -> 31 | JsonpStream = {chloe_jsonp_stream, {chloe_jsonp_stream, start_link, []}, 32 | temporary, brutal_kill, worker, [chloe_session]}, 33 | Children = [JsonpStream], 34 | RestartStrategy = {simple_one_for_one, 0, 1}, 35 | {ok, {RestartStrategy, Children}}. 36 | -------------------------------------------------------------------------------- /packaging/sample_user_data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | log="/tmp/init.log" 3 | 4 | apt-get update >> $log 5 | apt-get install -y ruby irb ri libopenssl-ruby1.8 libshadow-ruby1.8 ruby1.8-dev gcc g++ rsync curl >> $log 6 | 7 | curl -L 'http://production.cf.rubygems.org/rubygems/rubygems-1.6.2.tgz' | tar xvzf - 8 | cd rubygems* && ruby setup.rb --no-ri --no-rdoc >> $log 9 | ln -sfv /usr/bin/gem1.8 /usr/bin/gem 10 | 11 | gem install rdoc chef ohai --no-ri --no-rdoc --source http://gems.opscode.com --source http://gems.rubyforge.org >> $log 12 | 13 | mkdir -p /etc/chef 14 | 15 | cat << EOF > /etc/chef/solo.rb 16 | file_cache_path "/var/chef/cache" 17 | file_backup_path "/var/chef/backup" 18 | cookbook_path ["/var/chef/cookbooks"] 19 | role_path "/var/chef/roles" 20 | json_attribs "/etc/chef/node.json" 21 | verbose_logging true 22 | EOF 23 | 24 | cat << EOF > /etc/chef/node.json 25 | { 26 | "run_list": ["recipe[chloe]"] 27 | } 28 | EOF 29 | 30 | chef-solo -r https://s3.amazonaws.com/chloe-trotter/chloe-chef-solo.tgz >> $log 2>&1 31 | 32 | while ! ls /opt/chloe_chat_example 33 | do 34 | sleep 1 35 | done 36 | 37 | cd /opt/chloe-0.0.5/bin && ./chloe start 38 | /usr/bin/ruby /opt/chloe_chat_example/chat_server.rb > /var/chat_server.log 2>&1 & 39 | -------------------------------------------------------------------------------- /rel/files/erl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## This script replaces the default "erl" in erts-VSN/bin. This is necessary 4 | ## as escript depends on erl and in turn, erl depends on having access to a 5 | ## bootscript (start.boot). Note that this script is ONLY invoked as a side-effect 6 | ## of running escript -- the embedded node bypasses erl and uses erlexec directly 7 | ## (as it should). 8 | ## 9 | ## Note that this script makes the assumption that there is a start_clean.boot 10 | ## file available in $ROOTDIR/release/VSN. 11 | 12 | # Determine the abspath of where this script is executing from. 13 | ERTS_BIN_DIR=$(cd ${0%/*} && pwd) 14 | 15 | # Now determine the root directory -- this script runs from erts-VSN/bin, 16 | # so we simply need to strip off two dirs from the end of the ERTS_BIN_DIR 17 | # path. 18 | ROOTDIR=${ERTS_BIN_DIR%/*/*} 19 | 20 | # Parse out release and erts info 21 | START_ERL=`cat $ROOTDIR/releases/start_erl.data` 22 | ERTS_VSN=${START_ERL% *} 23 | APP_VSN=${START_ERL#* } 24 | 25 | BINDIR=$ROOTDIR/erts-$ERTS_VSN/bin 26 | EMU=beam 27 | PROGNAME=`echo $0 | sed 's/.*\\///'` 28 | CMD="$BINDIR/erlexec" 29 | export EMU 30 | export ROOTDIR 31 | export BINDIR 32 | export PROGNAME 33 | 34 | exec $CMD -boot $ROOTDIR/releases/$APP_VSN/start_clean ${1+"$@"} -------------------------------------------------------------------------------- /javascripts/chloe-websocket.js: -------------------------------------------------------------------------------- 1 | Chloe.WebSocketTransport = function (options) { 2 | this.host = options.host; 3 | this.port = options.port; 4 | this.socketAttributes = {}; 5 | }; 6 | 7 | Chloe.WebSocketTransport.prototype = { 8 | // Public API 9 | connect: function (callback) { 10 | var self = this; 11 | this.socket = new WebSocket("ws://" + this.host + ":" + this.port + "/chloe/websocket"); 12 | this.socket.onopen = callback; 13 | for (var i in this.socketAttributes) { 14 | this.socket[i] = this.socketAttributes[i]; 15 | } 16 | }, 17 | onclose: function (callback) { 18 | this.attachToSocket('onclose', callback); 19 | }, 20 | onmessage: function (callback) { 21 | this.attachToSocket('onmessage', function (message) { 22 | callback(message.data); 23 | }); 24 | }, 25 | send: function (message) { 26 | this.socket.send(message); 27 | }, 28 | 29 | // Internal helpers 30 | attachToSocket: function (attribute, callback) { 31 | this.socketAttributes[attribute] = callback; 32 | if (this.socket) { 33 | this.socket[attribute] = this.socketAttributes[attribute]; 34 | } 35 | } 36 | }; 37 | 38 | Chloe.WebSocketTransport.isEnabled = function () { 39 | return typeof(WebSocket) === "object"; 40 | }; 41 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_yaws_send.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_yaws_send). 2 | 3 | -include("../../../deps/yaws/include/yaws_api.hrl"). 4 | 5 | -export([out/1]). 6 | 7 | out(A) -> 8 | case is_message_authenticated(A) of 9 | true -> send_to_all_subscribers(A), 10 | {content, "text/plain", "success"}; 11 | _ -> {content, "text/plain", "unauthenticated"} 12 | end. 13 | 14 | %%-------------------------------------------------------------------- 15 | %% internal functions 16 | %%-------------------------------------------------------------------- 17 | 18 | is_message_authenticated(A) -> 19 | case application:get_env(chloe, secret) of 20 | undefined -> true; 21 | {ok, Secret} -> check_signature(A, Secret) 22 | end. 23 | 24 | check_signature(A, Secret) -> 25 | {ok, Data} = yaws_api:postvar(A, "data"), 26 | {ok, Sig} = yaws_api:postvar(A, "sig"), 27 | lib_md5:hexdigest(Data ++ Secret) =:= Sig. 28 | 29 | send_to_all_subscribers(A) -> 30 | Channel = case yaws_api:postvar(A, "channel") of 31 | {ok, C} -> C; 32 | _ -> "/all" 33 | end, 34 | {ok, Subscribers} = chloe_channel_store:fetch_subscribers(Channel), 35 | {ok, Data} = yaws_api:postvar(A, "data"), 36 | lists:foreach( 37 | fun(Pid) -> 38 | error_logger:info_msg("Sending ~p to ~p~n", [Data, Channel]), 39 | chloe_session:send_to_browser(Pid, Channel, Data) 40 | end, 41 | Subscribers). 42 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_yaws.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_yaws). 2 | 3 | %% API 4 | -export([ 5 | start_link/0, 6 | run/0 7 | ]). 8 | 9 | %% Supervisor module 10 | -define(SUPERVISOR, chloe_sup). 11 | 12 | start_link() -> 13 | {ok, spawn(?MODULE, run, [])}. 14 | 15 | run() -> 16 | Id = "embedded", 17 | GconfList = [{id, Id}, 18 | {logdir, get_env(chloe, log_dir, ".")}], 19 | Docroot = get_env(chloe, doc_root, "./public"), 20 | SconfList = [{port, get_env(chloe, port, 8901)}, 21 | {servername, "chloe"}, 22 | {listen, {0,0,0,0}}, 23 | {docroot, Docroot}, 24 | {appmods, [{"/chloe/websocket", chloe_yaws_websocket}, 25 | {"/chloe/xhr", chloe_yaws_xhr}, 26 | {"/chloe/jsonp.js", chloe_yaws_jsonp}, 27 | {"/send", chloe_yaws_send}]}], 28 | {ok, SCList, GC, ChildSpecs} = 29 | yaws_api:embedded_start_conf(Docroot, SconfList, GconfList, Id), 30 | [supervisor:start_child(?SUPERVISOR, Ch) || Ch <- ChildSpecs], 31 | yaws_api:setconf(GC, SCList), 32 | {ok, self()}. 33 | 34 | %%-------------------------------------------------------------------- 35 | %% Internal functions 36 | %%-------------------------------------------------------------------- 37 | get_env(AppName, Key, Default) -> 38 | case application:get_env(AppName, Key) of 39 | undefined -> Default; 40 | {ok, Value} -> Value 41 | end. 42 | -------------------------------------------------------------------------------- /provisioning_notes.txt: -------------------------------------------------------------------------------- 1 | Commands to get chloe package built: 2 | $ instance_id=$(ec2-run-instances ami-a6f504cf -k trotter | grep INSTANCE | cut -f2) 3 | $ host=$(ec2-describe-instances | grep $instance_id | cut -f4) 4 | $ ssh ubuntu@$host 5 | > sudo apt-get update 6 | > sudo apt-get install -y build-essential libncurses5-dev openssl libssl-dev 7 | > sudo apt-get install -y erlang # Installs R13B03, should probably install erlang / find better package 8 | > sudo apt-get install -y git-core 9 | > sudo apt-get install -y rake 10 | > sudo apt-get install -y gcc 11 | > sudo apt-get install -y libpam0g-dev 12 | > wget http://www.erlang.org/download/otp_src_R14B02.tar.gz 13 | > tar zxvf otp_src_R14B02.tar.gz 14 | > cd otp_src_R14B02 15 | > ./configure && make && sudo make install 16 | > cd - 17 | > git clone https://github.com/basho/rebar.git 18 | > cd rebar 19 | > ./bootstrap 20 | > sudo mv rebar /usr/local/bin 21 | > cd - 22 | > git clone https://github.com/mashion/chloe.git 23 | > cd chloe 24 | > rake bootstrap 25 | > rake compile 26 | > rebar generate 27 | > cd rel 28 | > mv chloe chloe-0.0.1 29 | > tar czvf chloe-0.0.1-ubuntu.tgz chloe-0.0.1 30 | 31 | Commands to run chloe package on ubuntu on ec2: 32 | $ instance_id=$(ec2-run-instances ami-a6f504cf -k trotter | grep INSTANCE | cut -f2) 33 | $ host=$(ec2-describe-instances | grep $instance_id | cut -f4) 34 | $ ssh ubuntu@$host 35 | $ wget --no-check-certificate https://github.com/downloads/mashion/chloe/chloe-0.0.1-ubuntu32.tgz 36 | $ tar xzvf chloe-0.0.1-ubuntu32.tgz 37 | $ cd chloe-0.0.1 && ./bin/chloe start 38 | 39 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_sup.erl: -------------------------------------------------------------------------------- 1 | 2 | -module(chloe_sup). 3 | 4 | -behaviour(supervisor). 5 | 6 | %% API 7 | -export([start_link/0]). 8 | 9 | %% Supervisor callbacks 10 | -export([init/1]). 11 | 12 | %% Helper macro for declaring children of supervisor 13 | -define(WORKER(I), {I, {I, start_link, []}, permanent, 5000, worker, [I]}). 14 | -define(SUPERVISOR(I, Child), {I, {I, start_link, []}, permanent, 5000, supervisor, [I, Child]}). 15 | 16 | %% =================================================================== 17 | %% API functions 18 | %% =================================================================== 19 | 20 | start_link() -> 21 | supervisor:start_link({local, ?MODULE}, ?MODULE, []). 22 | 23 | %% =================================================================== 24 | %% Supervisor callbacks 25 | %% =================================================================== 26 | 27 | init([]) -> 28 | Yaws = ?WORKER(chloe_yaws), 29 | ChannelStore = ?WORKER(chloe_channel_store), 30 | SessionManager = ?WORKER(chloe_session_manager), 31 | WebSocketSup = ?SUPERVISOR(chloe_websocket_sup, chloe_websocket), 32 | JsonpStreamSup = ?SUPERVISOR(chloe_jsonp_stream_sup, chloe_jsonp_stream), 33 | XhrStreamSup = ?SUPERVISOR(chloe_xhr_stream_sup, chloe_xhr_stream), 34 | SessionSup = ?SUPERVISOR(chloe_session_sup, chloe_session), 35 | Children = [Yaws, ChannelStore, SessionManager, WebSocketSup, 36 | JsonpStreamSup, XhrStreamSup, SessionSup], 37 | RestartStrategy = {one_for_one, 5, 10}, 38 | {ok, {RestartStrategy, Children}}. 39 | 40 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_message.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_message). 2 | 3 | %% API 4 | -export([ 5 | unpack/1, 6 | pack/1 7 | ]). 8 | 9 | -define(VERSION, 1). 10 | -include_lib("./chloe.hrl"). 11 | 12 | %%-------------------------------------------------------------------- 13 | %% API 14 | %%-------------------------------------------------------------------- 15 | 16 | unpack(Data) when is_binary(Data) -> 17 | unpack(binary_to_list(Data)); 18 | unpack(Data) -> 19 | {ok, {struct, PropList}} = json:decode_string(Data), 20 | check_version(PropList), 21 | #message{data=proplists:get_value(data, PropList), 22 | version=proplists:get_value(version, PropList), 23 | type=proplists:get_value(type, PropList), 24 | channel=proplists:get_value(channel, PropList), 25 | id=proplists:get_value(id, PropList), 26 | session_id=proplists:get_value(sessionId, PropList)}. 27 | 28 | to_struct(Message) -> 29 | {struct, [{data, Message#message.data}, 30 | {version, ?VERSION}, 31 | {type, Message#message.type}, 32 | {channel, Message#message.channel}, 33 | {id, Message#message.id}, 34 | {sessionId, Message#message.session_id}]}. 35 | 36 | pack(Messages) when is_list(Messages) -> 37 | json:encode({struct, [{messages, {array, lists:map(fun (M) -> 38 | to_struct(M) 39 | end, Messages)}}]}); 40 | pack(Message) -> 41 | json:encode(to_struct(Message)). 42 | 43 | %%-------------------------------------------------------------------- 44 | %% Internal functions 45 | %%-------------------------------------------------------------------- 46 | 47 | check_version(PropList) -> 48 | ?VERSION = proplists:get_value(version, PropList). 49 | 50 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_socketio_protocol.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_socketio_protocol). 2 | 3 | %% API 4 | -export([ 5 | parse/1, 6 | pack/2, 7 | pack/3 8 | ]). 9 | 10 | -include_lib("./chloe.hrl"). 11 | 12 | %%-------------------------------------------------------------------- 13 | %% API 14 | %%-------------------------------------------------------------------- 15 | 16 | parse(Data) -> 17 | parse(Data, #socketio_msg{}). 18 | 19 | pack(message, Realm, Data) when is_binary(Data) -> 20 | pack(message, Realm, binary_to_list(Data)); 21 | pack(message, Realm, Data) -> 22 | Body = lists:append([Realm, ":", Data]), 23 | lists:append(["1:", integer_to_list(length(Body)), ":", Body, ","]). 24 | 25 | pack(handshake, SessionId) -> 26 | lists:append(["3:", integer_to_list(length(SessionId)), ":", SessionId, ","]). 27 | 28 | %%-------------------------------------------------------------------- 29 | %% internal functions 30 | %%-------------------------------------------------------------------- 31 | %% NOTE: This parse works as long as the realm annotation of the 32 | %% Socket.IO protocol is not used. If that annotation is used, 33 | %% it will inject an extra ':', which gums up the whole works. 34 | parse(Data, #socketio_msg{type=undefined} = Message) -> 35 | [TypePart, Rest] = binary:split(Data, <<":">>), 36 | Type = case list_to_integer(binary_to_list(TypePart)) of 37 | 1 -> message 38 | end, 39 | parse(Rest, Message#socketio_msg{type=Type}); 40 | parse(Data, #socketio_msg{data=undefined} = Message) -> 41 | [Length, Rest] = binary:split(Data, <<":">>), 42 | [Annotations, Body] = binary:split(Rest, <<":">>), 43 | BodyLength = list_to_integer(binary_to_list(Length)) - size(Annotations), 44 | BodyLength = size(Body), 45 | {ok, Message#socketio_msg{data=binary:part(Body, {0, BodyLength - 1})}}. 46 | 47 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_session_manager.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_session_manager). 2 | 3 | -behaviour(gen_server). 4 | 5 | %% API 6 | -export([ 7 | start_link/0, 8 | create/1, 9 | fetch_pid/1 10 | ]). 11 | 12 | %% gen_server callbacks 13 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 14 | terminate/2, code_change/3]). 15 | 16 | -define(SERVER, ?MODULE). 17 | -define(TABLE_ID, ?MODULE). 18 | 19 | -record(state, {next_session_id}). 20 | 21 | %%-------------------------------------------------------------------- 22 | %% API 23 | %%-------------------------------------------------------------------- 24 | 25 | start_link() -> 26 | gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). 27 | 28 | create(Pid) -> 29 | gen_server:call(?SERVER, {create, Pid}). 30 | 31 | fetch_pid(SessionId) when is_list(SessionId) -> 32 | fetch_pid(list_to_integer(SessionId)); 33 | fetch_pid(SessionId) -> 34 | case ets:lookup(?TABLE_ID, SessionId) of 35 | [{SessionId, SessionPid}] -> {ok, SessionPid}; 36 | [] -> {error, session_not_found} 37 | end. 38 | 39 | %%-------------------------------------------------------------------- 40 | %% gen_server callbacks 41 | %%-------------------------------------------------------------------- 42 | 43 | init([]) -> 44 | ets:new(?TABLE_ID, [protected, named_table]), 45 | {ok, #state{next_session_id=1}}. 46 | 47 | handle_call({create, Pid}, _From, State) -> 48 | SessionId = State#state.next_session_id, 49 | {ok, SessionPid} = chloe_session_sup:start_child(Pid), 50 | ets:insert(?TABLE_ID, {SessionId, SessionPid}), 51 | NewState = State#state{next_session_id=SessionId + 1}, 52 | {reply, {ok, integer_to_list(SessionId)}, NewState}. 53 | 54 | handle_cast(_Request, State) -> 55 | {noreply, State}. 56 | 57 | handle_info(_Reason, State) -> 58 | {noreply, State}. 59 | 60 | terminate(_Info, _State) -> 61 | ok. 62 | 63 | code_change(_OldVsn, State, _Extra) -> 64 | {ok, State}. 65 | 66 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_xhr_stream.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_xhr_stream). 2 | 3 | -behaviour(gen_server). 4 | -include_lib("./chloe.hrl"). 5 | 6 | %% API 7 | -export([ 8 | start_link/2, 9 | send/3 10 | ]). 11 | 12 | %% gen_server callbacks 13 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 14 | terminate/2, code_change/3]). 15 | 16 | -record(state, {socket, message, yaws_pid}). 17 | 18 | %%-------------------------------------------------------------------- 19 | %% API functions 20 | %%-------------------------------------------------------------------- 21 | 22 | start_link(Socket, Message) -> 23 | gen_server:start_link(?MODULE, [Socket, Message], []). 24 | 25 | send(Pid, Channel, Data) -> 26 | gen_server:cast(Pid, {send, [Channel, Data]}). 27 | 28 | %%-------------------------------------------------------------------- 29 | %% gen_server callbacks 30 | %%-------------------------------------------------------------------- 31 | 32 | init([Socket, Message]) -> 33 | {ok, #state{socket = Socket, message = Message}}. 34 | 35 | handle_call(_Request, _From, State) -> 36 | {reply, ok, State}. 37 | 38 | handle_cast({send, [Messages]}, State) -> 39 | Packed = chloe_message:pack(Messages), 40 | yaws_api:stream_process_deliver_final_chunk(State#state.socket, Packed), 41 | yaws_api:stream_process_end(State#state.socket, State#state.yaws_pid), 42 | {stop, normal, State}. 43 | 44 | handle_info({ok, YawsPid}, State) -> 45 | Message = State#state.message, 46 | {ok, SessionPid} = chloe_session_manager:fetch_pid(Message#message.session_id), 47 | %% Now that Yaws is ready, we attach ourselves to our session. 48 | chloe_session:attach_transport_pid(SessionPid, self()), 49 | {noreply, State#state{yaws_pid = YawsPid}}; 50 | handle_info({discard, YawsPid}, State) -> 51 | yaws_api:stream_process_end(State#state.socket, YawsPid), 52 | {stop, normal, State}. 53 | 54 | terminate(_Reason, _State) -> 55 | ok. 56 | 57 | code_change(_OldVsn, State, _Extra) -> 58 | {ok, State}. 59 | -------------------------------------------------------------------------------- /javascripts/chloe-client.js: -------------------------------------------------------------------------------- 1 | Chloe = function (options) { 2 | options = options || {}; 3 | options.host = options.host || 'localhost'; 4 | options.port = options.port || 8901; 5 | 6 | var transports = options.transports || 7 | [Chloe.WebSocketTransport, 8 | // TODO (trotter): Test XHR and add back the transport 9 | // Chloe.XhrTransport, 10 | Chloe.JsonpTransport]; 11 | 12 | for (var i = 0, l = transports.length; i < l; i++) { 13 | if (transports[i].isEnabled()) { 14 | this.transport = new transports[i](options); 15 | break; 16 | } 17 | } 18 | 19 | this.channelSubscriptions = {}; 20 | }; 21 | 22 | Chloe.extend = function (source, obj) { 23 | for (var prop in source) obj[prop] = source[prop]; 24 | return obj; 25 | }; 26 | 27 | Chloe.prototype = { 28 | // Public API 29 | connect: function (callback) { 30 | var self = this; 31 | this.transport.connect(function (data) { 32 | self.sessionId = data.sessionId; 33 | callback(); 34 | }); 35 | this.transport.onmessage(function (message) { 36 | self.handleMessage(Chloe.Message.unpack(message)); 37 | }); 38 | }, 39 | onmessage: function (callback) { 40 | var self = this; 41 | this.onmessageCallback = callback; 42 | }, 43 | onclose: function (callback) { 44 | this.transport.onclose(callback); 45 | }, 46 | send: function (data) { 47 | var message = Chloe.Message.pack(data, this.sessionId); 48 | message.send(this.transport); 49 | }, 50 | subscribe: function (channel, callback) { 51 | var message = Chloe.Message.channelSubscribe(channel, this); 52 | this.channelSubscriptions[channel] = callback; 53 | message.send(this.transport); 54 | }, 55 | 56 | // Internal functions 57 | handleMessage: function (message) { 58 | var callback = this.channelSubscriptions[message.channel]; 59 | if (callback) { 60 | callback(message.data); 61 | } else if (this.onmessageCallback) { 62 | this.onmessageCallback(message.data); 63 | } 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /javascripts/chloe-message.js: -------------------------------------------------------------------------------- 1 | // Chloe message types: 2 | // 'connect' 3 | // 'channel-subscribe' 4 | // 'message' 5 | // 'poll' 6 | 7 | Chloe.Message = function (options) { 8 | this.version = Chloe.Message.version; 9 | this.sessionId = options.sessionId; 10 | this.id = options.id; 11 | this.type = options.type; 12 | // TODO (trotter): I don't really like doing this, find a better way. 13 | this.channel = options.channel; 14 | this.data = options.data; 15 | this.packed = options.packed; 16 | }; 17 | 18 | Chloe.Message.version = 1; 19 | 20 | Chloe.Message.pack = function (data, sessionId) { 21 | var message = new Chloe.Message({data: data, 22 | type: "message", 23 | sessionId: sessionId}); 24 | message.pack(); 25 | return message; 26 | }; 27 | 28 | Chloe.Message.unpack = function (packed) { 29 | var message = new Chloe.Message({packed: packed}); 30 | message.unpack(); 31 | return message; 32 | }; 33 | 34 | Chloe.Message.channelSubscribe = function (channel, client) { 35 | var message = new Chloe.Message({type: "channel-subscribe", 36 | sessionId: client.sessionId, 37 | channel: channel}); 38 | message.pack(); 39 | return message; 40 | }; 41 | 42 | Chloe.Message.prototype = { 43 | pack: function () { 44 | this.packed = JSON.stringify({ 45 | type: this.type, 46 | channel: this.channel, 47 | data: this.data, 48 | version: this.version, 49 | id: this.id, 50 | sessionId: this.sessionId 51 | }); 52 | }, 53 | unpack: function () { 54 | var decoded = JSON.parse(this.packed); 55 | if (decoded.version !== this.version) { 56 | throw new Error("Expected message version " + decoded.version + " to match " + this.version); 57 | } 58 | this.data = decoded.data; 59 | this.channel = decoded.channel; 60 | this.type = decoded.type; 61 | this.id = decoded.id; 62 | this.sessionId = decoded.sessionId; 63 | }, 64 | send: function (transport) { 65 | this.pack(); 66 | transport.send(this.packed); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_channel_store.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_channel_store). 2 | 3 | -behaviour(gen_server). 4 | 5 | -export([ 6 | start_link/0, 7 | subscribe/2, 8 | fetch_subscribers/1 9 | ]). 10 | 11 | %% gen_server callbacks 12 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 13 | terminate/2, code_change/3]). 14 | 15 | -define(SERVER, ?MODULE). 16 | -define(TABLE_ID, ?MODULE). 17 | 18 | -record(state, {}). 19 | 20 | %%-------------------------------------------------------------------- 21 | %% API 22 | %%-------------------------------------------------------------------- 23 | 24 | start_link() -> 25 | gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). 26 | 27 | subscribe(Channel, Pid) -> 28 | gen_server:cast(?SERVER, {subscribe, [Channel, Pid]}). 29 | 30 | fetch_subscribers(Channel) -> 31 | gen_server:call(?SERVER, {fetch_subscribers, Channel}). 32 | 33 | %%-------------------------------------------------------------------- 34 | %% gen_server callbacks 35 | %%-------------------------------------------------------------------- 36 | 37 | init([]) -> 38 | ets:new(?TABLE_ID, [protected, named_table]), 39 | {ok, #state{}}. 40 | 41 | handle_call({fetch_subscribers, Channel}, _From, State) -> 42 | Response = subscribers_for(Channel), 43 | {reply, Response, State}. 44 | 45 | handle_cast({subscribe, [Channel, Pid]}, State) -> 46 | OldSubscribers = case subscribers_for(Channel) of 47 | {ok, Subscribers} -> Subscribers; 48 | _ -> [] 49 | end, 50 | NewSubscribers = add_subscriber(Pid, OldSubscribers), 51 | ets:insert(?TABLE_ID, {Channel, NewSubscribers}), 52 | {noreply, State}. 53 | 54 | handle_info(_Reason, State) -> 55 | {noreply, State}. 56 | 57 | terminate(_Info, _State) -> 58 | ok. 59 | 60 | code_change(_OldVsn, State, _Extra) -> 61 | {ok, State}. 62 | 63 | %%-------------------------------------------------------------------- 64 | %% internal functions 65 | %%-------------------------------------------------------------------- 66 | 67 | add_subscriber(NewSubscriber, Subscribers) -> 68 | [NewSubscriber | lists:delete(NewSubscriber, Subscribers)]. 69 | 70 | subscribers_for(Channel) -> 71 | case ets:lookup(?TABLE_ID, Channel) of 72 | [{Channel, Subscribers}] -> {ok, Subscribers}; 73 | [] -> {ok, []} 74 | end. 75 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_yaws_xhr.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_yaws_xhr). 2 | 3 | -include("../../../deps/yaws/include/yaws_api.hrl"). 4 | -include_lib("./chloe.hrl"). 5 | 6 | -export([out/1]). 7 | 8 | -define(MIME_TYPE, "application/json"). 9 | 10 | %%-------------------------------------------------------------------- 11 | %% API 12 | %%-------------------------------------------------------------------- 13 | 14 | out(A) -> 15 | {ok, Raw} = yaws_api:getvar(A, "data"), 16 | Message = chloe_message:unpack(Raw), 17 | case Message#message.type of 18 | "connect" -> handle_connect(Message); 19 | "message" -> handle_message(Message); 20 | "channel-subscribe" -> handle_channel_subscribe(Message); 21 | "poll" -> handle_poll(A, Message) 22 | end. 23 | 24 | %%-------------------------------------------------------------------- 25 | %% Internal functions 26 | %%-------------------------------------------------------------------- 27 | 28 | cross_origin_response() -> 29 | cross_origin_response(""). 30 | 31 | %% TODO (trotter): May need to append the port here, I noticed 32 | %% that Chrome was upset with this header. 33 | cross_origin_response(Response) when is_tuple(Response) -> 34 | {ok, Origin} = application:get_env(chloe, application_server), 35 | [{header, ["Access-Control-Allow-Origin: ", Origin]}, 36 | Response]; 37 | 38 | cross_origin_response(Packed) -> 39 | cross_origin_response({content, ?MIME_TYPE, Packed}). 40 | 41 | handle_connect(Message) -> 42 | SessionId = create_session(), 43 | Packed = chloe_message:pack(#message{session_id=SessionId, 44 | id=Message#message.id, 45 | type=Message#message.type}), 46 | cross_origin_response(Packed). 47 | 48 | handle_message(Message) -> 49 | chloe_session:send_to_server(session_pid(Message#message.session_id), 50 | Message#message.data), 51 | cross_origin_response(). 52 | 53 | handle_channel_subscribe(Message) -> 54 | chloe_session:subscribe(session_pid(Message#message.session_id), 55 | Message#message.channel), 56 | cross_origin_response(). 57 | 58 | handle_poll(A, Message) -> 59 | {ok, StreamPid} = chloe_xhr_stream_sup:start_child(A#arg.clisock, Message), 60 | cross_origin_response({streamcontent_from_pid, ?MIME_TYPE, StreamPid}). 61 | 62 | session_pid(SessionId) -> 63 | {ok, SessionPid} = chloe_session_manager:fetch_pid(SessionId), 64 | SessionPid. 65 | 66 | create_session() -> 67 | %% TODO: We should really tell chloe session manager what it's dealing 68 | %% with, so that it doesn't try to send data down the pipe. 69 | {ok, SessionId} = chloe_session_manager:create(undefined), 70 | SessionId. 71 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_jsonp_stream.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_jsonp_stream). 2 | 3 | -behaviour(gen_server). 4 | -include_lib("./chloe.hrl"). 5 | 6 | %% API 7 | -export([ 8 | start_link/2, 9 | send/3 10 | ]). 11 | 12 | %% gen_server callbacks 13 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 14 | terminate/2, code_change/3]). 15 | 16 | -record(state, {socket, message, yaws_pid}). 17 | 18 | %%-------------------------------------------------------------------- 19 | %% API functions 20 | %%-------------------------------------------------------------------- 21 | 22 | start_link(Socket, Message) -> 23 | gen_server:start_link(?MODULE, [Socket, Message], []). 24 | 25 | send(Pid, Channel, Data) -> 26 | gen_server:cast(Pid, {send, [Channel, Data]}). 27 | 28 | %%-------------------------------------------------------------------- 29 | %% gen_server callbacks 30 | %%-------------------------------------------------------------------- 31 | 32 | init([Socket, Message]) -> 33 | {ok, #state{socket = Socket, message = Message}}. 34 | 35 | handle_call(_Request, _From, State) -> 36 | {reply, ok, State}. 37 | 38 | handle_cast({send, [Messages]}, State) -> 39 | Message = State#state.message, 40 | PackedMessages = lists:map(fun (M) -> 41 | Packed = chloe_message:pack(#message{id=Message#message.id, 42 | type=Message#message.type, 43 | data=M#message.data, 44 | channel=M#message.channel}), 45 | jsonp_response(Packed) 46 | end, Messages), 47 | Packed = string:join(PackedMessages, ""), 48 | yaws_api:stream_process_deliver_final_chunk(State#state.socket, Packed), 49 | yaws_api:stream_process_end(State#state.socket, State#state.yaws_pid), 50 | error_logger:info_msg("Should be sending data now!!"), 51 | {stop, normal, State}. 52 | 53 | handle_info({ok, YawsPid}, State) -> 54 | Message = State#state.message, 55 | {ok, SessionPid} = chloe_session_manager:fetch_pid(Message#message.session_id), 56 | %% Now that Yaws is ready, we attach ourselves to our session. 57 | chloe_session:attach_transport_pid(SessionPid, self()), 58 | {noreply, State#state{yaws_pid = YawsPid}}; 59 | handle_info({discard, YawsPid}, State) -> 60 | yaws_api:stream_process_end(State#state.socket, YawsPid), 61 | {stop, normal, State}. 62 | 63 | terminate(_Reason, _State) -> 64 | ok. 65 | 66 | code_change(_OldVsn, State, _Extra) -> 67 | {ok, State}. 68 | 69 | %%-------------------------------------------------------------------- 70 | %% Internal functions 71 | %%-------------------------------------------------------------------- 72 | 73 | jsonp_response(Packed) -> 74 | string:join(["Chloe.JsonpTransport.response(", Packed, ");"], ""). 75 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'fileutils' 3 | require 'bundler' 4 | Bundler.require 5 | 6 | desc "Install all necessary dependencies" 7 | task :bootstrap do 8 | sh("bundle install") 9 | sh("rebar get-deps") 10 | end 11 | 12 | desc "Start an erlang console" 13 | task :console => :compile do 14 | sh(erl) 15 | end 16 | 17 | desc "Start an erlang console running chloe" 18 | task :server => :compile do 19 | sh(erl "-s chloe -config ./rel/files/app") 20 | end 21 | 22 | desc "Compile Chloe" 23 | task :compile => [:build_js, :test] do 24 | sh("rebar update-deps") 25 | sh("rebar compile") 26 | end 27 | 28 | desc "Run unit tests for chloe" 29 | task :test do 30 | sh("rebar app=chloe eunit") 31 | end 32 | 33 | desc "Clean up" 34 | task :clean do 35 | sh "rebar clean" 36 | end 37 | 38 | desc "Generate a release on this box" 39 | task :platform_release => [:clean, :compile] do 40 | # TODO (factor out version to not be hard coded) 41 | version = "0.0.5" 42 | FileUtils.rm_rf("./rel/chloe") 43 | FileUtils.rm_rf("./rel/chloe-#{version}") 44 | sh "rebar generate" 45 | sh "cp -r ./rel/chloe ./rel/chloe-#{version}" 46 | sh "cd ./rel && tar czf chloe-#{version}.tgz chloe-#{version}" 47 | end 48 | 49 | desc "Run demo echo server" 50 | task :demo do 51 | require './support/echo_server' 52 | Sinatra::Application.run! 53 | end 54 | 55 | desc "Build chloe.js" 56 | task :build_js do 57 | unminified = "public/chloe.js" 58 | 59 | secretary = Sprockets::Secretary.new( 60 | :asset_root => "public", 61 | :source_files => ["javascripts/chloe.js"] 62 | ) 63 | 64 | secretary.concatenation.save_to(unminified) 65 | 66 | File.open("public/chloe-min.js", "w") do |f| 67 | f.write Uglifier.new.compile(File.read(unminified)) 68 | end 69 | end 70 | 71 | begin 72 | require('jslintrb-v8') 73 | task :jslint do 74 | linter = JSLint.new( 75 | :white => false, 76 | :undef => true, 77 | :nomen => false, 78 | :eqeqeq => true, 79 | :plusplus => true, 80 | :bitwise => true, 81 | :regexp => false, 82 | :strict => false, 83 | :newcap => true, 84 | :immed => true, 85 | :indent => 2, 86 | :predef => "Chloe" 87 | ) 88 | errors = [] 89 | path = File.join('public', '**', '*.js') 90 | Dir[path].each do |f| 91 | puts "checking #{f}" 92 | e = linter.check(File.read(f)) 93 | errors << "\nIn [#{f}]:\n#{e}\n" if e 94 | end 95 | if errors.empty? 96 | puts "JSLinty-fresh!" 97 | else 98 | $stderr.write(errors.join("\n")+"\n"); 99 | raise "JSLint Errors Found" 100 | end 101 | end 102 | rescue LoadError 103 | puts "jslintrb_v8 not installed. Not adding jslint task" 104 | end 105 | 106 | def erl(extra="") 107 | "erl -pa apps/chloe/ebin -pa deps/yaws/ebin #{extra}" 108 | end 109 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_yaws_jsonp.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_yaws_jsonp). 2 | 3 | -include("../../../deps/yaws/include/yaws_api.hrl"). 4 | -include_lib("./chloe.hrl"). 5 | 6 | -export([out/1]). 7 | 8 | -define(MIME_TYPE, "application/javascript"). 9 | 10 | %%-------------------------------------------------------------------- 11 | %% API 12 | %%-------------------------------------------------------------------- 13 | 14 | out(A) -> 15 | Raw = proplists:get_value("data", yaws_api:parse_query(A)), 16 | Message = chloe_message:unpack(Raw), 17 | case Message#message.type of 18 | "connect" -> handle_connect(Message); 19 | "message" -> handle_message(Message); 20 | "channel-subscribe" -> handle_channel_subscribe(Message); 21 | "poll" -> handle_poll(A, Message) 22 | end. 23 | 24 | %%-------------------------------------------------------------------- 25 | %% Internal functions 26 | %%-------------------------------------------------------------------- 27 | 28 | handle_connect(Message) -> 29 | SessionId = create_session(), 30 | Packed = chloe_message:pack(#message{session_id=SessionId, 31 | id=Message#message.id, 32 | type=Message#message.type}), 33 | {content, ?MIME_TYPE, jsonp_response(Packed)}. 34 | 35 | handle_message(Message) -> 36 | chloe_session:send_to_server(session_pid(Message#message.session_id), 37 | Message#message.data), 38 | {content, ?MIME_TYPE, ""}. 39 | 40 | handle_channel_subscribe(Message) -> 41 | chloe_session:subscribe(session_pid(Message#message.session_id), 42 | Message#message.channel), 43 | {content, ?MIME_TYPE, ""}. 44 | 45 | handle_poll(A, Message) -> 46 | {ok, JsonpStreamPid} = chloe_jsonp_stream_sup:start_child(A#arg.clisock, Message), 47 | {streamcontent_from_pid, ?MIME_TYPE, JsonpStreamPid}. 48 | 49 | %% Messages = chloe_session:retrieve_messages(session_pid(Message#message.session_id)), 50 | %% error_logger:info_msg("We got messages ~p~n", [Messages]), 51 | %% PackedMessages = lists:map(fun (M) -> 52 | %% Packed = chloe_message:pack(#message{id=Message#message.id, 53 | %% type=Message#message.type, 54 | %% data=M#message.data, 55 | %% channel=M#message.channel}), 56 | %% jsonp_response(Packed) 57 | %% end, Messages), 58 | %% Packed = string:join(PackedMessages, ""), 59 | %% {content, "application/javascript", Packed}. 60 | 61 | jsonp_response(Packed) -> 62 | string:join(["Chloe.JsonpTransport.response(", Packed, ");"], ""). 63 | 64 | session_pid(SessionId) -> 65 | {ok, SessionPid} = chloe_session_manager:fetch_pid(SessionId), 66 | SessionPid. 67 | 68 | create_session() -> 69 | %% TODO: We should really tell chloe session manager what it's dealing 70 | %% with, so that it doesn't try to send data down the pipe. 71 | {ok, SessionId} = chloe_session_manager:create(undefined), 72 | SessionId. 73 | -------------------------------------------------------------------------------- /rel/files/nodetool: -------------------------------------------------------------------------------- 1 | %% -*- erlang -*- 2 | %% ------------------------------------------------------------------- 3 | %% 4 | %% nodetool: Helper Script for interacting with live nodes 5 | %% 6 | %% ------------------------------------------------------------------- 7 | 8 | main(Args) -> 9 | %% Extract the args 10 | {RestArgs, TargetNode} = process_args(Args, [], undefined), 11 | 12 | %% See if the node is currently running -- if it's not, we'll bail 13 | case {net_kernel:hidden_connect_node(TargetNode), net_adm:ping(TargetNode)} of 14 | {true, pong} -> 15 | ok; 16 | {_, pang} -> 17 | io:format("Node ~p not responding to pings.\n", [TargetNode]), 18 | halt(1) 19 | end, 20 | 21 | case RestArgs of 22 | ["ping"] -> 23 | %% If we got this far, the node already responsed to a ping, so just dump 24 | %% a "pong" 25 | io:format("pong\n"); 26 | ["stop"] -> 27 | io:format("~p\n", [rpc:call(TargetNode, init, stop, [], 60000)]); 28 | ["restart"] -> 29 | io:format("~p\n", [rpc:call(TargetNode, init, restart, [], 60000)]); 30 | ["reboot"] -> 31 | io:format("~p\n", [rpc:call(TargetNode, init, reboot, [], 60000)]); 32 | ["rpc", Module, Function | RpcArgs] -> 33 | case rpc:call(TargetNode, list_to_atom(Module), list_to_atom(Function), [RpcArgs], 60000) of 34 | ok -> 35 | ok; 36 | {badrpc, Reason} -> 37 | io:format("RPC to ~p failed: ~p\n", [TargetNode, Reason]), 38 | halt(1); 39 | _ -> 40 | halt(1) 41 | end; 42 | Other -> 43 | io:format("Other: ~p\n", [Other]), 44 | io:format("Usage: nodetool {ping|stop|restart|reboot}\n") 45 | end, 46 | net_kernel:stop(). 47 | 48 | process_args([], Acc, TargetNode) -> 49 | {lists:reverse(Acc), TargetNode}; 50 | process_args(["-setcookie", Cookie | Rest], Acc, TargetNode) -> 51 | erlang:set_cookie(node(), list_to_atom(Cookie)), 52 | process_args(Rest, Acc, TargetNode); 53 | process_args(["-name", TargetName | Rest], Acc, _) -> 54 | ThisNode = append_node_suffix(TargetName, "_maint_"), 55 | {ok, _} = net_kernel:start([ThisNode, longnames]), 56 | process_args(Rest, Acc, nodename(TargetName)); 57 | process_args(["-sname", TargetName | Rest], Acc, _) -> 58 | ThisNode = append_node_suffix(TargetName, "_maint_"), 59 | {ok, _} = net_kernel:start([ThisNode, shortnames]), 60 | process_args(Rest, Acc, nodename(TargetName)); 61 | process_args([Arg | Rest], Acc, Opts) -> 62 | process_args(Rest, [Arg | Acc], Opts). 63 | 64 | 65 | nodename(Name) -> 66 | case string:tokens(Name, "@") of 67 | [_Node, _Host] -> 68 | list_to_atom(Name); 69 | [Node] -> 70 | [_, Host] = string:tokens(atom_to_list(node()), "@"), 71 | list_to_atom(lists:concat([Node, "@", Host])) 72 | end. 73 | 74 | append_node_suffix(Name, Suffix) -> 75 | case string:tokens(Name, "@") of 76 | [Node, Host] -> 77 | list_to_atom(lists:concat([Node, Suffix, os:getpid(), "@", Host])); 78 | [Node] -> 79 | list_to_atom(lists:concat([Node, Suffix, os:getpid()])) 80 | end. 81 | -------------------------------------------------------------------------------- /javascripts/chloe-jsonp.js: -------------------------------------------------------------------------------- 1 | Chloe.JsonpTransport = function (options) { 2 | this.host = options.host; 3 | this.port = options.port; 4 | this.protocol = "http://"; 5 | this.callbacks = {}; 6 | this.register(); 7 | }; 8 | 9 | Chloe.JsonpTransport.prototype = { 10 | // Public API 11 | connect: function (callback) { 12 | var self = this, 13 | message = new Chloe.Message({ 14 | id: this.id, 15 | type: 'connect' 16 | }); 17 | 18 | this.callbacks.onconnect = function (data) { 19 | // TODO: Storing sessionId both here and on client level. 20 | // Feels marginally wrong. 21 | self.sessionId = data.sessionId; 22 | self.listenForMessages(); 23 | callback(data); 24 | }; 25 | 26 | message.pack(); 27 | message.send(this); 28 | }, 29 | 30 | onmessage: function (callback) { 31 | this.callbacks.onmessage = callback; 32 | }, 33 | 34 | onclose: function (callback) { 35 | this.callbacks.onclose = callback; 36 | }, 37 | 38 | send: function (data, options) { 39 | var self = this, 40 | script = document.createElement('script'); 41 | 42 | options = options || {}; 43 | 44 | script.src = this.url(data); 45 | script.type = 'text/javascript'; 46 | script.onerror = function () { 47 | if (options.onerror) { 48 | options.onerror(); 49 | } 50 | // TODO: Find out what, if any, arguments this takes 51 | self.handleError(); 52 | }; 53 | document.body.appendChild(script); 54 | }, 55 | 56 | // Internal functions 57 | register: function () { 58 | this.id = (new Date()).getTime(); 59 | Chloe.JsonpTransport.connections[this.id] = this; 60 | }, 61 | 62 | url: function (data) { 63 | return this.protocol + this.host + ":" + this.port + "/chloe/jsonp.js?data=" + escape(data) + "ts=" + (new Date()).getTime(); 64 | }, 65 | 66 | handleError: function () { 67 | // Need to figure out what to do here. 68 | }, 69 | 70 | listenForMessages: function () { 71 | var self = this, 72 | message = new Chloe.Message({ 73 | id: this.id, 74 | sessionId: this.sessionId, 75 | type: "poll" 76 | }); 77 | message.send(this, {onerror: function () { self.listenForMessages(); }}); 78 | } 79 | }; 80 | 81 | Chloe.JsonpTransport.connections = {}; 82 | 83 | Chloe.JsonpTransport.isEnabled = function () { 84 | return true; 85 | } 86 | 87 | Chloe.JsonpTransport.response = function (data) { 88 | var message = new Chloe.Message(data), 89 | connection = Chloe.JsonpTransport.connections[message.id]; 90 | 91 | // TODO: We are packing because chloe-client.js is going to try to unpack 92 | // later. We need to remove this dependency and instead have the transports 93 | // take care of unpacking. 94 | message.pack(); 95 | if (message.type === 'connect') { 96 | connection.callbacks.onconnect(message); 97 | } else if (message.type === 'poll') { 98 | connection.callbacks.onmessage(message.packed); 99 | connection.listenForMessages(); 100 | } else { 101 | throw new Error("Unknown message type for JsonpTransport."); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /javascripts/chloe-xhr.js: -------------------------------------------------------------------------------- 1 | Chloe.XhrTransport = function (options) { 2 | this.host = options.host; 3 | this.port = options.port; 4 | this.protocol = "http://"; 5 | this.callbacks = {}; 6 | }; 7 | 8 | Chloe.XhrTransport.isEnabled = function (host) { 9 | return 'XMLHttpRequest' in window && 10 | this.prototype.makeXhr().withCredentials != undefined; 11 | }; 12 | 13 | Chloe.XhrTransport.prototype = { 14 | makeXhr: function () { 15 | return new XMLHttpRequest(); 16 | }, 17 | 18 | url: function (path) { 19 | return this.protocol + this.host + ":" + this.port + "/chloe" + path; 20 | }, 21 | 22 | connect: function (callback) { 23 | var self = this, 24 | message = new Chloe.Message({ 25 | type: 'connect' 26 | }); 27 | 28 | message.pack(); 29 | this.postRequest(message.packed, function (data) { 30 | self.sessionId = data.sessionId; 31 | self.listenForMessages(); 32 | callback(message); 33 | }); 34 | }, 35 | 36 | send: function (outbound) { 37 | // TODO (mat): definitely need to move pack/unpack into the transport 38 | var message = Chloe.Message.unpack(outbound); 39 | message.sessionId = this.sessionId; 40 | message.pack(); 41 | this.postRequest(message.packed); 42 | }, 43 | 44 | onclose: function (callback) { 45 | this.callbacks.onclose = function () { 46 | clearTimeout(this.poller); 47 | callback(); 48 | }; 49 | }, 50 | 51 | onmessage: function (callback) { 52 | this.callbacks.onmessage = callback; 53 | }, 54 | 55 | noop: function () { 56 | }, 57 | 58 | handleStateChange: function (req, callback) { 59 | var received = callback || this.noop, 60 | closed = this.callbacks.onclose || this.noop; 61 | req.onreadystatechange = function(){ 62 | var message, status; 63 | if (req.readyState == 4){ 64 | req.onreadystatechange = this.noop; 65 | try { status = req.status; } catch(e){} 66 | if (status == 200){ 67 | if (req.responseText !== "") { 68 | var data = JSON.parse(req.responseText); 69 | if (data.messages) { 70 | var messages = data.messages; 71 | for (var i in messages) { 72 | received(new Chloe.Message(messages[i])); 73 | } 74 | } else { 75 | received(new Chloe.Message(data)); 76 | } 77 | } 78 | } else { 79 | closed(); 80 | } 81 | } 82 | } 83 | }, 84 | 85 | getRequest: function (callback) { 86 | var req = this.makeXhr(), 87 | message = new Chloe.Message({ sessionId: this.sessionId, 88 | type: "poll" }); 89 | message.pack(); 90 | req.open('GET', this.url("/xhr/" + (+ new Date)) + 91 | "?data=" + escape(message.packed)); 92 | this.handleStateChange(req, callback); 93 | req.send(null); 94 | }, 95 | 96 | postRequest: function (data, callback) { 97 | var req = this.makeXhr(); 98 | req.open('POST', this.url('/xhr')); 99 | if ('setRequestHeader' in req) { 100 | req.setRequestHeader('Content-type', 'application/x-www-form-urlencoded; charset=utf-8'); 101 | } 102 | this.handleStateChange(req, callback); 103 | req.send("data=" + escape(data)); 104 | }, 105 | 106 | listenForMessages: function () { 107 | var self = this, 108 | onmessage = this.callbacks.onmessage || this.noop; 109 | message = new Chloe.Message({ sessionId: this.sessionId, 110 | type: "poll" }); 111 | 112 | this.getRequest(function (incoming) { 113 | if (typeof(incoming) !== "undefined") { 114 | incoming.pack(); 115 | onmessage(incoming.packed); 116 | } 117 | // XXX (trotter): The following works for fetching messages, 118 | // but it causes a weird '' error message 119 | // to show up in firebug. 120 | self.listenForMessages(); 121 | }); 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_websocket.erl: -------------------------------------------------------------------------------- 1 | %% To test run the following in chrome: 2 | %% ws = new WebSocket("ws://localhost:8888/updates"); 3 | %% ws.onmessage = function (m) { console.log(m.data); }; 4 | %% ws.send("Hey dude"); 5 | 6 | -module(chloe_websocket). 7 | 8 | -behaviour(gen_server). 9 | 10 | %% API 11 | -export([ 12 | start_link/0, 13 | send/2 14 | ]). 15 | 16 | %% gen_server callbacks 17 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 18 | terminate/2, code_change/3]). 19 | 20 | -record(state, {websocket, session_id}). 21 | -include_lib("./chloe.hrl"). 22 | 23 | %%-------------------------------------------------------------------- 24 | %% API 25 | %%-------------------------------------------------------------------- 26 | 27 | start_link() -> 28 | gen_server:start_link(?MODULE, [], []). 29 | 30 | send(Pid, [Messages]) -> 31 | gen_server:cast(Pid, {send, [Messages]}). 32 | 33 | %%-------------------------------------------------------------------- 34 | %% gen_server callbacks 35 | %%-------------------------------------------------------------------- 36 | 37 | init([]) -> 38 | {ok, #state{}}. 39 | 40 | handle_call(_Request, _From, State) -> 41 | {reply, ok, State}. 42 | 43 | handle_cast({send, [Messages]}, State) -> 44 | lists:foreach(fun (M) -> 45 | Packed = chloe_message:pack(M), 46 | error_logger:info_msg("Sending back: ~p", [Packed]), 47 | yaws_api:websocket_send(State#state.websocket, Packed) 48 | end, Messages), 49 | {noreply, State}. 50 | 51 | %% This is where our websocket comms will come in 52 | handle_info({ok, WebSocket}, State) -> 53 | error_logger:info_msg("Websocket started on ~p~n", [self()]), 54 | SessionId = perform_session_handshake(WebSocket), 55 | {noreply, State#state{websocket = WebSocket, 56 | session_id = SessionId}}; 57 | handle_info({tcp, _WebSocket, DataFrames}, State) -> 58 | error_logger:info_msg("Raw DataFrame: ~p~n", [DataFrames]), 59 | handle_websocket_frames(DataFrames, State), 60 | {noreply, State}; 61 | handle_info({tcp_closed, _WebSocket}, State) -> 62 | {stop, ok, State}; 63 | % handle_info(discard, State) -> 64 | % {stop, ok, State}; 65 | %% httpc is going to tell us how our request went, 66 | %% but we don't care 67 | handle_info({http, _Response}, State) -> 68 | {noreply, State}. 69 | 70 | terminate(_Reason, _State) -> 71 | ok. 72 | 73 | code_change(_OldVsn, State, _Extra) -> 74 | {ok, State}. 75 | 76 | %%-------------------------------------------------------------------- 77 | %% internal functions 78 | %%-------------------------------------------------------------------- 79 | 80 | perform_session_handshake(_WebSocket) -> 81 | {ok, SessionId} = chloe_session_manager:create(self()), 82 | % Message = chloe_socketio_protocol:pack(handshake, SessionId), 83 | % yaws_api:websocket_send(WebSocket, Message), 84 | SessionId. 85 | 86 | session_pid(SessionId) -> 87 | {ok, SessionPid} = chloe_session_manager:fetch_pid(SessionId), 88 | SessionPid. 89 | 90 | handle_message(Data, State) -> 91 | Message = chloe_message:unpack(Data), 92 | case Message#message.type of 93 | "channel-subscribe" -> handle_channel_subscribe_message(Message, State); 94 | _ -> handle_data_message(Message, State) 95 | end. 96 | 97 | handle_websocket_frames(DataFrames, State) -> 98 | %% TODO (trotter): We _may_ be able to use yaws_websockets:unframe_all 99 | %% then process the resulting list. 100 | case yaws_websockets:unframe_one(DataFrames) of 101 | {ok, Data, <<>>} -> handle_message(Data, State); 102 | {ok, Data, NextFrame} -> handle_message(Data, State), 103 | handle_websocket_frames(NextFrame, State) 104 | end. 105 | 106 | handle_channel_subscribe_message(Message, State) -> 107 | chloe_session:subscribe(session_pid(State#state.session_id), 108 | Message#message.channel). 109 | 110 | handle_data_message(Message, State) -> 111 | error_logger:info_msg("Got data from WebSocket: ~p~n", [Message#message.data]), 112 | chloe_session:send_to_server(session_pid(State#state.session_id), 113 | Message#message.data). 114 | -------------------------------------------------------------------------------- /rel/files/chloe: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # -*- tab-width:4;indent-tabs-mode:nil -*- 3 | # ex: ts=4 sw=4 et 4 | 5 | RUNNER_SCRIPT_DIR=$(cd ${0%/*} && pwd) 6 | 7 | RUNNER_BASE_DIR=${RUNNER_SCRIPT_DIR%/*} 8 | RUNNER_ETC_DIR=$RUNNER_BASE_DIR/etc 9 | RUNNER_LOG_DIR=$RUNNER_BASE_DIR/log 10 | PIPE_DIR=/tmp/$RUNNER_BASE_DIR/ 11 | RUNNER_USER= 12 | 13 | # Make sure this script is running as the appropriate user 14 | if [ ! -z "$RUNNER_USER" ] && [ `whoami` != "$RUNNER_USER" ]; then 15 | exec sudo -u $RUNNER_USER -i $0 $@ 16 | fi 17 | 18 | # Make sure CWD is set to runner base dir 19 | cd $RUNNER_BASE_DIR 20 | 21 | # Make sure log directory exists 22 | mkdir -p $RUNNER_LOG_DIR 23 | 24 | # Extract the target node name from node.args 25 | NAME_ARG=`grep -e '-[s]*name' $RUNNER_ETC_DIR/vm.args` 26 | if [ -z "$NAME_ARG" ]; then 27 | echo "vm.args needs to have either -name or -sname parameter." 28 | exit 1 29 | fi 30 | 31 | # Extract the target cookie 32 | COOKIE_ARG=`grep -e '-setcookie' $RUNNER_ETC_DIR/vm.args` 33 | if [ -z "$COOKIE_ARG" ]; then 34 | echo "vm.args needs to have a -setcookie parameter." 35 | exit 1 36 | fi 37 | 38 | # Identify the script name 39 | SCRIPT=`basename $0` 40 | 41 | # Parse out release and erts info 42 | START_ERL=`cat $RUNNER_BASE_DIR/releases/start_erl.data` 43 | ERTS_VSN=${START_ERL% *} 44 | APP_VSN=${START_ERL#* } 45 | 46 | # Add ERTS bin dir to our path 47 | ERTS_PATH=$RUNNER_BASE_DIR/erts-$ERTS_VSN/bin 48 | 49 | # Setup command to control the node 50 | NODETOOL="$ERTS_PATH/escript $ERTS_PATH/nodetool $NAME_ARG $COOKIE_ARG" 51 | 52 | # Check the first argument for instructions 53 | case "$1" in 54 | start) 55 | # Make sure there is not already a node running 56 | RES=`$NODETOOL ping` 57 | if [ "$RES" = "pong" ]; then 58 | echo "Node is already running!" 59 | exit 1 60 | fi 61 | HEART_COMMAND="$RUNNER_BASE_DIR/bin/$SCRIPT start" 62 | export HEART_COMMAND 63 | mkdir -p $PIPE_DIR 64 | # Note the trailing slash on $PIPE_DIR/ 65 | $ERTS_PATH/run_erl -daemon $PIPE_DIR/ $RUNNER_LOG_DIR "exec $RUNNER_BASE_DIR/bin/$SCRIPT console" 2>&1 66 | ;; 67 | 68 | stop) 69 | # Wait for the node to completely stop... 70 | case `uname -s` in 71 | Linux|Darwin|FreeBSD|DragonFly|NetBSD|OpenBSD) 72 | # PID COMMAND 73 | PID=`ps ax -o pid= -o command=|\ 74 | grep "$RUNNER_BASE_DIR/.*/[b]eam"|awk '{print $1}'` 75 | ;; 76 | SunOS) 77 | # PID COMMAND 78 | PID=`ps -ef -o pid= -o args=|\ 79 | grep "$RUNNER_BASE_DIR/.*/[b]eam"|awk '{print $1}'` 80 | ;; 81 | CYGWIN*) 82 | # UID PID PPID TTY STIME COMMAND 83 | PID=`ps -efW|grep "$RUNNER_BASE_DIR/.*/[b]eam"|awk '{print $2}'` 84 | ;; 85 | esac 86 | $NODETOOL stop 87 | while `kill -0 $PID 2>/dev/null`; 88 | do 89 | sleep 1 90 | done 91 | ;; 92 | 93 | restart) 94 | ## Restart the VM without exiting the process 95 | $NODETOOL restart 96 | ;; 97 | 98 | reboot) 99 | ## Restart the VM completely (uses heart to restart it) 100 | $NODETOOL reboot 101 | ;; 102 | 103 | ping) 104 | ## See if the VM is alive 105 | $NODETOOL ping 106 | ;; 107 | 108 | attach) 109 | # Make sure a node IS running 110 | RES=`$NODETOOL ping` 111 | if [ "$RES" != "pong" ]; then 112 | echo "Node is not running!" 113 | exit 1 114 | fi 115 | 116 | shift 117 | $ERTS_PATH/to_erl $PIPE_DIR 118 | ;; 119 | 120 | console) 121 | # Setup beam-required vars 122 | ROOTDIR=$RUNNER_BASE_DIR 123 | BINDIR=$ROOTDIR/erts-$ERTS_VSN/bin 124 | EMU=beam 125 | PROGNAME=`echo $0 | sed 's/.*\\///'` 126 | CMD="$BINDIR/erlexec -boot $RUNNER_BASE_DIR/releases/$APP_VSN/$SCRIPT -embedded -config $RUNNER_ETC_DIR/app.config -args_file $RUNNER_ETC_DIR/vm.args -- ${1+"$@"}" 127 | export EMU 128 | export ROOTDIR 129 | export BINDIR 130 | export PROGNAME 131 | 132 | # Dump environment info for logging purposes 133 | echo "Exec: $CMD" 134 | echo "Root: $ROOTDIR" 135 | 136 | # Log the startup 137 | logger -t "$SCRIPT[$$]" "Starting up" 138 | 139 | # Start the VM 140 | exec $CMD 141 | ;; 142 | 143 | *) 144 | echo "Usage: $SCRIPT {start|stop|restart|reboot|ping|console|attach}" 145 | exit 1 146 | ;; 147 | esac 148 | 149 | exit 0 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Chloe 2 | ===== 3 | 4 | A realtime web server that doesn't suck... or at least won't suck when it's 5 | finished. 6 | 7 | How it Works 8 | ------------ 9 | 10 | The User's browser loads your page [1], which instantiates a connection to 11 | Chloe using JavaScript. It can then send data over that connection [2], which 12 | will be relayed to your app via a POST from Chloe [3]. When you have data that 13 | you want to send back to the browser, send a POST to Chloe [4], which will 14 | relay it back to the connected browser [5]. 15 | 16 | 2. Send data over websockets 17 | +---------------------------------------------------------------------------------------------+ 18 | | v 19 | +------------------------+ 1. /index.html +----------+ 4. POST /send (data for browser) +-------+ 20 | | Browser | ----------------> | Your App | ----------------------------------> | Chloe | -+ 21 | +------------------------+ +----------+ +-------+ | 22 | ^ ^ 3. Data from the browser | | 23 | | 5. Data from your app +------------------------------------------------+ | 24 | | | 25 | | | 26 | +------------------------------------------------------------------------------------------------------+ 27 | 28 | Support 29 | ------- 30 | 31 | Mailing List: `http://groups.google.com/group/chloe-ws` 32 | 33 | Installation 34 | ------------ 35 | 36 | Mac Binary 37 | ========== 38 | 39 | curl -LO https://github.com/downloads/mashion/chloe/chloe-0.0.5-osx.tgz 40 | tar xzvf chloe-0.0.5-osx.tgz 41 | cd chloe-0.0.5 42 | ./bin/chloe start 43 | 44 | Ubuntu Binary 45 | ============= 46 | 47 | wget https://github.com/downloads/mashion/chloe/chloe-0.0.5-ubuntu-32.tgz 48 | tar xzvf chloe-0.0.5-ubuntu32.tgz 49 | cd chloe-0.0.5 50 | ./bin/chloe start 51 | 52 | From Source 53 | =========== 54 | 55 | - Get you some erlang, on a mac: 56 | 57 | brew install erlang 58 | 59 | - Get rebar: [start here](https://github.com/basho/rebar/wiki/Getting-started). Or for mac: 60 | 61 | brew install rebar 62 | 63 | - Clone this repo: 64 | 65 | git clone https://github.com/mashion/chloe.git 66 | cd chloe 67 | 68 | - Run these commands 69 | 70 | gem install rake 71 | rake bootstrap 72 | rake server 73 | 74 | - get sinatra and run the demo app in another terminal window 75 | 76 | gem install sinatra 77 | rake demo 78 | 79 | - Point your browser at http://localhost:4567 and open up the javascript console 80 | - demo.js sets up a chloe variable that is already connected to the server, do 81 | the following in the console: 82 | 83 | chloe.send("hi mom"); 84 | 85 | - Relish the awesome. 86 | - To stop Chloe, enter `q().` back in erlang 87 | - To stop the demo app, use Ctrl+C 88 | 89 | JS API 90 | ------ 91 | 92 | Instantiate a Chloe object: 93 | 94 | var chloe = new Chloe({host: 'localhost', port: 8901}); 95 | 96 | Define a function for handling incoming messages: 97 | 98 | chloe.onmessage(function (message) { 99 | console.log('I got a message: ' + message); 100 | }); 101 | 102 | Connect to Chloe and send a message: 103 | 104 | chloe.connect(function () { 105 | chloe.send('Ohai!'); 106 | }); 107 | 108 | Subscribe to a channel (note that we do this within the `connect` callback, 109 | because subscribing to a channel requires sending a message to Chloe). 110 | 111 | chloe.connect(function () { 112 | chloe.subscribe('pumpkin', function (message) { 113 | console.log('Someone was eating pumpkins: ' + message); 114 | }); 115 | }); 116 | 117 | Server Side API 118 | --------------- 119 | 120 | Sending a message to Chloe, which will then be sent to all connected browsers: 121 | 122 | curl -d "data=This is the message data" http://localhost:8901/send 123 | 124 | Sending a message to a specific channel in chloe: 125 | 126 | curl -d "channel=pumpkin&data=Trotter" http://localhost:8901/send 127 | 128 | Configuring Chloe 129 | ----------------- 130 | 131 | Chloe has a single configuration file, `chloe-0.0.5/etc/app.config`. Allowed 132 | options for the Chloe application are: 133 | 134 | - **application_server_url**: The url for the application server, ex: http://localhost:4567/updates 135 | - **port**: The port on which chloe runs (default: 8901) 136 | - **log_dir**: Where the Chloe log files will go (default: `.`) 137 | 138 | Transport Types 139 | --------------- 140 | 141 | Chloe currently supports Websockets, XHR, and JSONP as possible transports. If your 142 | browser does not support WebSockets, we'll intelligently fallback to JSONP or XHR. 143 | 144 | Caveats 145 | ------- 146 | 147 | Our primary test browser is Chrome. If you're using any other browser, please 148 | let us know if you run into issues. 149 | 150 | This sucker is pretty alpha right now. There's a number of undocumented 151 | features, but everything that is documented seems to work. As with all alpha, 152 | open source software, ymmv. Pull requests gladly accepted! 153 | -------------------------------------------------------------------------------- /apps/chloe/src/chloe_session.erl: -------------------------------------------------------------------------------- 1 | -module(chloe_session). 2 | 3 | -behaviour(gen_server). 4 | -include_lib("./chloe.hrl"). 5 | 6 | %% API 7 | -export([ 8 | start_link/1, 9 | send_to_server/2, 10 | subscribe/2, 11 | send_to_browser/3, 12 | retrieve_messages/1, 13 | attach_transport_pid/2 14 | ]). 15 | 16 | %% gen_server callbacks 17 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 18 | terminate/2, code_change/3]). 19 | 20 | -record(state, {transport_pid, messages, failed_health_checks}). 21 | 22 | -define(TIMEOUT, 13 * 1000). % Primes are fun 23 | -define(HEALTH_CHECK_THRESHOLD, 4). % Five fails == death 24 | 25 | %%-------------------------------------------------------------------- 26 | %% API functions 27 | %%-------------------------------------------------------------------- 28 | 29 | start_link(TransportPid) -> 30 | gen_server:start_link(?MODULE, [TransportPid], []). 31 | 32 | send_to_server(Pid, Data) -> 33 | gen_server:cast(Pid, {send_to_server, Data}). 34 | 35 | subscribe(Pid, Channel) -> 36 | gen_server:cast(Pid, {subscribe, Channel}). 37 | 38 | send_to_browser(Pid, Channel, Data) -> 39 | gen_server:cast(Pid, {send_to_browser, [Channel, Data]}). 40 | 41 | retrieve_messages(Pid) -> 42 | gen_server:call(Pid, retrieve_messages). 43 | 44 | attach_transport_pid(Pid, TransportPid) -> 45 | gen_server:cast(Pid, {attach_transport_pid, TransportPid}). 46 | 47 | %%-------------------------------------------------------------------- 48 | %% gen_server callbacks 49 | %%-------------------------------------------------------------------- 50 | 51 | init([TransportPid]) -> 52 | chloe_channel_store:subscribe("/all", self()), 53 | {ok, #state{transport_pid = TransportPid, messages = [], failed_health_checks = 0}, ?TIMEOUT}. 54 | 55 | handle_call(retrieve_messages, _From, State) -> 56 | Messages = State#state.messages, 57 | error_logger:info_msg("Old State was ~p~n", [State]), 58 | NewState = State#state{messages= [] }, 59 | error_logger:info_msg("New State is ~p~n", [NewState]), 60 | {reply, Messages, NewState, ?TIMEOUT}; 61 | handle_call(_Request, _From, State) -> 62 | {reply, ok, State, ?TIMEOUT}. 63 | 64 | handle_cast({send_to_server, Data}, State) -> 65 | send_data_to_server(Data), 66 | {noreply, State, ?TIMEOUT}; 67 | handle_cast({subscribe, Channel}, State) -> 68 | error_logger:info_msg("Subscribing to channel ~p~n", [Channel]), 69 | chloe_channel_store:subscribe(Channel, self()), 70 | {noreply, State, ?TIMEOUT}; 71 | handle_cast({send_to_browser, [Channel, Data]}, State) -> 72 | %% TODO (trotter): Handle case where we have a pid but 73 | %% it's no longer active. 74 | NewState = case is_transport_available(State#state.transport_pid) of 75 | true -> send_message_to_browser(Channel, Data, State); 76 | _ -> store_message_for_later(Channel, Data, State) 77 | end, 78 | {noreply, NewState, ?TIMEOUT}; 79 | handle_cast({attach_transport_pid, TransportPid}, State) -> 80 | Messages = State#state.messages, 81 | case Messages of 82 | [] -> NewState = State; 83 | _ -> NewState = State#state{messages = []}, 84 | gen_server:cast(TransportPid, {send, [Messages]}) 85 | end, 86 | {noreply, NewState#state{transport_pid = TransportPid}, ?TIMEOUT}. 87 | 88 | handle_info(timeout, State) -> 89 | case check_transport_health(State) of 90 | dead -> {stop, normal, State}; 91 | unhealthy -> {noreply, 92 | State#state{failed_health_checks=State#state.failed_health_checks + 1}, 93 | ?TIMEOUT}; 94 | ok -> {noreply, 95 | State#state{failed_health_checks=0}, 96 | ?TIMEOUT} 97 | end; 98 | %% This will come to us as the response to our request 99 | %% to the app server. We don't care what the app server 100 | %% said. 101 | handle_info({http, _}, State) -> 102 | {noreply, State, ?TIMEOUT}. 103 | 104 | terminate(_Reason, _State) -> 105 | ok. 106 | 107 | code_change(_OldVsn, State, _Extra) -> 108 | {ok, State}. 109 | 110 | %%-------------------------------------------------------------------- 111 | %% Internal Functions 112 | %%-------------------------------------------------------------------- 113 | 114 | check_transport_health(State) -> 115 | case is_transport_available(State#state.transport_pid) of 116 | true -> ok; 117 | _ -> case State#state.failed_health_checks > ?HEALTH_CHECK_THRESHOLD of 118 | true -> dead; 119 | _ -> unhealthy 120 | end 121 | end. 122 | 123 | send_data_to_server(Data) -> 124 | {ok, Url} = application:get_env(chloe, application_server_url), 125 | {ok, Signature} = create_signature(Data), 126 | httpc:request(post, {Url, 127 | [{"x-chloe-signature", Signature}], 128 | "text/plain", 129 | Data}, 130 | [], [{sync, false}]). 131 | 132 | create_signature(Data) -> 133 | case application:get_env(chloe, secret) of 134 | undefined -> {ok, ""}; 135 | {ok, Secret} -> {ok, lib_md5:hexdigest(Data ++ Secret)} 136 | end. 137 | 138 | store_message_for_later(Channel, Data, State) -> 139 | Messages = [#message{channel=Channel, data=Data} | State#state.messages], 140 | State#state{messages=Messages, transport_pid=undefined}. 141 | 142 | send_message_to_browser(Channel, Data, State) -> 143 | Messages = [#message{channel=Channel, data=Data}], 144 | gen_server:cast(State#state.transport_pid, {send, [Messages]}), 145 | State. 146 | 147 | is_transport_available(TransportPid) -> 148 | case TransportPid of 149 | undefined -> false; 150 | _ -> is_process_alive(TransportPid) 151 | end. 152 | -------------------------------------------------------------------------------- /javascripts/json2.js: -------------------------------------------------------------------------------- 1 | // Create a JSON object only if one does not already exist. We create the 2 | // methods in a closure to avoid creating global variables. 3 | 4 | var JSON; 5 | if (!JSON) { 6 | JSON = {}; 7 | } 8 | 9 | (function () { 10 | "use strict"; 11 | 12 | function f(n) { 13 | // Format integers to have at least two digits. 14 | return n < 10 ? '0' + n : n; 15 | } 16 | 17 | if (typeof Date.prototype.toJSON !== 'function') { 18 | 19 | Date.prototype.toJSON = function (key) { 20 | 21 | return isFinite(this.valueOf()) ? 22 | this.getUTCFullYear() + '-' + 23 | f(this.getUTCMonth() + 1) + '-' + 24 | f(this.getUTCDate()) + 'T' + 25 | f(this.getUTCHours()) + ':' + 26 | f(this.getUTCMinutes()) + ':' + 27 | f(this.getUTCSeconds()) + 'Z' : null; 28 | }; 29 | 30 | String.prototype.toJSON = 31 | Number.prototype.toJSON = 32 | Boolean.prototype.toJSON = function (key) { 33 | return this.valueOf(); 34 | }; 35 | } 36 | 37 | var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, 38 | escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, 39 | gap, 40 | indent, 41 | meta = { // table of character substitutions 42 | '\b': '\\b', 43 | '\t': '\\t', 44 | '\n': '\\n', 45 | '\f': '\\f', 46 | '\r': '\\r', 47 | '"' : '\\"', 48 | '\\': '\\\\' 49 | }, 50 | rep; 51 | 52 | 53 | function quote(string) { 54 | 55 | // If the string contains no control characters, no quote characters, and no 56 | // backslash characters, then we can safely slap some quotes around it. 57 | // Otherwise we must also replace the offending characters with safe escape 58 | // sequences. 59 | 60 | escapable.lastIndex = 0; 61 | return escapable.test(string) ? '"' + string.replace(escapable, function (a) { 62 | var c = meta[a]; 63 | return typeof c === 'string' ? c : 64 | '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); 65 | }) + '"' : '"' + string + '"'; 66 | } 67 | 68 | 69 | function str(key, holder) { 70 | 71 | // Produce a string from holder[key]. 72 | 73 | var i, // The loop counter. 74 | k, // The member key. 75 | v, // The member value. 76 | length, 77 | mind = gap, 78 | partial, 79 | value = holder[key]; 80 | 81 | // If the value has a toJSON method, call it to obtain a replacement value. 82 | 83 | if (value && typeof value === 'object' && 84 | typeof value.toJSON === 'function') { 85 | value = value.toJSON(key); 86 | } 87 | 88 | // If we were called with a replacer function, then call the replacer to 89 | // obtain a replacement value. 90 | 91 | if (typeof rep === 'function') { 92 | value = rep.call(holder, key, value); 93 | } 94 | 95 | // What happens next depends on the value's type. 96 | 97 | switch (typeof value) { 98 | case 'string': 99 | return quote(value); 100 | 101 | case 'number': 102 | 103 | // JSON numbers must be finite. Encode non-finite numbers as null. 104 | 105 | return isFinite(value) ? String(value) : 'null'; 106 | 107 | case 'boolean': 108 | case 'null': 109 | 110 | // If the value is a boolean or null, convert it to a string. Note: 111 | // typeof null does not produce 'null'. The case is included here in 112 | // the remote chance that this gets fixed someday. 113 | 114 | return String(value); 115 | 116 | // If the type is 'object', we might be dealing with an object or an array or 117 | // null. 118 | 119 | case 'object': 120 | 121 | // Due to a specification blunder in ECMAScript, typeof null is 'object', 122 | // so watch out for that case. 123 | 124 | if (!value) { 125 | return 'null'; 126 | } 127 | 128 | // Make an array to hold the partial results of stringifying this object value. 129 | 130 | gap += indent; 131 | partial = []; 132 | 133 | // Is the value an array? 134 | 135 | if (Object.prototype.toString.apply(value) === '[object Array]') { 136 | 137 | // The value is an array. Stringify every element. Use null as a placeholder 138 | // for non-JSON values. 139 | 140 | length = value.length; 141 | for (i = 0; i < length; i += 1) { 142 | partial[i] = str(i, value) || 'null'; 143 | } 144 | 145 | // Join all of the elements together, separated with commas, and wrap them in 146 | // brackets. 147 | 148 | v = partial.length === 0 ? '[]' : gap ? 149 | '[\n' + gap + partial.join(',\n' + gap) + '\n' + mind + ']' : 150 | '[' + partial.join(',') + ']'; 151 | gap = mind; 152 | return v; 153 | } 154 | 155 | // If the replacer is an array, use it to select the members to be stringified. 156 | 157 | if (rep && typeof rep === 'object') { 158 | length = rep.length; 159 | for (i = 0; i < length; i += 1) { 160 | if (typeof rep[i] === 'string') { 161 | k = rep[i]; 162 | v = str(k, value); 163 | if (v) { 164 | partial.push(quote(k) + (gap ? ': ' : ':') + v); 165 | } 166 | } 167 | } 168 | } else { 169 | 170 | // Otherwise, iterate through all of the keys in the object. 171 | 172 | for (k in value) { 173 | if (Object.prototype.hasOwnProperty.call(value, k)) { 174 | v = str(k, value); 175 | if (v) { 176 | partial.push(quote(k) + (gap ? ': ' : ':') + v); 177 | } 178 | } 179 | } 180 | } 181 | 182 | // Join all of the member texts together, separated with commas, 183 | // and wrap them in braces. 184 | 185 | v = partial.length === 0 ? '{}' : gap ? 186 | '{\n' + gap + partial.join(',\n' + gap) + '\n' + mind + '}' : 187 | '{' + partial.join(',') + '}'; 188 | gap = mind; 189 | return v; 190 | } 191 | } 192 | 193 | // If the JSON object does not yet have a stringify method, give it one. 194 | 195 | if (typeof JSON.stringify !== 'function') { 196 | JSON.stringify = function (value, replacer, space) { 197 | 198 | // The stringify method takes a value and an optional replacer, and an optional 199 | // space parameter, and returns a JSON text. The replacer can be a function 200 | // that can replace values, or an array of strings that will select the keys. 201 | // A default replacer method can be provided. Use of the space parameter can 202 | // produce text that is more easily readable. 203 | 204 | var i; 205 | gap = ''; 206 | indent = ''; 207 | 208 | // If the space parameter is a number, make an indent string containing that 209 | // many spaces. 210 | 211 | if (typeof space === 'number') { 212 | for (i = 0; i < space; i += 1) { 213 | indent += ' '; 214 | } 215 | 216 | // If the space parameter is a string, it will be used as the indent string. 217 | 218 | } else if (typeof space === 'string') { 219 | indent = space; 220 | } 221 | 222 | // If there is a replacer, it must be a function or an array. 223 | // Otherwise, throw an error. 224 | 225 | rep = replacer; 226 | if (replacer && typeof replacer !== 'function' && 227 | (typeof replacer !== 'object' || 228 | typeof replacer.length !== 'number')) { 229 | throw new Error('JSON.stringify'); 230 | } 231 | 232 | // Make a fake root object containing our value under the key of ''. 233 | // Return the result of stringifying the value. 234 | 235 | return str('', {'': value}); 236 | }; 237 | } 238 | 239 | 240 | // If the JSON object does not yet have a parse method, give it one. 241 | 242 | if (typeof JSON.parse !== 'function') { 243 | JSON.parse = function (text, reviver) { 244 | 245 | // The parse method takes a text and an optional reviver function, and returns 246 | // a JavaScript value if the text is a valid JSON text. 247 | 248 | var j; 249 | 250 | function walk(holder, key) { 251 | 252 | // The walk method is used to recursively walk the resulting structure so 253 | // that modifications can be made. 254 | 255 | var k, v, value = holder[key]; 256 | if (value && typeof value === 'object') { 257 | for (k in value) { 258 | if (Object.prototype.hasOwnProperty.call(value, k)) { 259 | v = walk(value, k); 260 | if (v !== undefined) { 261 | value[k] = v; 262 | } else { 263 | delete value[k]; 264 | } 265 | } 266 | } 267 | } 268 | return reviver.call(holder, key, value); 269 | } 270 | 271 | 272 | // Parsing happens in four stages. In the first stage, we replace certain 273 | // Unicode characters with escape sequences. JavaScript handles many characters 274 | // incorrectly, either silently deleting them, or treating them as line endings. 275 | 276 | text = String(text); 277 | cx.lastIndex = 0; 278 | if (cx.test(text)) { 279 | text = text.replace(cx, function (a) { 280 | return '\\u' + 281 | ('0000' + a.charCodeAt(0).toString(16)).slice(-4); 282 | }); 283 | } 284 | 285 | // In the second stage, we run the text against regular expressions that look 286 | // for non-JSON patterns. We are especially concerned with '()' and 'new' 287 | // because they can cause invocation, and '=' because it can cause mutation. 288 | // But just to be safe, we want to reject all unexpected forms. 289 | 290 | // We split the second stage into 4 regexp operations in order to work around 291 | // crippling inefficiencies in IE's and Safari's regexp engines. First we 292 | // replace the JSON backslash pairs with '@' (a non-JSON character). Second, we 293 | // replace all simple value tokens with ']' characters. Third, we delete all 294 | // open brackets that follow a colon or comma or that begin the text. Finally, 295 | // we look to see that the remaining characters are only whitespace or ']' or 296 | // ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval. 297 | 298 | if (/^[\],:{}\s]*$/ 299 | .test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@') 300 | .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']') 301 | .replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { 302 | 303 | // In the third stage we use the eval function to compile the text into a 304 | // JavaScript structure. The '{' operator is subject to a syntactic ambiguity 305 | // in JavaScript: it can begin a block or an object literal. We wrap the text 306 | // in parens to eliminate the ambiguity. 307 | 308 | j = eval('(' + text + ')'); 309 | 310 | // In the optional fourth stage, we recursively walk the new structure, passing 311 | // each name/value pair to a reviver function for possible transformation. 312 | 313 | return typeof reviver === 'function' ? 314 | walk({'': j}, '') : j; 315 | } 316 | 317 | // If the text is not JSON parseable, then a SyntaxError is thrown. 318 | 319 | throw new SyntaxError('JSON.parse'); 320 | }; 321 | } 322 | }()); 323 | -------------------------------------------------------------------------------- /apps/chloe/test/gen_server_mock.erl: -------------------------------------------------------------------------------- 1 | 2 | %%%------------------------------------------------------------------- 3 | %%% File : gen_server_mock.erl 4 | %%% Author : nmurray@attinteractive.com 5 | %%% Description : Mocking for gen_server. Expectations are ordered, every 6 | %%% message required and no messages more than are expected are allowed. 7 | %%% 8 | %%% Expectations get the same input as the handle_(whatever) gen_server methods. They should return -> ok | {ok, NewState} 9 | %%% Created : 2009-08-05 10 | %%% Inspired by: http://erlang.org/pipermail/erlang-questions/2008-April/034140.html 11 | %%%------------------------------------------------------------------- 12 | 13 | -module(gen_server_mock). 14 | -behaviour(gen_server). 15 | 16 | % API 17 | -export([new/0, new/1, stop/1, crash/1, 18 | expect/3, expect_call/2, expect_info/2, expect_cast/2, 19 | assert_expectations/1]). 20 | 21 | % gen_server callbacks 22 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, 23 | code_change/3]). 24 | 25 | %% Macros 26 | -define(SERVER, ?MODULE). 27 | -define(DEFAULT_CONFIG, {}). 28 | 29 | -record(state, { 30 | expectations 31 | }). 32 | 33 | -record(expectation, { 34 | type, 35 | lambda 36 | }). 37 | 38 | % steal assert from eunit 39 | -define(assert(BoolExpr), 40 | ((fun () -> 41 | case (BoolExpr) of 42 | true -> ok; 43 | __V -> .erlang:error({assertion_failed, 44 | [{module, ?MODULE}, 45 | {line, ?LINE}, 46 | {expression, (??BoolExpr)}, 47 | {expected, true}, 48 | {value, case __V of false -> __V; 49 | _ -> {not_a_boolean,__V} 50 | end}]}) 51 | end 52 | end)())). 53 | 54 | -define(raise(ErrorName), 55 | erlang:error({ErrorName, 56 | [{module, ?MODULE}, 57 | {line, ?LINE}]})). 58 | 59 | -define(raise_info(ErrorName, Info), 60 | erlang:error({ErrorName, 61 | [{module, ?MODULE}, 62 | {line, ?LINE}, 63 | {info, Info} 64 | ]})). 65 | 66 | 67 | -define (DEBUG, true). 68 | -define (TRACE(X, M), case ?DEBUG of 69 | true -> io:format(user, "TRACE ~p:~p ~p ~p~n", [?MODULE, ?LINE, X, M]); 70 | false -> ok 71 | end). 72 | 73 | %%==================================================================== 74 | %% API 75 | %%==================================================================== 76 | %%-------------------------------------------------------------------- 77 | %% Function: start() -> {ok,Pid} | ignore | {error,Error} 78 | %% Description: Alias for start_link 79 | %%-------------------------------------------------------------------- 80 | start() -> 81 | start_link([]). 82 | 83 | %%-------------------------------------------------------------------- 84 | %% Function: start_link() -> {ok,Pid} | ignore | {error,Error} 85 | %% Description: Starts the server 86 | %%-------------------------------------------------------------------- 87 | start_link(Config) -> 88 | gen_server:start_link(?MODULE, [Config], []). % start a nameless server 89 | 90 | %%-------------------------------------------------------------------- 91 | %% Function: new() -> {ok, Mock} | {error, Error} 92 | %% Description: 93 | %%-------------------------------------------------------------------- 94 | new() -> 95 | case start() of 96 | {ok, Pid} -> 97 | {ok, Pid}; 98 | {error, Error} -> 99 | {error, Error}; 100 | Other -> 101 | {error, Other} 102 | end. 103 | 104 | %%-------------------------------------------------------------------- 105 | %% Function: new(N) when is_integer(N) -> [Pids] 106 | %% Description: Return multiple Mock gen_servers 107 | %%-------------------------------------------------------------------- 108 | new(N) when is_integer(N) -> % list() of Pids 109 | lists:map(fun(_) -> {ok, Mock} = new(), Mock end, lists:seq(1, N)). 110 | 111 | %%-------------------------------------------------------------------- 112 | %% Function: expect(Mock, Type, Callback) -> ok 113 | %% Types: Mock = pid() 114 | %% Type = atom() = call | cast | info 115 | %% Callback = fun(Args) -> ok | {ok, NewState} | {ok, ResponseValue, NewState} 116 | %% Args matches signature of handle_* in gen_server. e.g. handle_call 117 | %% 118 | %% Description: Set an expectation of Type 119 | %%-------------------------------------------------------------------- 120 | expect(Mock, Type, Callback) -> 121 | Exp = #expectation{type=Type, lambda=Callback}, 122 | added = gen_server:call(Mock, {expect, Exp}), 123 | ok. 124 | 125 | %%-------------------------------------------------------------------- 126 | %% Function: expect_call(Mock, Callback) -> ok 127 | %% Types: Mock = pid() 128 | %% Callback = fun(Args) -> ok | {ok, NewState} | {ok, ResponseValue, NewState} 129 | %% Args matches signature of handle_call in gen_server. 130 | %% 131 | %% Description: Set a call expectation 132 | %%-------------------------------------------------------------------- 133 | expect_call(Mock, Callback) -> 134 | expect(Mock, call, Callback). 135 | 136 | %%-------------------------------------------------------------------- 137 | %% Function: expect_info(Mock, Callback) -> ok 138 | %% Types: Mock = pid() 139 | %% Callback = fun(Args) -> ok | {ok, NewState} | {ok, ResponseValue, NewState} 140 | %% Args matches signature of handle_info in gen_server. 141 | %% 142 | %% Description: Set a info expectation 143 | %%-------------------------------------------------------------------- 144 | expect_info(Mock, Callback) -> 145 | expect(Mock, info, Callback). 146 | 147 | %%-------------------------------------------------------------------- 148 | %% Function: expect_cast(Mock, Callback) -> ok 149 | %% Types: Mock = pid() 150 | %% Callback = fun(Args) -> ok | {ok, NewState} | {ok, ResponseValue, NewState} 151 | %% Args matches signature of handle_cast in gen_server. 152 | %% 153 | %% Description: Set a cast expectation 154 | %%-------------------------------------------------------------------- 155 | expect_cast(Mock, Callback) -> 156 | expect(Mock, cast, Callback). 157 | 158 | %%-------------------------------------------------------------------- 159 | %% Function: assert_expectations(Mock)-> ok 160 | %% Types: Mock = pid() | [Mocks] 161 | %% Description: Ensure expectations were fully met 162 | %%-------------------------------------------------------------------- 163 | assert_expectations(Mock) when is_pid(Mock) -> 164 | assert_expectations([Mock]); 165 | assert_expectations([H|T]) -> 166 | timer:sleep(1), % This sleep(1) seems to give the other processes time 167 | % to send their requests through. 168 | gen_server:call(H, assert_expectations), 169 | ok = assert_expectations(T); 170 | assert_expectations([]) -> 171 | ok. 172 | 173 | %%-------------------------------------------------------------------- 174 | %% Function: stop(Mock)-> ok 175 | %% Types: Mock = pid() | [Mocks] 176 | %% Description: Stop the Mock gen_server normally 177 | %%-------------------------------------------------------------------- 178 | stop(H) when is_pid(H) -> 179 | stop([H]); 180 | stop([H|T]) -> 181 | gen_server:cast(H, {'$gen_server_mock', stop}), 182 | stop(T); 183 | stop([]) -> 184 | ok. 185 | 186 | crash(H) when is_pid(H) -> 187 | crash([H]); 188 | crash([H|T]) -> 189 | gen_server:cast(H, {'$gen_server_mock', crash}), 190 | crash(T); 191 | crash([]) -> 192 | ok. 193 | 194 | 195 | 196 | %%==================================================================== 197 | %% gen_server callbacks 198 | %%==================================================================== 199 | 200 | %%-------------------------------------------------------------------- 201 | %% Function: init(Args) -> {ok, State} | 202 | %% {ok, State, Timeout} | 203 | %% ignore | 204 | %% {stop, Reason} 205 | %% Description: Initiates the server 206 | %%-------------------------------------------------------------------- 207 | 208 | init(_Args) -> 209 | InitialState = #state{expectations=[]}, 210 | {ok, InitialState}. 211 | 212 | %%-------------------------------------------------------------------- 213 | %% Function: %% handle_call(Request, From, State) -> {reply, Reply, State} | 214 | %% {reply, Reply, State, Timeout} | 215 | %% {noreply, State} | 216 | %% {noreply, State, Timeout} | 217 | %% {stop, Reason, Reply, State} | 218 | %% {stop, Reason, State} 219 | %% Description: Handling call messages 220 | %%-------------------------------------------------------------------- 221 | 222 | % return the state 223 | handle_call(state, _From, State) -> 224 | {reply, {ok, State}, State}; 225 | 226 | handle_call({expect, Expectation}, _From, State) -> 227 | {ok, NewState} = store_expectation(Expectation, State), 228 | {reply, added, NewState}; 229 | 230 | handle_call(assert_expectations, _From, State) -> 231 | {ok, NewState} = handle_assert_expectations(State), 232 | {reply, ok, NewState}; 233 | 234 | handle_call(Request, From, State) -> 235 | {ok, Reply, NewState} = reply_with_next_expectation(call, Request, From, undef, undef, State), 236 | {reply, Reply, NewState}. 237 | 238 | %%-------------------------------------------------------------------- 239 | %% Function: handle_cast(Msg, State) -> {noreply, State} | 240 | %% {noreply, State, Timeout} | 241 | %% {stop, Reason, State} 242 | %% Description: Handling cast messages 243 | %%-------------------------------------------------------------------- 244 | handle_cast({'$gen_server_mock', stop}, State) -> 245 | {stop, normal, State}; 246 | handle_cast({'$gen_server_mock', crash}, State) -> 247 | {stop, crash, State}; 248 | handle_cast(Msg, State) -> 249 | {ok, _Reply, NewState} = reply_with_next_expectation(cast, undef, undef, Msg, undef, State), 250 | {noreply, NewState}. 251 | 252 | %%-------------------------------------------------------------------- 253 | %% Function: handle_info(Info, State) -> {noreply, State} | 254 | %% {noreply, State, Timeout} | 255 | %% {stop, Reason, State} 256 | %% Description: Handling all non call/cast messages 257 | %%-------------------------------------------------------------------- 258 | handle_info(Info, State) -> 259 | {ok, _Reply, NewState} = reply_with_next_expectation(info, undef, undef, undef, Info, State), 260 | {noreply, NewState}. 261 | 262 | %%-------------------------------------------------------------------- 263 | %% Function: terminate(Reason, State) -> void() 264 | %% Description: This function is called by a gen_server when it is about to 265 | %% terminate. It should be the opposite of Module:init/1 and do any necessary 266 | %% cleaning up. When it returns, the gen_server terminates with Reason. 267 | %% The return value is ignored. 268 | %%-------------------------------------------------------------------- 269 | terminate(_Reason, _State) -> 270 | ok. 271 | 272 | %%-------------------------------------------------------------------- 273 | %% Func: code_change(OldVsn, State, Extra) -> {ok, NewState} 274 | %% Description: Convert process state when code is changed 275 | %%-------------------------------------------------------------------- 276 | code_change(_OldVsn, State, _Extra) -> 277 | {ok, State}. 278 | 279 | %% 280 | %% private functions 281 | %% 282 | store_expectation(Expectation, State) -> % {ok, NewState} 283 | NewExpectations = [Expectation|State#state.expectations], 284 | NewState = State#state{expectations = NewExpectations}, 285 | {ok, NewState}. 286 | 287 | pop(L) -> % {Result, NewList} | {undef, []} 288 | case L of 289 | [] -> {undef, []}; 290 | List -> {lists:last(List), lists:sublist(List, 1, length(List) - 1)} 291 | end. 292 | 293 | pop_expectation(State) -> % {ok, Expectation, NewState} 294 | {Expectation, RestExpectations} = case pop(State#state.expectations) of 295 | {undef, []} -> ?raise(no_gen_server_mock_expectation); 296 | {Head, Rest} -> {Head, Rest} 297 | end, 298 | NewState = State#state{expectations = RestExpectations}, 299 | {ok, Expectation, NewState}. 300 | 301 | handle_assert_expectations(State) -> % {ok, State} 302 | ExpLeft = State#state.expectations, 303 | case length(ExpLeft) > 0 of 304 | true -> ?raise_info(unmet_gen_server_expectation, ExpLeft); 305 | false -> ok 306 | end, 307 | {ok, State}. 308 | 309 | reply_with_next_expectation(Type, Request, From, Msg, Info, State) -> % -> {ok, Reply, NewState} 310 | {ok, Expectation, NewState} = pop_expectation(State), 311 | ?assert(Type =:= Expectation#expectation.type), % todo, have a useful error message, "expected this got that" 312 | 313 | {ok, Reply, NewState2} = try call_expectation_lambda(Expectation, Type, Request, From, Msg, Info, NewState) of 314 | {ok, R, State2} -> {ok, R, State2} 315 | catch 316 | error:function_clause -> 317 | ?raise_info(unexpected_request_made, {Expectation, Type, Request, From, Msg, Info, NewState}) 318 | end, 319 | {ok, Reply, NewState2}. 320 | 321 | % hmm what if we want better response. 322 | call_expectation_lambda(Expectation, Type, Request, From, Msg, Info, State) -> % {ok, NewState} 323 | L = Expectation#expectation.lambda, 324 | Response = case Type of 325 | call -> L(Request, From, State); 326 | cast -> L(Msg, State); 327 | info -> L(Info, State); 328 | _ -> L(Request, From, Msg, Info, State) 329 | end, 330 | case Response of % hmmm 331 | ok -> {ok, ok, State}; 332 | {ok, NewState} -> {ok, ok, NewState}; 333 | {ok, ResponseValue, NewState} -> {ok, ResponseValue, NewState}; 334 | Other -> Other 335 | end. 336 | --------------------------------------------------------------------------------