├── .gitignore ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── alondra.gemspec ├── app ├── assets │ ├── javascripts │ │ ├── alondra-client.js.coffee.erb │ │ ├── moz_websocket.js │ │ └── vendor │ │ │ ├── jquery.json-2.2.js │ │ │ ├── json2.js │ │ │ ├── swfobject.js │ │ │ └── web_socket.js │ └── swf │ │ └── WebSocketMain.swf └── helpers │ └── alondra_helper.rb ├── bin └── alondra ├── lib ├── alondra.rb ├── alondra │ ├── changes_callbacks.rb │ ├── changes_push.rb │ ├── channel.rb │ ├── command.rb │ ├── command_dispatcher.rb │ ├── connection.rb │ ├── event.rb │ ├── event_listener.rb │ ├── event_router.rb │ ├── listener_callback.rb │ ├── log.rb │ ├── message.rb │ ├── message_queue.rb │ ├── message_queue_client.rb │ ├── push_controller.rb │ ├── pushing.rb │ ├── server.rb │ ├── session_parser.rb │ └── version.rb ├── generators │ └── alondra │ │ ├── USAGE │ │ ├── alondra_generator.rb │ │ └── templates │ │ └── alondra └── tasks │ └── alondra_tasks.rake ├── script └── rails └── test ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── javascripts │ │ │ └── application.js │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── application_controller.rb │ │ ├── chats_controller.rb │ │ ├── messages_controller.rb │ │ ├── sessions_controller.rb │ │ └── users_controller.rb │ ├── helpers │ │ ├── application_helper.rb │ │ ├── chats_helper.rb │ │ ├── error_messages_helper.rb │ │ └── layout_helper.rb │ ├── mailers │ │ └── .gitkeep │ ├── models │ │ ├── .gitkeep │ │ ├── chat.rb │ │ ├── message.rb │ │ └── user.rb │ └── views │ │ ├── chats │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ ├── new.html.erb │ │ └── show.html.erb │ │ ├── layouts │ │ └── application.html.erb │ │ ├── sessions │ │ └── new.html.erb │ │ ├── shared │ │ └── _message.js.erb │ │ └── users │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ ├── new.html.erb │ │ └── show.html.erb ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── backtrace_silencers.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── secret_token.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ └── routes.rb ├── db │ ├── migrate │ │ ├── 20110719090458_create_chats.rb │ │ ├── 20110719090538_create_messages.rb │ │ └── 20110720193249_create_users.rb │ └── schema.rb ├── lib │ └── controller_authentication.rb ├── log │ └── .gitkeep ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── favicon.ico │ └── stylesheets │ │ └── application.css └── script │ └── rails ├── integration ├── push_changes_test.rb └── push_messages_test.rb ├── models ├── channel_test.rb ├── command_test.rb ├── configuration_test.rb ├── connection_test.rb ├── event_listener_test.rb ├── event_router_test.rb ├── message_queue_client_test.rb ├── message_queue_test.rb └── pushing_test.rb ├── performance └── message_queue_performance.rb ├── support ├── factories.rb ├── integration_helper.rb ├── integration_test.rb └── mocks │ ├── bogus_event.rb │ ├── mock_connection.rb │ ├── mock_event_router.rb │ └── mock_listener.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .rvmrc 3 | .bundle/ 4 | log/*.log 5 | pkg/ 6 | test/dummy/db/*.sqlite3 7 | test/dummy/log/*.log 8 | test/dummy/tmp/ 9 | 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Pusher server 4 | gemspec 5 | 6 | # rails dependecies 7 | gem "mysql2" 8 | 9 | # Rails 3.1 - Asset Pipeline 10 | gem 'json' 11 | gem 'sass-rails' 12 | gem 'coffee-script' 13 | gem 'uglifier' 14 | 15 | # Rails 3.1 - JavaScript 16 | gem 'jquery-rails' 17 | 18 | gem 'capybara' 19 | gem 'capybara-webkit' 20 | gem 'launchy' 21 | gem 'factory_girl' 22 | 23 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | alondra (0.1.1) 5 | daemons 6 | em-websocket 7 | em-zeromq (>= 0.3.1) 8 | rails (>= 3.1.0) 9 | uuidtools 10 | 11 | GEM 12 | remote: http://rubygems.org/ 13 | specs: 14 | actionmailer (3.2.8) 15 | actionpack (= 3.2.8) 16 | mail (~> 2.4.4) 17 | actionpack (3.2.8) 18 | activemodel (= 3.2.8) 19 | activesupport (= 3.2.8) 20 | builder (~> 3.0.0) 21 | erubis (~> 2.7.0) 22 | journey (~> 1.0.4) 23 | rack (~> 1.4.0) 24 | rack-cache (~> 1.2) 25 | rack-test (~> 0.6.1) 26 | sprockets (~> 2.1.3) 27 | activemodel (3.2.8) 28 | activesupport (= 3.2.8) 29 | builder (~> 3.0.0) 30 | activerecord (3.2.8) 31 | activemodel (= 3.2.8) 32 | activesupport (= 3.2.8) 33 | arel (~> 3.0.2) 34 | tzinfo (~> 0.3.29) 35 | activeresource (3.2.8) 36 | activemodel (= 3.2.8) 37 | activesupport (= 3.2.8) 38 | activesupport (3.2.8) 39 | i18n (~> 0.6) 40 | multi_json (~> 1.0) 41 | addressable (2.3.2) 42 | arel (3.0.2) 43 | builder (3.0.0) 44 | capybara (1.1.2) 45 | mime-types (>= 1.16) 46 | nokogiri (>= 1.3.3) 47 | rack (>= 1.0.0) 48 | rack-test (>= 0.5.4) 49 | selenium-webdriver (~> 2.0) 50 | xpath (~> 0.1.4) 51 | capybara-webkit (0.12.1) 52 | capybara (>= 1.0.0, < 1.2) 53 | json 54 | childprocess (0.3.5) 55 | ffi (~> 1.0, >= 1.0.6) 56 | coffee-script (2.2.0) 57 | coffee-script-source 58 | execjs 59 | coffee-script-source (1.3.3) 60 | daemons (1.1.9) 61 | em-websocket (0.3.8) 62 | addressable (>= 2.1.1) 63 | eventmachine (>= 0.12.9) 64 | em-zeromq (0.3.1) 65 | eventmachine (= 1.0.0.beta.4) 66 | ffi (>= 1.0.0) 67 | ffi-rzmq (= 0.9.3) 68 | erubis (2.7.0) 69 | eventmachine (1.0.0.beta.4) 70 | execjs (1.4.0) 71 | multi_json (~> 1.0) 72 | factory_girl (4.0.0) 73 | activesupport (>= 3.0.0) 74 | ffi (1.1.5) 75 | ffi-rzmq (0.9.3) 76 | ffi 77 | hike (1.2.1) 78 | i18n (0.6.0) 79 | journey (1.0.4) 80 | jquery-rails (2.0.2) 81 | railties (>= 3.2.0, < 5.0) 82 | thor (~> 0.14) 83 | json (1.7.4) 84 | launchy (2.1.2) 85 | addressable (~> 2.3) 86 | libwebsocket (0.1.5) 87 | addressable 88 | mail (2.4.4) 89 | i18n (>= 0.4.0) 90 | mime-types (~> 1.16) 91 | treetop (~> 1.4.8) 92 | mime-types (1.19) 93 | multi_json (1.3.6) 94 | mysql2 (0.3.11) 95 | nokogiri (1.5.5) 96 | polyglot (0.3.3) 97 | rack (1.4.1) 98 | rack-cache (1.2) 99 | rack (>= 0.4) 100 | rack-ssl (1.3.2) 101 | rack 102 | rack-test (0.6.1) 103 | rack (>= 1.0) 104 | rails (3.2.8) 105 | actionmailer (= 3.2.8) 106 | actionpack (= 3.2.8) 107 | activerecord (= 3.2.8) 108 | activeresource (= 3.2.8) 109 | activesupport (= 3.2.8) 110 | bundler (~> 1.0) 111 | railties (= 3.2.8) 112 | railties (3.2.8) 113 | actionpack (= 3.2.8) 114 | activesupport (= 3.2.8) 115 | rack-ssl (~> 1.3.2) 116 | rake (>= 0.8.7) 117 | rdoc (~> 3.4) 118 | thor (>= 0.14.6, < 2.0) 119 | rake (0.9.2.2) 120 | rdoc (3.12) 121 | json (~> 1.4) 122 | rubyzip (0.9.9) 123 | sass (3.2.0) 124 | sass-rails (3.2.5) 125 | railties (~> 3.2.0) 126 | sass (>= 3.1.10) 127 | tilt (~> 1.3) 128 | selenium-webdriver (2.25.0) 129 | childprocess (>= 0.2.5) 130 | libwebsocket (~> 0.1.3) 131 | multi_json (~> 1.0) 132 | rubyzip 133 | sprockets (2.1.3) 134 | hike (~> 1.2) 135 | rack (~> 1.0) 136 | tilt (~> 1.1, != 1.3.0) 137 | thor (0.15.4) 138 | tilt (1.3.3) 139 | treetop (1.4.10) 140 | polyglot 141 | polyglot (>= 0.3.1) 142 | tzinfo (0.3.33) 143 | uglifier (1.2.7) 144 | execjs (>= 0.3.0) 145 | multi_json (~> 1.3) 146 | uuidtools (2.1.3) 147 | xpath (0.1.4) 148 | nokogiri (~> 1.3) 149 | 150 | PLATFORMS 151 | ruby 152 | 153 | DEPENDENCIES 154 | alondra! 155 | capybara 156 | capybara-webkit 157 | coffee-script 158 | factory_girl 159 | jquery-rails 160 | json 161 | launchy 162 | mysql2 163 | sass-rails 164 | uglifier 165 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2011 YOURNAME 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alondra 2 | 3 | Alondra is a push server and framework that adds real time capabilities to 4 | your rails applications. 5 | 6 | ## What can I do with Alondra? 7 | 8 | ### Subscribe clients to channels 9 | 10 | Alondra allows browsers to subscribe to channels. Any Ruby process that loads 11 | your Rails environment will be able to push messages to those channels. 12 | 13 | To subscribe to a channel you can use the built in helper: 14 | 15 | ``` 16 | <%= alondra_client @chat %> 17 | ``` 18 | 19 | Alondra uses [conventions to map records and classes to channel names](https://github.com/afcapel/alondra/wiki/Event-conventions). 20 | The last example will subscribe the browser to a channel named '/chats/:chat_id'. 21 | Then, the Alondra client will render any message pushed to that channel. 22 | 23 | If you don't want to use Alondra conventions, you can always provide your own 24 | channel names: 25 | 26 | ``` 27 | <%= alondra_client ['my custom channel', 'another channel'] %> 28 | ``` 29 | 30 | ### Sending push notifications 31 | 32 | Since Alondra is all Ruby and integrates with your Rails environment, you can 33 | use your Rails models and views to render push messages. For example, sending 34 | a push notification from your controller action is as simple as this: 35 | 36 | ```ruby 37 | def create 38 | @chat = Chat.find(params[:chat_id]) 39 | @message = @chat.messages.build(params[:message]) 40 | 41 | if @message.save 42 | push '/messages/create', :to => @chat 43 | end 44 | 45 | respond_with @message 46 | end 47 | ``` 48 | 49 | This will render the '/messages/create' view and send the results to all 50 | clients subscribed to the chat channel. 51 | 52 | You can send push notifications from any process that loads your Rails 53 | environment and from any class that includes the Alondra::Pushing module. 54 | When rendering a push message the local context (that is, the instance 55 | variables of the caller object) will be available in the view. 56 | 57 | ### Listening to events 58 | 59 | Alondra comes bundled with an EventListener class that allows you to react to 60 | events such as when a client subscribes to a channel. 61 | 62 | ```ruby 63 | # A ChatListener will by default listen to events 64 | # sent to any channel whose name begins with '/chat' 65 | class ChatListener < Alondra::EventListener 66 | 67 | # If you want to listen to other channels than the default ones 68 | # you can specify other patterns with the listen_to method, like 69 | # 70 | # listen_to /tion$/ 71 | # 72 | # That would make your listener receive events from any channel whose 73 | # name ends in 'ion' 74 | 75 | 76 | # This will be fired any time a client subscribes to 77 | # any of the observed channels 78 | on :subscribed, :to => :member do 79 | 80 | # If you use Cookie Based Session Store, 81 | # you can access the Rails session from the listener 82 | @user = User.find(session[:user_id]) 83 | 84 | # Push notifications from listener 85 | push '/users/user', :to => channel_name 86 | end 87 | end 88 | ``` 89 | 90 | You can also listen to :unsubscribe, :created, :updated, :destroyed or any 91 | custom event in the observed channels. 92 | 93 | ### Push record changes to the client 94 | 95 | Sometimes you are just interested in pushing record updates to subscribed 96 | clients. You can do that annotating your model: 97 | 98 | ```ruby 99 | class Presence < ActiveRecord::Base 100 | belongs_to :user 101 | belongs_to :chat 102 | 103 | push :changes, :to => :chat 104 | end 105 | ``` 106 | 107 | This will push an event (:created, :upated or :destroyed) to the chat channel 108 | each time a Message instance changes. 109 | 110 | In the client you can listen to these events using the JavaScript API: 111 | 112 | ```javascript 113 | 114 | var alondraClient = new AlondraClient('localhost', 12345, ['/chat_rooms/1']); 115 | 116 | // render user name when presence is created 117 | 118 | $(alondraClient).bind("created.Presence", function(event, resource){ 119 | if( $('#user_'+resource.user_id).length == 0 ){ 120 | $('#users').append("
  • " + resource.username + "
  • "); 121 | } 122 | }); 123 | 124 | // remove user name when presence is destroyed 125 | 126 | $(alondraClient).bind("destroyed.Presence", function(event, resource){ 127 | $('#user_'+resource.user_id).remove(); 128 | }); 129 | 130 | ``` 131 | 132 | This technique is especially useful if you use something like Backbone.js 133 | to render your app frontend. 134 | 135 | 136 | ## Example application 137 | 138 | You can check the [example application](http://github.com/afcapel/alondra-example) 139 | to see how some of the features are used. 140 | 141 | ## Installation 142 | 143 | Currently Alondra depends on Rails 3.1 and Ruby 1.9. It also uses ZeroMQ for 144 | interprocess communication, so you need to install the library first. If 145 | you are using Homebrew on Mac OS X, just type 146 | 147 |
    148 |   brew install zeromq
    149 | 
    150 | 151 | When ZeroMQ is installed, add the Alondra gem to your Gemfile. 152 | 153 |
    154 |   gem "alondra"
    155 | 
    156 | 157 | You also will need to install the server initialization script into your app. 158 | In the shell execute the generator. 159 | 160 |
    161 |   $ rails g alondra install
    162 | 
    163 | 164 | To run the Alondra server, just call the provided executable in your project directory 165 | 166 |
    167 |   $ bundle exec alondra
    168 | 
    169 | 170 | In development mode you can also run the Alondra server in its own thread. 171 | See the [initializer in the example application](https://github.com/afcapel/alondra-example/blob/master/config/initializers/alondra_server.rb) 172 | for how to do it. 173 | 174 | ## Contributors 175 | 176 | - [Ryan LeCompte](http://github.com/ryanlecompte) 177 | - [Jaime Iniesta](http://github.com/jaimeiniesta) 178 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | begin 3 | require 'bundler/setup' 4 | require "bundler/gem_tasks" 5 | require 'rake/testtask' 6 | rescue LoadError 7 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 8 | end 9 | 10 | Rake::TestTask.new(:test) do |t| 11 | t.libs << 'lib' 12 | t.libs << 'test' 13 | t.pattern = 'test/**/*_test.rb' 14 | t.verbose = false 15 | end 16 | 17 | task :default => :test 18 | -------------------------------------------------------------------------------- /alondra.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/alondra/version', __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "alondra" 6 | s.summary = "Add real time capabilities to your rails app" 7 | s.description = "Add real time capabilities to your rails app" 8 | s.version = Alondra::VERSION 9 | s.authors = ['Alberto F. Capel', 'Ryan LeCompte'] 10 | 11 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 12 | s.files = `git ls-files`.split("\n") 13 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 14 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 15 | s.require_paths = ["lib"] 16 | 17 | # Dependencies 18 | s.add_dependency('daemons') 19 | s.add_dependency('uuidtools') 20 | s.add_dependency('rails', '>= 3.1.0') 21 | s.add_dependency('em-websocket') 22 | s.add_dependency('em-zeromq', '>= 0.3.1') 23 | end 24 | -------------------------------------------------------------------------------- /app/assets/javascripts/alondra-client.js.coffee.erb: -------------------------------------------------------------------------------- 1 | #= require "moz_websocket" 2 | #= require "vendor/jquery.json-2.2" 3 | #= require "vendor/swfobject" 4 | #= require "vendor/web_socket" 5 | //= provide "../swf" 6 | 7 | window.WEB_SOCKET_SWF_LOCATION = "<%= asset_path 'WebSocketMain.swf' %>" 8 | 9 | class @AlondraClient 10 | constructor: (@server, @channels=[], @token = null, @retry = 10000) -> 11 | 12 | @channels = [@channels] unless @channels instanceof Array 13 | 14 | @url = "ws://#{@server}" 15 | @url += "?token=#{@token}" if @token 16 | 17 | @connect() 18 | 19 | subscribe: (channel) => 20 | @channels.push(channel) 21 | 22 | if @socket.readyState == 0 23 | # Socket is connecting 24 | # Schedule for later subscription 25 | 26 | return 27 | 28 | subscription = 29 | command: 'subscribe' 30 | channel: channel 31 | 32 | @socket.send $.toJSON(subscription) 33 | @ 34 | 35 | unsubscribe: (channel) => 36 | channelIndex = @channels.indexOf(channel) 37 | @channels.splice(channelIndex, 1) if channelIndex >= 0 38 | 39 | if @socket.readyState == 1 40 | unsubscription = 41 | command: 'unsubscribe' 42 | channel: channel 43 | 44 | @socket.send $.toJSON(unsubscription) 45 | 46 | return this 47 | 48 | opened: () => 49 | if @reconnectInterval 50 | clearInterval(@reconnectInterval) 51 | @reconnectInterval = null 52 | 53 | @subscribe(channel) for channel in @channels 54 | $(this).trigger('connected') 55 | true 56 | 57 | connect: => 58 | @socket = new WebSocket(@url) 59 | 60 | @socket.onopen = @opened 61 | 62 | @socket.onclose = () => 63 | this.reconnect() 64 | $(this).trigger('disconnected') 65 | 66 | @socket.onmessage = (message) => 67 | msg = $.parseJSON(message.data) 68 | if msg.event 69 | @process(msg) 70 | else 71 | @execute(msg) 72 | 73 | 74 | @socket.onerror = (error) => 75 | @reconnect() 76 | $(this).trigger('error', error) 77 | 78 | @ 79 | 80 | process: (serverEvent) -> 81 | eventName = serverEvent.event 82 | resourceType = serverEvent.resource_type 83 | resource = serverEvent.resource 84 | 85 | $(@).trigger("#{eventName}.#{resourceType}", resource) 86 | 87 | execute: (message) -> 88 | eval(message.message) 89 | 90 | reconnect: -> 91 | return if !@retry || @reconnectInterval 92 | 93 | @reconnectInterval = setInterval => 94 | this.connect() 95 | ,@retry 96 | 97 | -------------------------------------------------------------------------------- /app/assets/javascripts/moz_websocket.js: -------------------------------------------------------------------------------- 1 | /* In Firefox 6 WebSocket has been renamed to MozWebSocket */ 2 | 3 | if( window.WebSocket == null && window.MozWebSocket ){ 4 | window.WebSocket = window.MozWebSocket; 5 | } -------------------------------------------------------------------------------- /app/assets/javascripts/vendor/jquery.json-2.2.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery JSON Plugin 3 | * version: 2.1 (2009-08-14) 4 | * 5 | * This document is licensed as free software under the terms of the 6 | * MIT License: http://www.opensource.org/licenses/mit-license.php 7 | * 8 | * Brantley Harris wrote this plugin. It is based somewhat on the JSON.org 9 | * website's http://www.json.org/json2.js, which proclaims: 10 | * "NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.", a sentiment that 11 | * I uphold. 12 | * 13 | * It is also influenced heavily by MochiKit's serializeJSON, which is 14 | * copyrighted 2005 by Bob Ippolito. 15 | */ 16 | 17 | (function($) { 18 | /** jQuery.toJSON( json-serializble ) 19 | Converts the given argument into a JSON respresentation. 20 | 21 | If an object has a "toJSON" function, that will be used to get the representation. 22 | Non-integer/string keys are skipped in the object, as are keys that point to a function. 23 | 24 | json-serializble: 25 | The *thing* to be converted. 26 | **/ 27 | $.toJSON = function(o) 28 | { 29 | if (typeof(JSON) == 'object' && JSON.stringify) 30 | return JSON.stringify(o); 31 | 32 | var type = typeof(o); 33 | 34 | if (o === null) 35 | return "null"; 36 | 37 | if (type == "undefined") 38 | return undefined; 39 | 40 | if (type == "number" || type == "boolean") 41 | return o + ""; 42 | 43 | if (type == "string") 44 | return $.quoteString(o); 45 | 46 | if (type == 'object') 47 | { 48 | if (typeof o.toJSON == "function") 49 | return $.toJSON( o.toJSON() ); 50 | 51 | if (o.constructor === Date) 52 | { 53 | var month = o.getUTCMonth() + 1; 54 | if (month < 10) month = '0' + month; 55 | 56 | var day = o.getUTCDate(); 57 | if (day < 10) day = '0' + day; 58 | 59 | var year = o.getUTCFullYear(); 60 | 61 | var hours = o.getUTCHours(); 62 | if (hours < 10) hours = '0' + hours; 63 | 64 | var minutes = o.getUTCMinutes(); 65 | if (minutes < 10) minutes = '0' + minutes; 66 | 67 | var seconds = o.getUTCSeconds(); 68 | if (seconds < 10) seconds = '0' + seconds; 69 | 70 | var milli = o.getUTCMilliseconds(); 71 | if (milli < 100) milli = '0' + milli; 72 | if (milli < 10) milli = '0' + milli; 73 | 74 | return '"' + year + '-' + month + '-' + day + 'T' + 75 | hours + ':' + minutes + ':' + seconds + 76 | '.' + milli + 'Z"'; 77 | } 78 | 79 | if (o.constructor === Array) 80 | { 81 | var ret = []; 82 | for (var i = 0; i < o.length; i++) 83 | ret.push( $.toJSON(o[i]) || "null" ); 84 | 85 | return "[" + ret.join(",") + "]"; 86 | } 87 | 88 | var pairs = []; 89 | for (var k in o) { 90 | var name; 91 | var type = typeof k; 92 | 93 | if (type == "number") 94 | name = '"' + k + '"'; 95 | else if (type == "string") 96 | name = $.quoteString(k); 97 | else 98 | continue; //skip non-string or number keys 99 | 100 | if (typeof o[k] == "function") 101 | continue; //skip pairs where the value is a function. 102 | 103 | var val = $.toJSON(o[k]); 104 | 105 | pairs.push(name + ":" + val); 106 | } 107 | 108 | return "{" + pairs.join(", ") + "}"; 109 | } 110 | }; 111 | 112 | /** jQuery.evalJSON(src) 113 | Evaluates a given piece of json source. 114 | **/ 115 | $.evalJSON = function(src) 116 | { 117 | if (typeof(JSON) == 'object' && JSON.parse) 118 | return JSON.parse(src); 119 | return eval("(" + src + ")"); 120 | }; 121 | 122 | /** jQuery.secureEvalJSON(src) 123 | Evals JSON in a way that is *more* secure. 124 | **/ 125 | $.secureEvalJSON = function(src) 126 | { 127 | if (typeof(JSON) == 'object' && JSON.parse) 128 | return JSON.parse(src); 129 | 130 | var filtered = src; 131 | filtered = filtered.replace(/\\["\\\/bfnrtu]/g, '@'); 132 | filtered = filtered.replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']'); 133 | filtered = filtered.replace(/(?:^|:|,)(?:\s*\[)+/g, ''); 134 | 135 | if (/^[\],:{}\s]*$/.test(filtered)) 136 | return eval("(" + src + ")"); 137 | else 138 | throw new SyntaxError("Error parsing JSON, source is not valid."); 139 | }; 140 | 141 | /** jQuery.quoteString(string) 142 | Returns a string-repr of a string, escaping quotes intelligently. 143 | Mostly a support function for toJSON. 144 | 145 | Examples: 146 | >>> jQuery.quoteString("apple") 147 | "apple" 148 | 149 | >>> jQuery.quoteString('"Where are we going?", she asked.') 150 | "\"Where are we going?\", she asked." 151 | **/ 152 | $.quoteString = function(string) 153 | { 154 | if (string.match(_escapeable)) 155 | { 156 | return '"' + string.replace(_escapeable, function (a) 157 | { 158 | var c = _meta[a]; 159 | if (typeof c === 'string') return c; 160 | c = a.charCodeAt(); 161 | return '\\u00' + Math.floor(c / 16).toString(16) + (c % 16).toString(16); 162 | }) + '"'; 163 | } 164 | return '"' + string + '"'; 165 | }; 166 | 167 | var _escapeable = /["\\\x00-\x1f\x7f-\x9f]/g; 168 | 169 | var _meta = { 170 | '\b': '\\b', 171 | '\t': '\\t', 172 | '\n': '\\n', 173 | '\f': '\\f', 174 | '\r': '\\r', 175 | '"' : '\\"', 176 | '\\': '\\\\' 177 | }; 178 | })(jQuery); 179 | -------------------------------------------------------------------------------- /app/assets/javascripts/vendor/json2.js: -------------------------------------------------------------------------------- 1 | /* 2 | http://www.JSON.org/json2.js 3 | 2011-02-23 4 | 5 | Public Domain. 6 | 7 | NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. 8 | 9 | See http://www.JSON.org/js.html 10 | 11 | 12 | This code should be minified before deployment. 13 | See http://javascript.crockford.com/jsmin.html 14 | 15 | USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO 16 | NOT CONTROL. 17 | 18 | 19 | This file creates a global JSON object containing two methods: stringify 20 | and parse. 21 | 22 | JSON.stringify(value, replacer, space) 23 | value any JavaScript value, usually an object or array. 24 | 25 | replacer an optional parameter that determines how object 26 | values are stringified for objects. It can be a 27 | function or an array of strings. 28 | 29 | space an optional parameter that specifies the indentation 30 | of nested structures. If it is omitted, the text will 31 | be packed without extra whitespace. If it is a number, 32 | it will specify the number of spaces to indent at each 33 | level. If it is a string (such as '\t' or ' '), 34 | it contains the characters used to indent at each level. 35 | 36 | This method produces a JSON text from a JavaScript value. 37 | 38 | When an object value is found, if the object contains a toJSON 39 | method, its toJSON method will be called and the result will be 40 | stringified. A toJSON method does not serialize: it returns the 41 | value represented by the name/value pair that should be serialized, 42 | or undefined if nothing should be serialized. The toJSON method 43 | will be passed the key associated with the value, and this will be 44 | bound to the value 45 | 46 | For example, this would serialize Dates as ISO strings. 47 | 48 | Date.prototype.toJSON = function (key) { 49 | function f(n) { 50 | // Format integers to have at least two digits. 51 | return n < 10 ? '0' + n : n; 52 | } 53 | 54 | return this.getUTCFullYear() + '-' + 55 | f(this.getUTCMonth() + 1) + '-' + 56 | f(this.getUTCDate()) + 'T' + 57 | f(this.getUTCHours()) + ':' + 58 | f(this.getUTCMinutes()) + ':' + 59 | f(this.getUTCSeconds()) + 'Z'; 60 | }; 61 | 62 | You can provide an optional replacer method. It will be passed the 63 | key and value of each member, with this bound to the containing 64 | object. The value that is returned from your method will be 65 | serialized. If your method returns undefined, then the member will 66 | be excluded from the serialization. 67 | 68 | If the replacer parameter is an array of strings, then it will be 69 | used to select the members to be serialized. It filters the results 70 | such that only members with keys listed in the replacer array are 71 | stringified. 72 | 73 | Values that do not have JSON representations, such as undefined or 74 | functions, will not be serialized. Such values in objects will be 75 | dropped; in arrays they will be replaced with null. You can use 76 | a replacer function to replace those with JSON values. 77 | JSON.stringify(undefined) returns undefined. 78 | 79 | The optional space parameter produces a stringification of the 80 | value that is filled with line breaks and indentation to make it 81 | easier to read. 82 | 83 | If the space parameter is a non-empty string, then that string will 84 | be used for indentation. If the space parameter is a number, then 85 | the indentation will be that many spaces. 86 | 87 | Example: 88 | 89 | text = JSON.stringify(['e', {pluribus: 'unum'}]); 90 | // text is '["e",{"pluribus":"unum"}]' 91 | 92 | 93 | text = JSON.stringify(['e', {pluribus: 'unum'}], null, '\t'); 94 | // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' 95 | 96 | text = JSON.stringify([new Date()], function (key, value) { 97 | return this[key] instanceof Date ? 98 | 'Date(' + this[key] + ')' : value; 99 | }); 100 | // text is '["Date(---current time---)"]' 101 | 102 | 103 | JSON.parse(text, reviver) 104 | This method parses a JSON text to produce an object or array. 105 | It can throw a SyntaxError exception. 106 | 107 | The optional reviver parameter is a function that can filter and 108 | transform the results. It receives each of the keys and values, 109 | and its return value is used instead of the original value. 110 | If it returns what it received, then the structure is not modified. 111 | If it returns undefined then the member is deleted. 112 | 113 | Example: 114 | 115 | // Parse the text. Values that look like ISO date strings will 116 | // be converted to Date objects. 117 | 118 | myData = JSON.parse(text, function (key, value) { 119 | var a; 120 | if (typeof value === 'string') { 121 | a = 122 | /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); 123 | if (a) { 124 | return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], 125 | +a[5], +a[6])); 126 | } 127 | } 128 | return value; 129 | }); 130 | 131 | myData = JSON.parse('["Date(09/09/2001)"]', function (key, value) { 132 | var d; 133 | if (typeof value === 'string' && 134 | value.slice(0, 5) === 'Date(' && 135 | value.slice(-1) === ')') { 136 | d = new Date(value.slice(5, -1)); 137 | if (d) { 138 | return d; 139 | } 140 | } 141 | return value; 142 | }); 143 | 144 | 145 | This is a reference implementation. You are free to copy, modify, or 146 | redistribute. 147 | */ 148 | 149 | /*jslint evil: true, strict: false, regexp: false */ 150 | 151 | /*members "", "\b", "\t", "\n", "\f", "\r", "\"", JSON, "\\", apply, 152 | call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, 153 | getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, 154 | lastIndex, length, parse, prototype, push, replace, slice, stringify, 155 | test, toJSON, toString, valueOf 156 | */ 157 | 158 | 159 | // Create a JSON object only if one does not already exist. We create the 160 | // methods in a closure to avoid creating global variables. 161 | 162 | var JSON; 163 | if (!JSON) { 164 | JSON = {}; 165 | } 166 | 167 | (function () { 168 | "use strict"; 169 | 170 | function f(n) { 171 | // Format integers to have at least two digits. 172 | return n < 10 ? '0' + n : n; 173 | } 174 | 175 | if (typeof Date.prototype.toJSON !== 'function') { 176 | 177 | Date.prototype.toJSON = function (key) { 178 | 179 | return isFinite(this.valueOf()) ? 180 | this.getUTCFullYear() + '-' + 181 | f(this.getUTCMonth() + 1) + '-' + 182 | f(this.getUTCDate()) + 'T' + 183 | f(this.getUTCHours()) + ':' + 184 | f(this.getUTCMinutes()) + ':' + 185 | f(this.getUTCSeconds()) + 'Z' : null; 186 | }; 187 | 188 | String.prototype.toJSON = 189 | Number.prototype.toJSON = 190 | Boolean.prototype.toJSON = function (key) { 191 | return this.valueOf(); 192 | }; 193 | } 194 | 195 | var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, 196 | escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, 197 | gap, 198 | indent, 199 | meta = { // table of character substitutions 200 | '\b': '\\b', 201 | '\t': '\\t', 202 | '\n': '\\n', 203 | '\f': '\\f', 204 | '\r': '\\r', 205 | '"' : '\\"', 206 | '\\': '\\\\' 207 | }, 208 | rep; 209 | 210 | 211 | function quote(string) { 212 | 213 | // If the string contains no control characters, no quote characters, and no 214 | // backslash characters, then we can safely slap some quotes around it. 215 | // Otherwise we must also replace the offending characters with safe escape 216 | // sequences. 217 | 218 | escapable.lastIndex = 0; 219 | return escapable.test(string) ? '"' + string.replace(escapable, function (a) { 220 | var c = meta[a]; 221 | return typeof c === 'string' ? c : 222 | '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); 223 | }) + '"' : '"' + string + '"'; 224 | } 225 | 226 | 227 | function str(key, holder) { 228 | 229 | // Produce a string from holder[key]. 230 | 231 | var i, // The loop counter. 232 | k, // The member key. 233 | v, // The member value. 234 | length, 235 | mind = gap, 236 | partial, 237 | value = holder[key]; 238 | 239 | // If the value has a toJSON method, call it to obtain a replacement value. 240 | 241 | if (value && typeof value === 'object' && 242 | typeof value.toJSON === 'function') { 243 | value = value.toJSON(key); 244 | } 245 | 246 | // If we were called with a replacer function, then call the replacer to 247 | // obtain a replacement value. 248 | 249 | if (typeof rep === 'function') { 250 | value = rep.call(holder, key, value); 251 | } 252 | 253 | // What happens next depends on the value's type. 254 | 255 | switch (typeof value) { 256 | case 'string': 257 | return quote(value); 258 | 259 | case 'number': 260 | 261 | // JSON numbers must be finite. Encode non-finite numbers as null. 262 | 263 | return isFinite(value) ? String(value) : 'null'; 264 | 265 | case 'boolean': 266 | case 'null': 267 | 268 | // If the value is a boolean or null, convert it to a string. Note: 269 | // typeof null does not produce 'null'. The case is included here in 270 | // the remote chance that this gets fixed someday. 271 | 272 | return String(value); 273 | 274 | // If the type is 'object', we might be dealing with an object or an array or 275 | // null. 276 | 277 | case 'object': 278 | 279 | // Due to a specification blunder in ECMAScript, typeof null is 'object', 280 | // so watch out for that case. 281 | 282 | if (!value) { 283 | return 'null'; 284 | } 285 | 286 | // Make an array to hold the partial results of stringifying this object value. 287 | 288 | gap += indent; 289 | partial = []; 290 | 291 | // Is the value an array? 292 | 293 | if (Object.prototype.toString.apply(value) === '[object Array]') { 294 | 295 | // The value is an array. Stringify every element. Use null as a placeholder 296 | // for non-JSON values. 297 | 298 | length = value.length; 299 | for (i = 0; i < length; i += 1) { 300 | partial[i] = str(i, value) || 'null'; 301 | } 302 | 303 | // Join all of the elements together, separated with commas, and wrap them in 304 | // brackets. 305 | 306 | v = partial.length === 0 ? '[]' : gap ? 307 | '[\n' + gap + partial.join(',\n' + gap) + '\n' + mind + ']' : 308 | '[' + partial.join(',') + ']'; 309 | gap = mind; 310 | return v; 311 | } 312 | 313 | // If the replacer is an array, use it to select the members to be stringified. 314 | 315 | if (rep && typeof rep === 'object') { 316 | length = rep.length; 317 | for (i = 0; i < length; i += 1) { 318 | if (typeof rep[i] === 'string') { 319 | k = rep[i]; 320 | v = str(k, value); 321 | if (v) { 322 | partial.push(quote(k) + (gap ? ': ' : ':') + v); 323 | } 324 | } 325 | } 326 | } else { 327 | 328 | // Otherwise, iterate through all of the keys in the object. 329 | 330 | for (k in value) { 331 | if (Object.prototype.hasOwnProperty.call(value, k)) { 332 | v = str(k, value); 333 | if (v) { 334 | partial.push(quote(k) + (gap ? ': ' : ':') + v); 335 | } 336 | } 337 | } 338 | } 339 | 340 | // Join all of the member texts together, separated with commas, 341 | // and wrap them in braces. 342 | 343 | v = partial.length === 0 ? '{}' : gap ? 344 | '{\n' + gap + partial.join(',\n' + gap) + '\n' + mind + '}' : 345 | '{' + partial.join(',') + '}'; 346 | gap = mind; 347 | return v; 348 | } 349 | } 350 | 351 | // If the JSON object does not yet have a stringify method, give it one. 352 | 353 | if (typeof JSON.stringify !== 'function') { 354 | JSON.stringify = function (value, replacer, space) { 355 | 356 | // The stringify method takes a value and an optional replacer, and an optional 357 | // space parameter, and returns a JSON text. The replacer can be a function 358 | // that can replace values, or an array of strings that will select the keys. 359 | // A default replacer method can be provided. Use of the space parameter can 360 | // produce text that is more easily readable. 361 | 362 | var i; 363 | gap = ''; 364 | indent = ''; 365 | 366 | // If the space parameter is a number, make an indent string containing that 367 | // many spaces. 368 | 369 | if (typeof space === 'number') { 370 | for (i = 0; i < space; i += 1) { 371 | indent += ' '; 372 | } 373 | 374 | // If the space parameter is a string, it will be used as the indent string. 375 | 376 | } else if (typeof space === 'string') { 377 | indent = space; 378 | } 379 | 380 | // If there is a replacer, it must be a function or an array. 381 | // Otherwise, throw an error. 382 | 383 | rep = replacer; 384 | if (replacer && typeof replacer !== 'function' && 385 | (typeof replacer !== 'object' || 386 | typeof replacer.length !== 'number')) { 387 | throw new Error('JSON.stringify'); 388 | } 389 | 390 | // Make a fake root object containing our value under the key of ''. 391 | // Return the result of stringifying the value. 392 | 393 | return str('', {'': value}); 394 | }; 395 | } 396 | 397 | 398 | // If the JSON object does not yet have a parse method, give it one. 399 | 400 | if (typeof JSON.parse !== 'function') { 401 | JSON.parse = function (text, reviver) { 402 | 403 | // The parse method takes a text and an optional reviver function, and returns 404 | // a JavaScript value if the text is a valid JSON text. 405 | 406 | var j; 407 | 408 | function walk(holder, key) { 409 | 410 | // The walk method is used to recursively walk the resulting structure so 411 | // that modifications can be made. 412 | 413 | var k, v, value = holder[key]; 414 | if (value && typeof value === 'object') { 415 | for (k in value) { 416 | if (Object.prototype.hasOwnProperty.call(value, k)) { 417 | v = walk(value, k); 418 | if (v !== undefined) { 419 | value[k] = v; 420 | } else { 421 | delete value[k]; 422 | } 423 | } 424 | } 425 | } 426 | return reviver.call(holder, key, value); 427 | } 428 | 429 | 430 | // Parsing happens in four stages. In the first stage, we replace certain 431 | // Unicode characters with escape sequences. JavaScript handles many characters 432 | // incorrectly, either silently deleting them, or treating them as line endings. 433 | 434 | text = String(text); 435 | cx.lastIndex = 0; 436 | if (cx.test(text)) { 437 | text = text.replace(cx, function (a) { 438 | return '\\u' + 439 | ('0000' + a.charCodeAt(0).toString(16)).slice(-4); 440 | }); 441 | } 442 | 443 | // In the second stage, we run the text against regular expressions that look 444 | // for non-JSON patterns. We are especially concerned with '()' and 'new' 445 | // because they can cause invocation, and '=' because it can cause mutation. 446 | // But just to be safe, we want to reject all unexpected forms. 447 | 448 | // We split the second stage into 4 regexp operations in order to work around 449 | // crippling inefficiencies in IE's and Safari's regexp engines. First we 450 | // replace the JSON backslash pairs with '@' (a non-JSON character). Second, we 451 | // replace all simple value tokens with ']' characters. Third, we delete all 452 | // open brackets that follow a colon or comma or that begin the text. Finally, 453 | // we look to see that the remaining characters are only whitespace or ']' or 454 | // ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval. 455 | 456 | if (/^[\],:{}\s]*$/ 457 | .test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@') 458 | .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']') 459 | .replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { 460 | 461 | // In the third stage we use the eval function to compile the text into a 462 | // JavaScript structure. The '{' operator is subject to a syntactic ambiguity 463 | // in JavaScript: it can begin a block or an object literal. We wrap the text 464 | // in parens to eliminate the ambiguity. 465 | 466 | j = eval('(' + text + ')'); 467 | 468 | // In the optional fourth stage, we recursively walk the new structure, passing 469 | // each name/value pair to a reviver function for possible transformation. 470 | 471 | return typeof reviver === 'function' ? 472 | walk({'': j}, '') : j; 473 | } 474 | 475 | // If the text is not JSON parseable, then a SyntaxError is thrown. 476 | 477 | throw new SyntaxError('JSON.parse'); 478 | }; 479 | } 480 | }()); 481 | -------------------------------------------------------------------------------- /app/assets/javascripts/vendor/swfobject.js: -------------------------------------------------------------------------------- 1 | /* SWFObject v2.2 2 | is released under the MIT License 3 | */ 4 | var swfobject=function(){var D="undefined",r="object",S="Shockwave Flash",W="ShockwaveFlash.ShockwaveFlash",q="application/x-shockwave-flash",R="SWFObjectExprInst",x="onreadystatechange",O=window,j=document,t=navigator,T=false,U=[h],o=[],N=[],I=[],l,Q,E,B,J=false,a=false,n,G,m=true,M=function(){var aa=typeof j.getElementById!=D&&typeof j.getElementsByTagName!=D&&typeof j.createElement!=D,ah=t.userAgent.toLowerCase(),Y=t.platform.toLowerCase(),ae=Y?/win/.test(Y):/win/.test(ah),ac=Y?/mac/.test(Y):/mac/.test(ah),af=/webkit/.test(ah)?parseFloat(ah.replace(/^.*webkit\/(\d+(\.\d+)?).*$/,"$1")):false,X=!+"\v1",ag=[0,0,0],ab=null;if(typeof t.plugins!=D&&typeof t.plugins[S]==r){ab=t.plugins[S].description;if(ab&&!(typeof t.mimeTypes!=D&&t.mimeTypes[q]&&!t.mimeTypes[q].enabledPlugin)){T=true;X=false;ab=ab.replace(/^.*\s+(\S+\s+\S+$)/,"$1");ag[0]=parseInt(ab.replace(/^(.*)\..*$/,"$1"),10);ag[1]=parseInt(ab.replace(/^.*\.(.*)\s.*$/,"$1"),10);ag[2]=/[a-zA-Z]/.test(ab)?parseInt(ab.replace(/^.*[a-zA-Z]+(.*)$/,"$1"),10):0}}else{if(typeof O.ActiveXObject!=D){try{var ad=new ActiveXObject(W);if(ad){ab=ad.GetVariable("$version");if(ab){X=true;ab=ab.split(" ")[1].split(",");ag=[parseInt(ab[0],10),parseInt(ab[1],10),parseInt(ab[2],10)]}}}catch(Z){}}}return{w3:aa,pv:ag,wk:af,ie:X,win:ae,mac:ac}}(),k=function(){if(!M.w3){return}if((typeof j.readyState!=D&&j.readyState=="complete")||(typeof j.readyState==D&&(j.getElementsByTagName("body")[0]||j.body))){f()}if(!J){if(typeof j.addEventListener!=D){j.addEventListener("DOMContentLoaded",f,false)}if(M.ie&&M.win){j.attachEvent(x,function(){if(j.readyState=="complete"){j.detachEvent(x,arguments.callee);f()}});if(O==top){(function(){if(J){return}try{j.documentElement.doScroll("left")}catch(X){setTimeout(arguments.callee,0);return}f()})()}}if(M.wk){(function(){if(J){return}if(!/loaded|complete/.test(j.readyState)){setTimeout(arguments.callee,0);return}f()})()}s(f)}}();function f(){if(J){return}try{var Z=j.getElementsByTagName("body")[0].appendChild(C("span"));Z.parentNode.removeChild(Z)}catch(aa){return}J=true;var X=U.length;for(var Y=0;Y0){for(var af=0;af0){var ae=c(Y);if(ae){if(F(o[af].swfVersion)&&!(M.wk&&M.wk<312)){w(Y,true);if(ab){aa.success=true;aa.ref=z(Y);ab(aa)}}else{if(o[af].expressInstall&&A()){var ai={};ai.data=o[af].expressInstall;ai.width=ae.getAttribute("width")||"0";ai.height=ae.getAttribute("height")||"0";if(ae.getAttribute("class")){ai.styleclass=ae.getAttribute("class")}if(ae.getAttribute("align")){ai.align=ae.getAttribute("align")}var ah={};var X=ae.getElementsByTagName("param");var ac=X.length;for(var ad=0;ad'}}aa.outerHTML='"+af+"";N[N.length]=ai.id;X=c(ai.id)}else{var Z=C(r);Z.setAttribute("type",q);for(var ac in ai){if(ai[ac]!=Object.prototype[ac]){if(ac.toLowerCase()=="styleclass"){Z.setAttribute("class",ai[ac])}else{if(ac.toLowerCase()!="classid"){Z.setAttribute(ac,ai[ac])}}}}for(var ab in ag){if(ag[ab]!=Object.prototype[ab]&&ab.toLowerCase()!="movie"){e(Z,ab,ag[ab])}}aa.parentNode.replaceChild(Z,aa);X=Z}}return X}function e(Z,X,Y){var aa=C("param");aa.setAttribute("name",X);aa.setAttribute("value",Y);Z.appendChild(aa)}function y(Y){var X=c(Y);if(X&&X.nodeName=="OBJECT"){if(M.ie&&M.win){X.style.display="none";(function(){if(X.readyState==4){b(Y)}else{setTimeout(arguments.callee,10)}})()}else{X.parentNode.removeChild(X)}}}function b(Z){var Y=c(Z);if(Y){for(var X in Y){if(typeof Y[X]=="function"){Y[X]=null}}Y.parentNode.removeChild(Y)}}function c(Z){var X=null;try{X=j.getElementById(Z)}catch(Y){}return X}function C(X){return j.createElement(X)}function i(Z,X,Y){Z.attachEvent(X,Y);I[I.length]=[Z,X,Y]}function F(Z){var Y=M.pv,X=Z.split(".");X[0]=parseInt(X[0],10);X[1]=parseInt(X[1],10)||0;X[2]=parseInt(X[2],10)||0;return(Y[0]>X[0]||(Y[0]==X[0]&&Y[1]>X[1])||(Y[0]==X[0]&&Y[1]==X[1]&&Y[2]>=X[2]))?true:false}function v(ac,Y,ad,ab){if(M.ie&&M.mac){return}var aa=j.getElementsByTagName("head")[0];if(!aa){return}var X=(ad&&typeof ad=="string")?ad:"screen";if(ab){n=null;G=null}if(!n||G!=X){var Z=C("style");Z.setAttribute("type","text/css");Z.setAttribute("media",X);n=aa.appendChild(Z);if(M.ie&&M.win&&typeof j.styleSheets!=D&&j.styleSheets.length>0){n=j.styleSheets[j.styleSheets.length-1]}G=X}if(M.ie&&M.win){if(n&&typeof n.addRule==r){n.addRule(ac,Y)}}else{if(n&&typeof j.createTextNode!=D){n.appendChild(j.createTextNode(ac+" {"+Y+"}"))}}}function w(Z,X){if(!m){return}var Y=X?"visible":"hidden";if(J&&c(Z)){c(Z).style.visibility=Y}else{v("#"+Z,"visibility:"+Y)}}function L(Y){var Z=/[\\\"<>\.;]/;var X=Z.exec(Y)!=null;return X&&typeof encodeURIComponent!=D?encodeURIComponent(Y):Y}var d=function(){if(M.ie&&M.win){window.attachEvent("onunload",function(){var ac=I.length;for(var ab=0;ab 2 | // License: New BSD License 3 | // Reference: http://dev.w3.org/html5/websockets/ 4 | // Reference: http://tools.ietf.org/html/rfc6455 5 | 6 | (function() { 7 | 8 | if (window.WEB_SOCKET_FORCE_FLASH) { 9 | // Keeps going. 10 | } else if (window.WebSocket) { 11 | return; 12 | } else if (window.MozWebSocket) { 13 | // Firefox. 14 | window.WebSocket = MozWebSocket; 15 | return; 16 | } 17 | 18 | var logger; 19 | if (window.WEB_SOCKET_LOGGER) { 20 | logger = WEB_SOCKET_LOGGER; 21 | } else if (window.console && window.console.log && window.console.error) { 22 | // In some environment, console is defined but console.log or console.error is missing. 23 | logger = window.console; 24 | } else { 25 | logger = {log: function(){ }, error: function(){ }}; 26 | } 27 | 28 | // swfobject.hasFlashPlayerVersion("10.0.0") doesn't work with Gnash. 29 | if (swfobject.getFlashPlayerVersion().major < 10) { 30 | logger.error("Flash Player >= 10.0.0 is required."); 31 | return; 32 | } 33 | if (location.protocol == "file:") { 34 | logger.error( 35 | "WARNING: web-socket-js doesn't work in file:///... URL " + 36 | "unless you set Flash Security Settings properly. " + 37 | "Open the page via Web server i.e. http://..."); 38 | } 39 | 40 | /** 41 | * Our own implementation of WebSocket class using Flash. 42 | * @param {string} url 43 | * @param {array or string} protocols 44 | * @param {string} proxyHost 45 | * @param {int} proxyPort 46 | * @param {string} headers 47 | */ 48 | window.WebSocket = function(url, protocols, proxyHost, proxyPort, headers) { 49 | var self = this; 50 | self.__id = WebSocket.__nextId++; 51 | WebSocket.__instances[self.__id] = self; 52 | self.readyState = WebSocket.CONNECTING; 53 | self.bufferedAmount = 0; 54 | self.__events = {}; 55 | if (!protocols) { 56 | protocols = []; 57 | } else if (typeof protocols == "string") { 58 | protocols = [protocols]; 59 | } 60 | // Uses setTimeout() to make sure __createFlash() runs after the caller sets ws.onopen etc. 61 | // Otherwise, when onopen fires immediately, onopen is called before it is set. 62 | self.__createTask = setTimeout(function() { 63 | WebSocket.__addTask(function() { 64 | self.__createTask = null; 65 | WebSocket.__flash.create( 66 | self.__id, url, protocols, proxyHost || null, proxyPort || 0, headers || null); 67 | }); 68 | }, 0); 69 | }; 70 | 71 | /** 72 | * Send data to the web socket. 73 | * @param {string} data The data to send to the socket. 74 | * @return {boolean} True for success, false for failure. 75 | */ 76 | WebSocket.prototype.send = function(data) { 77 | if (this.readyState == WebSocket.CONNECTING) { 78 | throw "INVALID_STATE_ERR: Web Socket connection has not been established"; 79 | } 80 | // We use encodeURIComponent() here, because FABridge doesn't work if 81 | // the argument includes some characters. We don't use escape() here 82 | // because of this: 83 | // https://developer.mozilla.org/en/Core_JavaScript_1.5_Guide/Functions#escape_and_unescape_Functions 84 | // But it looks decodeURIComponent(encodeURIComponent(s)) doesn't 85 | // preserve all Unicode characters either e.g. "\uffff" in Firefox. 86 | // Note by wtritch: Hopefully this will not be necessary using ExternalInterface. Will require 87 | // additional testing. 88 | var result = WebSocket.__flash.send(this.__id, encodeURIComponent(data)); 89 | if (result < 0) { // success 90 | return true; 91 | } else { 92 | this.bufferedAmount += result; 93 | return false; 94 | } 95 | }; 96 | 97 | /** 98 | * Close this web socket gracefully. 99 | */ 100 | WebSocket.prototype.close = function() { 101 | if (this.__createTask) { 102 | clearTimeout(this.__createTask); 103 | this.__createTask = null; 104 | this.readyState = WebSocket.CLOSED; 105 | return; 106 | } 107 | if (this.readyState == WebSocket.CLOSED || this.readyState == WebSocket.CLOSING) { 108 | return; 109 | } 110 | this.readyState = WebSocket.CLOSING; 111 | WebSocket.__flash.close(this.__id); 112 | }; 113 | 114 | /** 115 | * Implementation of {@link DOM 2 EventTarget Interface} 116 | * 117 | * @param {string} type 118 | * @param {function} listener 119 | * @param {boolean} useCapture 120 | * @return void 121 | */ 122 | WebSocket.prototype.addEventListener = function(type, listener, useCapture) { 123 | if (!(type in this.__events)) { 124 | this.__events[type] = []; 125 | } 126 | this.__events[type].push(listener); 127 | }; 128 | 129 | /** 130 | * Implementation of {@link DOM 2 EventTarget Interface} 131 | * 132 | * @param {string} type 133 | * @param {function} listener 134 | * @param {boolean} useCapture 135 | * @return void 136 | */ 137 | WebSocket.prototype.removeEventListener = function(type, listener, useCapture) { 138 | if (!(type in this.__events)) return; 139 | var events = this.__events[type]; 140 | for (var i = events.length - 1; i >= 0; --i) { 141 | if (events[i] === listener) { 142 | events.splice(i, 1); 143 | break; 144 | } 145 | } 146 | }; 147 | 148 | /** 149 | * Implementation of {@link DOM 2 EventTarget Interface} 150 | * 151 | * @param {Event} event 152 | * @return void 153 | */ 154 | WebSocket.prototype.dispatchEvent = function(event) { 155 | var events = this.__events[event.type] || []; 156 | for (var i = 0; i < events.length; ++i) { 157 | events[i](event); 158 | } 159 | var handler = this["on" + event.type]; 160 | if (handler) handler.apply(this, [event]); 161 | }; 162 | 163 | /** 164 | * Handles an event from Flash. 165 | * @param {Object} flashEvent 166 | */ 167 | WebSocket.prototype.__handleEvent = function(flashEvent) { 168 | 169 | if ("readyState" in flashEvent) { 170 | this.readyState = flashEvent.readyState; 171 | } 172 | if ("protocol" in flashEvent) { 173 | this.protocol = flashEvent.protocol; 174 | } 175 | 176 | var jsEvent; 177 | if (flashEvent.type == "open" || flashEvent.type == "error") { 178 | jsEvent = this.__createSimpleEvent(flashEvent.type); 179 | } else if (flashEvent.type == "close") { 180 | jsEvent = this.__createSimpleEvent("close"); 181 | jsEvent.wasClean = flashEvent.wasClean ? true : false; 182 | jsEvent.code = flashEvent.code; 183 | jsEvent.reason = flashEvent.reason; 184 | } else if (flashEvent.type == "message") { 185 | var data = decodeURIComponent(flashEvent.message); 186 | jsEvent = this.__createMessageEvent("message", data); 187 | } else { 188 | throw "unknown event type: " + flashEvent.type; 189 | } 190 | 191 | this.dispatchEvent(jsEvent); 192 | 193 | }; 194 | 195 | WebSocket.prototype.__createSimpleEvent = function(type) { 196 | if (document.createEvent && window.Event) { 197 | var event = document.createEvent("Event"); 198 | event.initEvent(type, false, false); 199 | return event; 200 | } else { 201 | return {type: type, bubbles: false, cancelable: false}; 202 | } 203 | }; 204 | 205 | WebSocket.prototype.__createMessageEvent = function(type, data) { 206 | if (document.createEvent && window.MessageEvent && !window.opera) { 207 | var event = document.createEvent("MessageEvent"); 208 | event.initMessageEvent("message", false, false, data, null, null, window, null); 209 | return event; 210 | } else { 211 | // IE and Opera, the latter one truncates the data parameter after any 0x00 bytes. 212 | return {type: type, data: data, bubbles: false, cancelable: false}; 213 | } 214 | }; 215 | 216 | /** 217 | * Define the WebSocket readyState enumeration. 218 | */ 219 | WebSocket.CONNECTING = 0; 220 | WebSocket.OPEN = 1; 221 | WebSocket.CLOSING = 2; 222 | WebSocket.CLOSED = 3; 223 | 224 | WebSocket.__initialized = false; 225 | WebSocket.__flash = null; 226 | WebSocket.__instances = {}; 227 | WebSocket.__tasks = []; 228 | WebSocket.__nextId = 0; 229 | 230 | /** 231 | * Load a new flash security policy file. 232 | * @param {string} url 233 | */ 234 | WebSocket.loadFlashPolicyFile = function(url){ 235 | WebSocket.__addTask(function() { 236 | WebSocket.__flash.loadManualPolicyFile(url); 237 | }); 238 | }; 239 | 240 | /** 241 | * Loads WebSocketMain.swf and creates WebSocketMain object in Flash. 242 | */ 243 | WebSocket.__initialize = function() { 244 | 245 | if (WebSocket.__initialized) return; 246 | WebSocket.__initialized = true; 247 | 248 | if (WebSocket.__swfLocation) { 249 | // For backword compatibility. 250 | window.WEB_SOCKET_SWF_LOCATION = WebSocket.__swfLocation; 251 | } 252 | if (!window.WEB_SOCKET_SWF_LOCATION) { 253 | logger.error("[WebSocket] set WEB_SOCKET_SWF_LOCATION to location of WebSocketMain.swf"); 254 | return; 255 | } 256 | if (!window.WEB_SOCKET_SUPPRESS_CROSS_DOMAIN_SWF_ERROR && 257 | !WEB_SOCKET_SWF_LOCATION.match(/(^|\/)WebSocketMainInsecure\.swf(\?.*)?$/) && 258 | WEB_SOCKET_SWF_LOCATION.match(/^\w+:\/\/([^\/]+)/)) { 259 | var swfHost = RegExp.$1; 260 | if (location.host != swfHost) { 261 | logger.error( 262 | "[WebSocket] You must host HTML and WebSocketMain.swf in the same host " + 263 | "('" + location.host + "' != '" + swfHost + "'). " + 264 | "See also 'How to host HTML file and SWF file in different domains' section " + 265 | "in README.md. If you use WebSocketMainInsecure.swf, you can suppress this message " + 266 | "by WEB_SOCKET_SUPPRESS_CROSS_DOMAIN_SWF_ERROR = true;"); 267 | } 268 | } 269 | var container = document.createElement("div"); 270 | container.id = "webSocketContainer"; 271 | // Hides Flash box. We cannot use display: none or visibility: hidden because it prevents 272 | // Flash from loading at least in IE. So we move it out of the screen at (-100, -100). 273 | // But this even doesn't work with Flash Lite (e.g. in Droid Incredible). So with Flash 274 | // Lite, we put it at (0, 0). This shows 1x1 box visible at left-top corner but this is 275 | // the best we can do as far as we know now. 276 | container.style.position = "absolute"; 277 | if (WebSocket.__isFlashLite()) { 278 | container.style.left = "0px"; 279 | container.style.top = "0px"; 280 | } else { 281 | container.style.left = "-100px"; 282 | container.style.top = "-100px"; 283 | } 284 | var holder = document.createElement("div"); 285 | holder.id = "webSocketFlash"; 286 | container.appendChild(holder); 287 | document.body.appendChild(container); 288 | // See this article for hasPriority: 289 | // http://help.adobe.com/en_US/as3/mobile/WS4bebcd66a74275c36cfb8137124318eebc6-7ffd.html 290 | swfobject.embedSWF( 291 | WEB_SOCKET_SWF_LOCATION, 292 | "webSocketFlash", 293 | "1" /* width */, 294 | "1" /* height */, 295 | "10.0.0" /* SWF version */, 296 | null, 297 | null, 298 | {hasPriority: true, swliveconnect : true, allowScriptAccess: "always"}, 299 | null, 300 | function(e) { 301 | if (!e.success) { 302 | logger.error("[WebSocket] swfobject.embedSWF failed"); 303 | } 304 | } 305 | ); 306 | 307 | }; 308 | 309 | /** 310 | * Called by Flash to notify JS that it's fully loaded and ready 311 | * for communication. 312 | */ 313 | WebSocket.__onFlashInitialized = function() { 314 | // We need to set a timeout here to avoid round-trip calls 315 | // to flash during the initialization process. 316 | setTimeout(function() { 317 | WebSocket.__flash = document.getElementById("webSocketFlash"); 318 | WebSocket.__flash.setCallerUrl(location.href); 319 | WebSocket.__flash.setDebug(!!window.WEB_SOCKET_DEBUG); 320 | for (var i = 0; i < WebSocket.__tasks.length; ++i) { 321 | WebSocket.__tasks[i](); 322 | } 323 | WebSocket.__tasks = []; 324 | }, 0); 325 | }; 326 | 327 | /** 328 | * Called by Flash to notify WebSockets events are fired. 329 | */ 330 | WebSocket.__onFlashEvent = function() { 331 | setTimeout(function() { 332 | try { 333 | // Gets events using receiveEvents() instead of getting it from event object 334 | // of Flash event. This is to make sure to keep message order. 335 | // It seems sometimes Flash events don't arrive in the same order as they are sent. 336 | var events = WebSocket.__flash.receiveEvents(); 337 | for (var i = 0; i < events.length; ++i) { 338 | WebSocket.__instances[events[i].webSocketId].__handleEvent(events[i]); 339 | } 340 | } catch (e) { 341 | logger.error(e); 342 | } 343 | }, 0); 344 | return true; 345 | }; 346 | 347 | // Called by Flash. 348 | WebSocket.__log = function(message) { 349 | logger.log(decodeURIComponent(message)); 350 | }; 351 | 352 | // Called by Flash. 353 | WebSocket.__error = function(message) { 354 | logger.error(decodeURIComponent(message)); 355 | }; 356 | 357 | WebSocket.__addTask = function(task) { 358 | if (WebSocket.__flash) { 359 | task(); 360 | } else { 361 | WebSocket.__tasks.push(task); 362 | } 363 | }; 364 | 365 | /** 366 | * Test if the browser is running flash lite. 367 | * @return {boolean} True if flash lite is running, false otherwise. 368 | */ 369 | WebSocket.__isFlashLite = function() { 370 | if (!window.navigator || !window.navigator.mimeTypes) { 371 | return false; 372 | } 373 | var mimeType = window.navigator.mimeTypes["application/x-shockwave-flash"]; 374 | if (!mimeType || !mimeType.enabledPlugin || !mimeType.enabledPlugin.filename) { 375 | return false; 376 | } 377 | return mimeType.enabledPlugin.filename.match(/flashlite/i) ? true : false; 378 | }; 379 | 380 | if (!window.WEB_SOCKET_DISABLE_AUTO_INITIALIZATION) { 381 | // NOTE: 382 | // This fires immediately if web_socket.js is dynamically loaded after 383 | // the document is loaded. 384 | swfobject.addDomLoadEvent(function() { 385 | WebSocket.__initialize(); 386 | }); 387 | } 388 | 389 | })(); 390 | -------------------------------------------------------------------------------- /app/assets/swf/WebSocketMain.swf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afcapel/alondra/071ec2fc677846cdb1fc3a964211ecdbf6c55d69/app/assets/swf/WebSocketMain.swf -------------------------------------------------------------------------------- /app/helpers/alondra_helper.rb: -------------------------------------------------------------------------------- 1 | module AlondraHelper 2 | 3 | def alondra_subscribe_tag(resources) 4 | javascript_tag do 5 | %Q{ 6 | $(function(){ 7 | #{alondra_subscribe(resources)} 8 | }); 9 | } 10 | end 11 | end 12 | 13 | def alondra_subscribe(resources) 14 | resources = [resources] unless Enumerable === resources 15 | resources_paths = resources.collect { |r| "'#{polymorphic_path(r)}'" }.join(', ') 16 | 17 | "new AlondraClient('#{Alondra::Alondra.config.host}', #{Alondra::Alondra.config.port}, [#{resources_paths}]);" 18 | end 19 | 20 | def encrypted_token 21 | token = {:user_id => current_user.id, :valid_until => 5.minutes.from_now}.to_json 22 | Alondra::SessionParser.verifier.generate(token) 23 | end 24 | end -------------------------------------------------------------------------------- /bin/alondra: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | options = {} 4 | 5 | parser = OptionParser.new do |opts| 6 | opts.on "-p", "--port PORT", Integer, 7 | "Define what port TCP port to bind to (default: 3000)" do |arg| 8 | options[:port] = arg 9 | end 10 | 11 | opts.on "-a", "--address HOST", 12 | "bind to HOST address (default: 0.0.0.0)" do |arg| 13 | options[:host] = arg 14 | end 15 | 16 | opts.on "-s", "--queue-socket PATH", "Socket for IPC communication" do 17 | options[:quiet] = true 18 | end 19 | 20 | opts.on "-e", "--environment ENVIRONMENT", 21 | "The environment to run the Rails app on (default: development)" do |arg| 22 | ENV['RAILS_ENV'] ||= arg 23 | end 24 | end 25 | 26 | parser.banner = "alondra " 27 | 28 | parser.on_tail "-h", "--help", "Show help" do 29 | puts parser 30 | exit 1 31 | end 32 | 33 | parser.parse(ARGV) 34 | 35 | ENV['ALONDRA_SERVER'] ||= 'true' 36 | ENV['RAILS_ENV'] ||= 'development' 37 | 38 | require './config/environment' 39 | require 'alondra' 40 | 41 | 42 | Alondra::Alondra.start_with_options(options) 43 | 44 | puts "Alondra server started at port #{Alondra::Alondra.config.port}" 45 | 46 | sleep -------------------------------------------------------------------------------- /lib/alondra.rb: -------------------------------------------------------------------------------- 1 | require_relative 'alondra/log' 2 | require_relative 'alondra/message' 3 | require_relative 'alondra/event' 4 | require_relative 'alondra/connection' 5 | require_relative 'alondra/channel' 6 | require_relative 'alondra/command' 7 | require_relative 'alondra/command_dispatcher' 8 | require_relative 'alondra/event_router' 9 | require_relative 'alondra/message_queue_client' 10 | require_relative 'alondra/message_queue' 11 | require_relative 'alondra/pushing' 12 | require_relative 'alondra/event_listener' 13 | require_relative 'alondra/session_parser' 14 | require_relative 'alondra/listener_callback' 15 | require_relative 'alondra/push_controller' 16 | require_relative 'alondra/changes_callbacks' 17 | require_relative 'alondra/changes_push' 18 | require_relative 'alondra/server' 19 | 20 | module Alondra 21 | ActiveRecord::Base.extend ChangesPush 22 | ActionController::Base.send :include, Pushing 23 | 24 | class Alondra < Rails::Engine 25 | 26 | # Setting default configuration values 27 | config.port = Rails.env == 'test' ? 12346 : 12345 28 | config.host = '0.0.0.0' 29 | config.queue_socket = 'ipc:///tmp/alondra.ipc' 30 | 31 | initializer "configure EM thread pool" do 32 | # If we have more threads than db connections we will exhaust the conn pool 33 | threadpool_size = ActiveRecord::Base.connection_pool.instance_variable_get :@size 34 | threadpool_size -= 2 if threadpool_size > 2 35 | EM.threadpool_size = threadpool_size 36 | end 37 | 38 | initializer "enable sessions for flash websockets" do 39 | Rails.application.config.session_store :cookie_store, httponly: false 40 | end 41 | 42 | initializer "load listeners" do 43 | listeners_dir = File.join(Rails.root, 'app', 'listeners') 44 | 45 | Log.info "Loading event listeners in #{listeners_dir}" 46 | Dir[File.join(listeners_dir, '*.rb')].each { |file| require_dependency file } 47 | end 48 | 49 | config.after_initialize do 50 | PushController.send :include, Rails.application.routes.url_helpers 51 | end 52 | 53 | def self.start_with_options(options) 54 | options.each do |k, v| 55 | config.send "#{k}=", v 56 | end 57 | start_server_in_new_thread! 58 | end 59 | 60 | def self.start_server_in_new_thread! 61 | Thread.new do 62 | start_server! 63 | end 64 | end 65 | 66 | def self.start_server! 67 | start_server_proc = Proc.new do 68 | MessageQueue.instance.start_listening 69 | Server.run 70 | end 71 | 72 | if EM.reactor_running? 73 | EM.schedule(start_server_proc) 74 | else 75 | Log.info "starting EM reactor" 76 | EM.run(start_server_proc) 77 | end 78 | end 79 | 80 | def self.stop_server! 81 | EM.stop 82 | end 83 | end 84 | end 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /lib/alondra/changes_callbacks.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | module ChangesCallbacks 3 | extend self 4 | 5 | def push_updates(klass, options) 6 | klass.class_eval do 7 | after_update do |record| 8 | ChangesCallbacks.push_event :updated, record, options 9 | end 10 | end 11 | end 12 | 13 | def push_creations(klass, options) 14 | klass.class_eval do 15 | after_create do |record| 16 | ChangesCallbacks.push_event :created, record, options 17 | end 18 | end 19 | end 20 | 21 | def push_destroys(klass, options) 22 | klass.class_eval do 23 | after_destroy do |record| 24 | ChangesCallbacks.push_event :destroyed, record, options 25 | end 26 | end 27 | end 28 | 29 | def push_event(type, record, options) 30 | channels = channel_names_from(type, record, options) 31 | 32 | channels.each do |channel| 33 | event = Event.new(:event => type, :resource => record, 34 | :resource_type => record.class.name, :channel => channel) 35 | 36 | MessageQueueClient.push event 37 | end 38 | end 39 | 40 | def channel_names_from(type, record, options) 41 | case options[:to] 42 | when String then 43 | [options[:to]] 44 | when Symbol then 45 | records = record.send options[:to] 46 | Channel.names_for(records) 47 | else 48 | [Channel.default_name_for(record)] 49 | end 50 | end 51 | end 52 | end -------------------------------------------------------------------------------- /lib/alondra/changes_push.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | module ChangesPush 3 | def push(*args) 4 | last_arg = args.last 5 | options = Hash === last_arg ? args.delete(last_arg) : {} 6 | 7 | args.each do |event_type| 8 | case event_type 9 | when :changes then 10 | ChangesCallbacks.push_updates(self, options) 11 | ChangesCallbacks.push_creations(self, options) 12 | ChangesCallbacks.push_destroys(self, options) 13 | when :updates then 14 | ChangesCallbacks.push_updates(self, options) 15 | when :creations then 16 | ChangesCallbacks.push_creations(self, options) 17 | when :destroys then 18 | ChangesCallbacks.push_destroys(self, options) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/alondra/channel.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class Channel 3 | attr_reader :name 4 | attr_reader :em_channel 5 | attr_reader :connections 6 | 7 | class << self 8 | def list 9 | @channel_list ||= {} 10 | end 11 | 12 | def [](name) 13 | list[name] ||= Channel.new(name) 14 | end 15 | 16 | def for(records) 17 | names_for(records).collect { |name| Channel[name] } 18 | end 19 | 20 | def names_for(records) 21 | case records 22 | when String 23 | [records] 24 | when Enumerable then 25 | records.collect { |r| Channel.default_name_for(r) } 26 | else 27 | [Channel.default_name_for(records)] 28 | end 29 | end 30 | 31 | def default_name_for(resource_or_class, type = :member) 32 | 33 | if resource_or_class.kind_of?(Class) 34 | resource_name = resource_or_class.name.pluralize.underscore 35 | else 36 | resource = resource_or_class 37 | resource_name = resource.class.name.pluralize.underscore 38 | end 39 | 40 | case type 41 | when :member then 42 | "/#{resource_name}/#{resource.id}" 43 | when :collection then 44 | "/#{resource_name}/" 45 | end 46 | end 47 | end 48 | 49 | def initialize(name) 50 | @name = name 51 | @em_channel = EM::Channel.new 52 | @connections = {} 53 | end 54 | 55 | def subscribe(connection) 56 | sid = em_channel.subscribe do |event_or_message| 57 | connection.receive event_or_message 58 | end 59 | 60 | connection.channels << self 61 | connections[connection] = sid 62 | end 63 | 64 | def unsubscribe(connection) 65 | em_channel.unsubscribe connections[connection] 66 | 67 | connection.channels.delete self 68 | connections.delete connection 69 | 70 | event_hash = { :event => :unsubscribed, 71 | :resource => connection.session, 72 | :resource_type => connection.session.class.name, 73 | :channel => name } 74 | 75 | 76 | event = Event.new(event_hash, nil, connection) 77 | event.fire! 78 | end 79 | 80 | def receive(event_or_message) 81 | em_channel << event_or_message 82 | end 83 | end 84 | end -------------------------------------------------------------------------------- /lib/alondra/command.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class Command 3 | attr_reader :name 4 | attr_reader :connection 5 | attr_reader :channel_name 6 | 7 | def initialize(connection, command_hash) 8 | @connection = connection 9 | 10 | @name = command_hash[:command].to_sym 11 | @channel_name = command_hash[:channel] 12 | end 13 | 14 | def channel 15 | @channel ||= Channel[channel_name] 16 | end 17 | 18 | def execute! 19 | case name 20 | when :subscribe then 21 | channel.subscribe @connection 22 | fire_event :subscribed 23 | when :unsubscribe then 24 | # :unsubscribed event will be fired in Channel#unsubscribe 25 | channel.unsubscribe @connection 26 | end 27 | end 28 | 29 | def fire_event(event_type) 30 | event_hash = { 31 | :event => event_type, 32 | :resource => @connection.session, 33 | :resource_type => @connection.session.class.name, 34 | :channel => @channel_name 35 | } 36 | 37 | Event.new(event_hash, nil, connection).fire! 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /lib/alondra/command_dispatcher.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | 3 | class NotRecognizedCommand < StandardError; end 4 | 5 | module CommandDispatcher 6 | extend self 7 | 8 | def dispatch(input, connection) 9 | msg = parse(input) 10 | 11 | unless msg.kind_of?(Hash) && msg[:command].present? 12 | raise NotRecognizedCommand.new("Unrecognized command: #{input}") 13 | end 14 | 15 | Command.new(connection, msg).execute! 16 | end 17 | 18 | def parse(string) 19 | msg = ActiveSupport::JSON.decode(string).symbolize_keys 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /lib/alondra/connection.rb: -------------------------------------------------------------------------------- 1 | require 'uuidtools' 2 | 3 | module Alondra 4 | module Connections 5 | extend self 6 | 7 | def connections 8 | @connections ||= {} 9 | end 10 | 11 | def [](websocket) 12 | connections[websocket] 13 | end 14 | 15 | def []=(websocket, connection) 16 | connections[websocket] = connection 17 | end 18 | 19 | def delete(websocket) 20 | connections.delete websocket 21 | end 22 | end 23 | 24 | class Connection 25 | attr_reader :uuid 26 | attr_reader :websocket 27 | attr_reader :session 28 | attr_reader :channels 29 | 30 | def initialize(websocket, session = {}) 31 | @session = session.symbolize_keys 32 | @websocket = websocket 33 | @uuid = UUIDTools::UUID.random_create 34 | 35 | Connections[websocket] = self 36 | end 37 | 38 | def channels 39 | @channels ||= [] 40 | end 41 | 42 | def receive(event_or_message) 43 | Log.info "sending: #{event_or_message.to_json}" 44 | websocket.send event_or_message.to_json 45 | end 46 | 47 | def destroy! 48 | channels.each { |c| c.unsubscribe self } 49 | Connections.delete self.websocket 50 | end 51 | end 52 | end -------------------------------------------------------------------------------- /lib/alondra/event.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class Event 3 | attr_reader :channel_name 4 | attr_reader :type 5 | attr_reader :resource 6 | attr_reader :resource_type 7 | attr_reader :connection 8 | 9 | def initialize(event_hash, from_json = nil, connection = nil) 10 | @connection = connection 11 | @type = event_hash[:event].to_sym 12 | @json_encoded = from_json 13 | 14 | set_resource_from(event_hash) 15 | set_channel_from(event_hash) 16 | end 17 | 18 | def channel 19 | @channel ||= Channel[channel_name] 20 | end 21 | 22 | def fire! 23 | if connection 24 | # We are inside the Alondra Server 25 | EM.schedule do 26 | MessageQueue.instance.receive self 27 | end 28 | else 29 | MessageQueueClient.push self 30 | end 31 | end 32 | 33 | def as_json 34 | { 35 | :event => type, 36 | :resource_type => resource_type, 37 | :resource => resource.as_json, 38 | :channel => channel_name 39 | } 40 | end 41 | 42 | def to_json 43 | @json_encoded ||= ActiveSupport::JSON.encode(as_json) 44 | end 45 | 46 | private 47 | 48 | def fetch(resource_type_name, attributes) 49 | attributes.symbolize_keys! 50 | resource_class = Kernel.const_get(resource_type_name) 51 | 52 | return attributes unless resource_class < ActiveRecord::Base 53 | 54 | resource = resource_class.new 55 | 56 | filtered_attributes = attributes.delete_if { |k,v| !resource.has_attribute?(k) } 57 | 58 | resource.assign_attributes(filtered_attributes, :without_protection => true) 59 | resource 60 | end 61 | 62 | def set_resource_from(event_hash) 63 | if Hash === event_hash[:resource] 64 | @resource = fetch(event_hash[:resource_type], event_hash[:resource]) 65 | else 66 | @resource = event_hash[:resource] 67 | end 68 | 69 | @resource_type = event_hash[:resource_type] || resource.class.name 70 | end 71 | 72 | def set_channel_from(event_hash) 73 | if event_hash[:channel].present? 74 | @channel_name = event_hash[:channel] 75 | else 76 | channel_type = type == :updated ? :member : :collection 77 | Channel.default_name_for(resource, channel_type) 78 | end 79 | end 80 | end 81 | end -------------------------------------------------------------------------------- /lib/alondra/event_listener.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class EventListener 3 | include Pushing 4 | 5 | attr_accessor :event 6 | attr_accessor :resource 7 | attr_accessor :channel_name 8 | 9 | class << self 10 | def listened_patterns 11 | @listened_patterns ||= [default_listened_pattern] 12 | end 13 | 14 | def listen_to?(channel_name) 15 | listened_patterns.any? { |p| p =~ channel_name } 16 | end 17 | 18 | def listen_to(channel_name) 19 | unless @custom_pattern_provided 20 | listened_patterns.clear 21 | @custom_pattern_provided = true 22 | end 23 | 24 | if Regexp === channel_name 25 | listened_patterns << channel_name 26 | else 27 | escaped_pattern = Regexp.escape(channel_name) 28 | listened_patterns << Regexp.new("^#{escaped_pattern}") 29 | end 30 | end 31 | 32 | def on(event_type, options = {}, &block) 33 | callbacks << ListenerCallback.new(event_type, options, block) 34 | end 35 | 36 | def callbacks 37 | @callbacks ||= [] 38 | end 39 | 40 | def default_listened_pattern 41 | word = self.name.demodulize 42 | word.gsub!(/Listener$/, '') 43 | word.gsub!(/::/, '/') 44 | word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1\/\2') 45 | word.gsub!(/([a-z\d])([A-Z])/,'\1/\2') 46 | word.downcase! 47 | Regexp.new("^/#{word}") 48 | end 49 | 50 | def inherited(subclass) 51 | # In development mode Rails will load the same class many times 52 | # Delete it first if we already have parsed it 53 | EventRouter.listeners.delete_if { |l| l.name == subclass.name } 54 | EventRouter.listeners << subclass 55 | end 56 | 57 | def matching_callbacks_for(event) 58 | callbacks.find_all { |c| c.matches?(event) } 59 | end 60 | 61 | def process(event) 62 | matching_callbacks_for(event).each do |callback| 63 | new_instance = new(event) 64 | begin 65 | new_instance.instance_exec(event, &callback.proc) 66 | rescue Exception => ex 67 | Log.error 'Error while processing event listener callback' 68 | Log.error ex.message 69 | Log.error ex.backtrace.join("\n") 70 | end 71 | end 72 | end 73 | end 74 | 75 | def session 76 | @connection.session 77 | end 78 | 79 | def initialize(event) 80 | @event = event 81 | @resource = event.resource 82 | @channel_name = event.channel_name 83 | @connection = event.connection 84 | end 85 | end 86 | end -------------------------------------------------------------------------------- /lib/alondra/event_router.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class EventRouter 3 | 4 | def self.listeners 5 | @listeners ||= [] 6 | end 7 | 8 | def process(event) 9 | event.channel.receive(event) 10 | 11 | # Event listeners callback can manipulate AR objects and so can potentially 12 | # block the EM reactor thread. To avoid that, we defer them to another thread. 13 | EM.defer do 14 | 15 | # Ensure the connection associated with the thread is checked in 16 | # after the callbacks are processed 17 | ActiveRecord::Base.connection_pool.with_connection do 18 | listening_classes = EventRouter.listeners.select do |ob| 19 | ob.listen_to?(event.channel_name) 20 | end 21 | 22 | listening_classes.each { |listening_class| listening_class.process(event) } 23 | end 24 | end 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /lib/alondra/listener_callback.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class ListenerCallback 3 | attr_reader :event_type 4 | attr_reader :options 5 | attr_reader :proc 6 | 7 | CHANNEL_NAME_PATTERN = %r{\d+$} 8 | 9 | def initialize(event_type, options = {}, proc) 10 | @event_type = event_type 11 | @options = options 12 | @proc = proc 13 | end 14 | 15 | def matches?(event) 16 | return false unless event.type == event_type 17 | 18 | case options[:to] 19 | when nil then true 20 | when :member then 21 | member_channel? event.channel_name 22 | when :collection then 23 | !member_channel?(event.channel_name) 24 | end 25 | end 26 | 27 | def to_proc 28 | proc 29 | end 30 | 31 | private 32 | 33 | def member_channel?(channel_name) 34 | channel_name =~ CHANNEL_NAME_PATTERN 35 | end 36 | end 37 | end -------------------------------------------------------------------------------- /lib/alondra/log.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | module Log 3 | extend self 4 | 5 | NUMBER_TO_NAME_MAP = {0=>'DEBUG', 1=>'INFO', 2=>'WARN', 3=>'ERROR', 4=>'FATAL', 5=>'UNKNOWN'} 6 | NUMBER_TO_COLOR_MAP = {0=>'0;37', 1=>'32', 2=>'33', 3=>'31', 4=>'31', 5=>'37'} 7 | 8 | 9 | def debug(message) 10 | add(ActiveSupport::BufferedLogger::Severity::DEBUG, message) 11 | end 12 | 13 | def info(message) 14 | add(ActiveSupport::BufferedLogger::Severity::INFO, message) 15 | end 16 | 17 | def warn(message) 18 | add(ActiveSupport::BufferedLogger::Severity::WARN, message) 19 | end 20 | 21 | def error(message) 22 | add(ActiveSupport::BufferedLogger::Severity::ERROR, message) 23 | end 24 | 25 | def fatal(message) 26 | add(ActiveSupport::BufferedLogger::Severity::FATAL, message) 27 | end 28 | 29 | def unkwon(message) 30 | add(ActiveSupport::BufferedLogger::Severity::UNKNOWN, message) 31 | end 32 | 33 | private 34 | 35 | def add(severity, message = nil, progname = 'ALONDRA') 36 | sevstring = NUMBER_TO_NAME_MAP[severity] 37 | color = NUMBER_TO_COLOR_MAP[severity] 38 | 39 | message = "\n\033[35m#{progname}:\033[0m[\033[#{color}m#{sevstring}\033[0m] #{message.strip}\033\n" 40 | Rails.logger.add(severity, message, progname) 41 | end 42 | end 43 | end -------------------------------------------------------------------------------- /lib/alondra/message.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class Message 3 | attr_reader :content 4 | attr_reader :channel_names 5 | 6 | def initialize(content, channel_names) 7 | @content = content 8 | @channel_names = channel_names 9 | end 10 | 11 | def enqueue 12 | MessageQueueClient.push self 13 | end 14 | 15 | def send_to_channels 16 | channels.each do |channel| 17 | channel.receive self 18 | end 19 | end 20 | 21 | def as_json 22 | {:message => content, :channel_names => channel_names} 23 | end 24 | 25 | def to_json 26 | ActiveSupport::JSON.encode(as_json) 27 | end 28 | 29 | private 30 | 31 | def channels 32 | channel_names.collect { |name| Channel[name] } 33 | end 34 | end 35 | end -------------------------------------------------------------------------------- /lib/alondra/message_queue.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | require 'ffi' 3 | require 'em-zeromq' 4 | 5 | module Alondra 6 | class MessageQueue 7 | include Singleton 8 | 9 | def start_listening 10 | Log.info "Starting message queue" 11 | 12 | if @pull_socket || @push_socket 13 | Log.warn 'Connections to message queue started twice' 14 | reset! 15 | end 16 | 17 | push_socket 18 | pull_socket 19 | 20 | self 21 | end 22 | 23 | def on_readable(socket, messages) 24 | messages.each do |received| 25 | begin 26 | parse received.copy_out_string 27 | rescue Exception => ex 28 | Log.error "Error raised while processing message" 29 | Log.error "#{ex.class}: #{ex.message}" 30 | Log.error ex.backtrace.join("\n") if ex.respond_to? :backtrace 31 | end 32 | end 33 | end 34 | 35 | def parse(received_string) 36 | received_hash = ActiveSupport::JSON.decode(received_string).symbolize_keys 37 | 38 | if received_hash[:event] 39 | event = Event.new(received_hash, received_string) 40 | receive(event) 41 | elsif received_hash[:message] 42 | message = Message.new(received_hash[:message], received_hash[:channel_names]) 43 | message.send_to_channels 44 | else 45 | Log.warn "Unrecognized message type #{received_string}" 46 | end 47 | end 48 | 49 | def receive(event) 50 | event_router.process(event) 51 | end 52 | 53 | def push_socket 54 | @push_socket ||= begin 55 | push_socket = context.socket(ZMQ::PUSH) 56 | push_socket.connect(Alondra.config.queue_socket) 57 | push_socket 58 | end 59 | end 60 | 61 | def pull_socket 62 | @pull_socket ||= begin 63 | pull_socket = context.socket(ZMQ::PULL, self) 64 | pull_socket.bind(Alondra.config.queue_socket) 65 | pull_socket 66 | end 67 | end 68 | 69 | def reset! 70 | @push_socket.unbind 71 | @pull_socket.unbind 72 | 73 | @context = nil 74 | @push_socket = nil 75 | @pull_socket = nil 76 | end 77 | 78 | private 79 | 80 | def event_router 81 | @event_router ||= EventRouter.new 82 | end 83 | 84 | def context 85 | @context ||= EM::ZeroMQ::Context.new(1) 86 | end 87 | end 88 | end 89 | 90 | -------------------------------------------------------------------------------- /lib/alondra/message_queue_client.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | require 'ffi-rzmq' 3 | require 'em-zeromq' 4 | 5 | module Alondra 6 | class MessageQueueClient 7 | 8 | def self.push(message) 9 | instance.send_message(message) 10 | end 11 | 12 | def self.instance 13 | if EM.reactor_running? 14 | async_instance 15 | else 16 | sync_instance 17 | end 18 | end 19 | 20 | def self.async_instance 21 | @async_instance ||= AsyncMessageQueueClient.new 22 | end 23 | 24 | def self.sync_instance 25 | @sync_instance ||= SyncMessageQueueClient.new 26 | end 27 | end 28 | 29 | class AsyncMessageQueueClient < MessageQueueClient 30 | def send_message(message) 31 | EM.schedule do 32 | begin 33 | push_socket.send_msg(message.to_json) 34 | rescue Exception => ex 35 | Log.error "Exception while sending message to message queue: #{ex.message}" 36 | end 37 | end 38 | end 39 | 40 | def push_socket 41 | @push_socket ||= begin 42 | push_socket = context.socket(ZMQ::PUSH) 43 | push_socket.connect(Alondra.config.queue_socket) 44 | push_socket 45 | end 46 | end 47 | 48 | def context 49 | @context ||= EM::ZeroMQ::Context.new(1) 50 | end 51 | end 52 | 53 | class SyncMessageQueueClient < MessageQueueClient 54 | 55 | def send_message(message) 56 | begin 57 | push_socket.send_string(message.to_json) 58 | rescue Exception => ex 59 | Log.error "Exception while sending message to message queue: #{ex.message}" 60 | end 61 | end 62 | 63 | def push_socket 64 | @push_socket ||= begin 65 | socket = context.socket(ZMQ::PUSH) 66 | socket.connect(Alondra.config.queue_socket) 67 | socket 68 | end 69 | end 70 | 71 | def context 72 | @context ||= ZMQ::Context.new(1) 73 | end 74 | end 75 | end -------------------------------------------------------------------------------- /lib/alondra/push_controller.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class PushController 3 | include ActiveSupport::Configurable 4 | include AbstractController::Logger 5 | include AbstractController::Rendering 6 | include AbstractController::Helpers 7 | include AbstractController::Translation 8 | include AbstractController::AssetPaths 9 | 10 | helper_method :protect_against_forgery? 11 | 12 | attr_accessor :channel_names 13 | attr_accessor :request 14 | 15 | def initialize(context, to, request = nil) 16 | @channel_names = Channel.names_for(to) 17 | @request = request 18 | 19 | self.class.view_paths = ActionController::Base.view_paths 20 | copy_instance_variables_from(context) 21 | end 22 | 23 | def render_push(options) 24 | if EM.reactor_thread? 25 | Log.warn 'You are rendering a view from the Event Machine reactor thread' 26 | Log.warn 'Rendering a view is a possibly blocking operation, so be careful' 27 | end 28 | 29 | message_content = render_to_string(*options) 30 | msg = Message.new(message_content, channel_names) 31 | msg.enqueue 32 | end 33 | 34 | def _prefixes 35 | ['application'] 36 | end 37 | 38 | def view_paths 39 | @view_paths ||= ApplicationController.send '_view_paths' 40 | end 41 | 42 | def action_name 43 | 'push' 44 | end 45 | 46 | def protect_against_forgery? 47 | false 48 | end 49 | 50 | def self.protect_against_forgery? 51 | false 52 | end 53 | 54 | private 55 | 56 | def copy_instance_variables_from(context) 57 | context.instance_variables.each do |var| 58 | value = context.instance_variable_get(var) 59 | instance_variable_set(var, value) 60 | end 61 | end 62 | end 63 | end -------------------------------------------------------------------------------- /lib/alondra/pushing.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | 3 | class PushingException < StandardError; end 4 | 5 | module Pushing 6 | def push(*args) 7 | raise PushingException.new('You need to specify the channel to push') unless args.last[:to].present? 8 | 9 | to = args.last.delete(:to) 10 | 11 | # If we are called in the context of a request we save this information 12 | # so we can create proper routes 13 | caller_request = self.respond_to?(:request) ? request : nil 14 | 15 | controller = PushController.new(self, to, caller_request) 16 | controller.render_push(args) 17 | end 18 | end 19 | end -------------------------------------------------------------------------------- /lib/alondra/server.rb: -------------------------------------------------------------------------------- 1 | require 'em-websocket' 2 | 3 | module Alondra 4 | module Server 5 | extend self 6 | 7 | def run 8 | Log.info "Server starting on port #{Alondra.config.port}" 9 | 10 | EM::WebSocket.start(:host => '0.0.0.0', :port => Alondra.config.port) do |websocket| 11 | 12 | websocket.onopen do 13 | session = SessionParser.parse(websocket) 14 | 15 | Log.info "client connected." 16 | Connection.new(websocket, session) 17 | end 18 | 19 | websocket.onclose do 20 | Log.info "Connection closed" 21 | Connections[websocket].destroy! if Connections[websocket].present? 22 | end 23 | 24 | websocket.onerror do |ex| 25 | puts "Error: #{ex.message}" 26 | Log.error "Error: #{ex.message}" 27 | Log.error ex.backtrace.join("\n") 28 | Connections[websocket].destroy! if Connections[websocket] 29 | end 30 | 31 | websocket.onmessage do |msg| 32 | Log.info "received: #{msg}" 33 | CommandDispatcher.dispatch(msg, Connections[websocket]) 34 | end 35 | end 36 | 37 | EM.error_handler do |error| 38 | puts "Error raised during event loop: #{error.message}" 39 | Log.error "Error raised during event loop: #{error.message}" 40 | Log.error error.backtrace.join("\n") 41 | end 42 | end 43 | end 44 | end -------------------------------------------------------------------------------- /lib/alondra/session_parser.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | 3 | module Alondra 4 | module SessionParser 5 | extend self 6 | 7 | def verifier 8 | @verifier ||= ActiveSupport::MessageVerifier.new(Rails.application.config.secret_token) 9 | end 10 | 11 | def parse(websocket) 12 | cookie = websocket.request['cookie'] || websocket.request['Cookie'] 13 | token = websocket.request['query']['token'] 14 | 15 | if token.present? 16 | SessionParser.parse_token(token) 17 | elsif cookie.present? 18 | SessionParser.parse_cookie(cookie) 19 | else 20 | Hash.new 21 | end 22 | end 23 | 24 | def parse_cookie(cookie) 25 | begin 26 | cookies = cookie.split(';') 27 | session_key = Rails.application.config.session_options[:key] 28 | 29 | encoded_session = cookies.detect{|c| c.include?(session_key)}.gsub("#{session_key}=",'').strip 30 | verifier.verify(CGI.unescape(encoded_session)) 31 | rescue ActiveSupport::MessageVerifier::InvalidSignature => ex 32 | Log.error "invalid session cookie: #{cookie}" 33 | Hash.new 34 | rescue Exception => ex 35 | Log.error "Exception parsing session from cookie: #{ex.message}" 36 | end 37 | end 38 | 39 | def parse_token(token) 40 | begin 41 | decoded_token = verifier.verify(token) 42 | ActiveSupport::JSON.decode(decoded_token) 43 | rescue ActiveSupport::MessageVerifier::InvalidSignature => ex 44 | Log.error "invalid session token: #{token}" 45 | Hash.new 46 | end 47 | end 48 | 49 | def session_key 50 | Rails.application.config.session_options.key 51 | end 52 | 53 | def marshall 54 | Rails.application.config.session_options[:coder] 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /lib/alondra/version.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | VERSION = '0.1.1' 3 | end -------------------------------------------------------------------------------- /lib/generators/alondra/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Explain the generator 3 | 4 | Example: 5 | rails generate alondra Thing 6 | 7 | This will create: 8 | what/will/it/create 9 | -------------------------------------------------------------------------------- /lib/generators/alondra/alondra_generator.rb: -------------------------------------------------------------------------------- 1 | class AlondraGenerator < Rails::Generators::NamedBase 2 | source_root File.expand_path('../templates', __FILE__) 3 | 4 | def generate_install 5 | copy_file "alondra", "script/alondra" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/alondra/templates/alondra: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Set up gems listed in the Gemfile. 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', File.dirname(__FILE__)) 4 | 5 | require 'rubygems' 6 | require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) 7 | require 'daemons' 8 | 9 | options = { 10 | :app_name => 'alondra', 11 | :dir_mode => :script, 12 | :dir => 'tmp/pids' 13 | } 14 | 15 | Daemons.run_proc 'alondra', options do 16 | 17 | ENV["ALONDRA_SERVER"] = 'true' 18 | 19 | require_relative File.join('..', 'config', 'environment') 20 | 21 | Rails.logger.info "Started alondra server on port #{Alondra::Alondra.config.port}... #{EM.reactor_running?}" 22 | 23 | Alondra::Alondra.start_server_in_new_thread!.join 24 | 25 | Rails.logger.info 'Alondra server terminated' 26 | end -------------------------------------------------------------------------------- /lib/tasks/alondra_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :alondra do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /script/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | #!/usr/bin/env ruby 3 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 4 | 5 | ENGINE_PATH = File.expand_path('../..', __FILE__) 6 | load File.expand_path('../../test/dummy/script/rails', __FILE__) 7 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # Add your own tasks in files placed in lib/tasks ending in .rake, 3 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 4 | 5 | require File.expand_path('../config/application', __FILE__) 6 | 7 | Dummy::Application.load_tasks 8 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into including all the files listed below. 2 | // Add new JavaScript/Coffee code in separate files in this directory and they'll automatically 3 | // be included in the compiled file accessible from http://example.com/assets/application.js 4 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 5 | // the compiled file. 6 | // 7 | //= require jquery 8 | //= require jquery_ujs 9 | //= require_tree . 10 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll automatically include all the stylesheets available in this directory 3 | * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at 4 | * the top of the compiled file, but it's generally better to create a new file per style scope. 5 | *= require_self 6 | *= require_tree . 7 | */ -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include ControllerAuthentication 3 | protect_from_forgery 4 | 5 | def current_user 6 | @current_user ||= find_current_user 7 | end 8 | 9 | private 10 | 11 | def find_current_user 12 | User.find(session[:user_id]) if session[:user_id].present? 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/chats_controller.rb: -------------------------------------------------------------------------------- 1 | class ChatsController < ApplicationController 2 | before_filter :login_required 3 | 4 | # GET /chats 5 | # GET /chats.json 6 | def index 7 | @chats = Chat.all 8 | 9 | respond_to do |format| 10 | format.html # index.html.erb 11 | format.json { render json: @chats } 12 | end 13 | end 14 | 15 | # GET /chats/1 16 | # GET /chats/1.json 17 | def show 18 | @chat = Chat.find(params[:id]) 19 | 20 | respond_to do |format| 21 | format.html # show.html.erb 22 | format.json { render json: @chat } 23 | end 24 | end 25 | 26 | # GET /chats/new 27 | # GET /chats/new.json 28 | def new 29 | @chat = Chat.new 30 | 31 | respond_to do |format| 32 | format.html # new.html.erb 33 | format.json { render json: @chat } 34 | end 35 | end 36 | 37 | # GET /chats/1/edit 38 | def edit 39 | @chat = Chat.find(params[:id]) 40 | end 41 | 42 | # POST /chats 43 | # POST /chats.json 44 | def create 45 | @chat = Chat.new(params[:chat]) 46 | 47 | respond_to do |format| 48 | if @chat.save 49 | format.html { redirect_to @chat, notice: 'Chat was successfully created.' } 50 | format.json { render json: @chat, status: :created, location: @chat } 51 | else 52 | format.html { render action: "new" } 53 | format.json { render json: @chat.errors, status: :unprocessable_entity } 54 | end 55 | end 56 | end 57 | 58 | # PUT /chats/1 59 | # PUT /chats/1.json 60 | def update 61 | @chat = Chat.find(params[:id]) 62 | 63 | respond_to do |format| 64 | if @chat.update_attributes(params[:chat]) 65 | format.html { redirect_to @chat, notice: 'Chat was successfully updated.' } 66 | format.json { head :ok } 67 | else 68 | format.html { render action: "edit" } 69 | format.json { render json: @chat.errors, status: :unprocessable_entity } 70 | end 71 | end 72 | end 73 | 74 | # DELETE /chats/1 75 | # DELETE /chats/1.json 76 | def destroy 77 | @chat = Chat.find(params[:id]) 78 | @chat.destroy 79 | 80 | respond_to do |format| 81 | format.html { redirect_to chats_url } 82 | format.json { head :ok } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/messages_controller.rb: -------------------------------------------------------------------------------- 1 | class MessagesController < ApplicationController 2 | respond_to :html, :js, :json 3 | 4 | def create 5 | @chat = Chat.find(params[:chat_id]) 6 | @message = @chat.messages.build(params[:message]) 7 | 8 | @message.save 9 | 10 | respond_with @message, :location => @chat 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | def new 3 | end 4 | 5 | def create 6 | user = User.authenticate(params[:login], params[:password]) 7 | if user 8 | session[:user_id] = user.id 9 | redirect_to_target_or_default root_url, :notice => "Logged in successfully." 10 | else 11 | flash.now[:alert] = "Invalid login or password." 12 | render :action => 'new' 13 | end 14 | end 15 | 16 | def destroy 17 | session[:user_id] = nil 18 | redirect_to root_url, :notice => "You have been logged out." 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | before_filter :login_required, :except => [:new, :create] 3 | 4 | def new 5 | @user = User.new 6 | end 7 | 8 | def create 9 | @user = User.new(params[:user]) 10 | if @user.save 11 | session[:user_id] = @user.id 12 | redirect_to root_url, :notice => "Thank you for signing up! You are now logged in." 13 | else 14 | render :action => 'new' 15 | end 16 | end 17 | 18 | def edit 19 | @user = current_user 20 | end 21 | 22 | def update 23 | @user = current_user 24 | if @user.update_attributes(params[:user]) 25 | redirect_to root_url, :notice => "Your profile has been updated." 26 | else 27 | render :action => 'edit' 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/chats_helper.rb: -------------------------------------------------------------------------------- 1 | module ChatsHelper 2 | 3 | def present_users 4 | @present_users = [] #||= Alondra::Channel['/messages/'].users 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/error_messages_helper.rb: -------------------------------------------------------------------------------- 1 | module ErrorMessagesHelper 2 | # Render error messages for the given objects. The :message and :header_message options are allowed. 3 | def error_messages_for(*objects) 4 | options = objects.extract_options! 5 | options[:header_message] ||= I18n.t(:"activerecord.errors.header", :default => "Invalid Fields") 6 | options[:message] ||= I18n.t(:"activerecord.errors.message", :default => "Correct the following errors and try again.") 7 | messages = objects.compact.map { |o| o.errors.full_messages }.flatten 8 | unless messages.empty? 9 | content_tag(:div, :class => "error_messages") do 10 | list_items = messages.map { |msg| content_tag(:li, msg) } 11 | content_tag(:h2, options[:header_message]) + content_tag(:p, options[:message]) + content_tag(:ul, list_items.join.html_safe) 12 | end 13 | end 14 | end 15 | 16 | module FormBuilderAdditions 17 | def error_messages(options = {}) 18 | @template.error_messages_for(@object, options) 19 | end 20 | end 21 | end 22 | 23 | ActionView::Helpers::FormBuilder.send(:include, ErrorMessagesHelper::FormBuilderAdditions) 24 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/layout_helper.rb: -------------------------------------------------------------------------------- 1 | # These helper methods can be called in your template to set variables to be used in the layout 2 | # This module should be included in all views globally, 3 | # to do so you may need to add this line to your ApplicationController 4 | # helper :layout 5 | module LayoutHelper 6 | def title(page_title, show_title = true) 7 | content_for(:title) { h(page_title.to_s) } 8 | @show_title = show_title 9 | end 10 | 11 | def show_title? 12 | @show_title 13 | end 14 | 15 | def stylesheet(*args) 16 | content_for(:head) { stylesheet_link_tag(*args) } 17 | end 18 | 19 | def javascript(*args) 20 | content_for(:head) { javascript_include_tag(*args) } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afcapel/alondra/071ec2fc677846cdb1fc3a964211ecdbf6c55d69/test/dummy/app/mailers/.gitkeep -------------------------------------------------------------------------------- /test/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afcapel/alondra/071ec2fc677846cdb1fc3a964211ecdbf6c55d69/test/dummy/app/models/.gitkeep -------------------------------------------------------------------------------- /test/dummy/app/models/chat.rb: -------------------------------------------------------------------------------- 1 | class Chat < ActiveRecord::Base 2 | has_many :messages 3 | push :changes 4 | 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/message.rb: -------------------------------------------------------------------------------- 1 | class Message < ActiveRecord::Base 2 | belongs_to :chat 3 | push :changes, :to => :chat 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | require 'digest' 2 | require 'securerandom' 3 | 4 | class User < ActiveRecord::Base 5 | # new columns need to be added here to be writable through mass assignment 6 | attr_accessible :username, :email, :password, :password_confirmation 7 | 8 | attr_accessor :password 9 | before_save :prepare_password 10 | 11 | validates_presence_of :username 12 | validates_uniqueness_of :username, :email, :allow_blank => true 13 | validates_format_of :username, :with => /^[-\w\._@]+$/i, :allow_blank => true, :message => "should only contain letters, numbers, or .-_@" 14 | validates_format_of :email, :with => /^[-a-z0-9_+\.]+\@([-a-z0-9]+\.)+[a-z0-9]{2,4}$/i 15 | validates_presence_of :password, :on => :create 16 | validates_confirmation_of :password 17 | validates_length_of :password, :minimum => 4, :allow_blank => true 18 | 19 | # login can be either username or email address 20 | def self.authenticate(login, pass) 21 | user = find_by_username(login) || find_by_email(login) 22 | return user if user && user.password_hash == user.encrypt_password(pass) 23 | end 24 | 25 | def encrypt_password(pass) 26 | Digest::SHA2.hexdigest(pass + password_salt) 27 | end 28 | 29 | private 30 | 31 | def prepare_password 32 | unless password.blank? 33 | self.password_salt = SecureRandom.hex(16) 34 | self.password_hash = encrypt_password(password) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/dummy/app/views/chats/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(@chat) do |f| %> 2 | <% if @chat.errors.any? %> 3 |
    4 |

    <%= pluralize(@chat.errors.count, "error") %> prohibited this chat from being saved:

    5 | 6 |
      7 | <% @chat.errors.full_messages.each do |msg| %> 8 |
    • <%= msg %>
    • 9 | <% end %> 10 |
    11 |
    12 | <% end %> 13 | 14 |
    15 | <%= f.label :name %>
    16 | <%= f.text_field :name %> 17 |
    18 |
    19 | <%= f.submit %> 20 |
    21 | <% end %> 22 | -------------------------------------------------------------------------------- /test/dummy/app/views/chats/edit.html.erb: -------------------------------------------------------------------------------- 1 |

    Editing chat

    2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Show', @chat %> | 6 | <%= link_to 'Back', chats_path %> 7 | -------------------------------------------------------------------------------- /test/dummy/app/views/chats/index.html.erb: -------------------------------------------------------------------------------- 1 |

    Listing chats

    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | <% @chats.each do |chat| %> 12 | 13 | 14 | 15 | 16 | 17 | 18 | <% end %> 19 |
    Name
    <%= chat.name %><%= link_to 'Show', chat %><%= link_to 'Edit', edit_chat_path(chat) %><%= link_to 'Destroy', chat, confirm: 'Are you sure?', method: :delete %>
    20 | 21 |
    22 | 23 | <%= link_to 'New Chat', new_chat_path %> 24 | -------------------------------------------------------------------------------- /test/dummy/app/views/chats/new.html.erb: -------------------------------------------------------------------------------- 1 |

    New chat

    2 | 3 | <%= render 'form' %> 4 | 5 | <%= link_to 'Back', chats_path %> 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/chats/show.html.erb: -------------------------------------------------------------------------------- 1 |

    <%= notice %>

    2 | 3 |

    4 | Chat name: 5 | <%= @chat.name %> 6 |

    7 | 8 |
      9 |

      Present users

      10 | <% present_users.each do |user| %> 11 | <%= user.username %> 12 | <% end %> 13 |
    14 | 15 |
    16 |

    Messages

    17 | <% @chat.messages.each do |msg| %> 18 |
    19 | <%= simple_format msg.text %> 20 |
    21 |
    22 | <% end %> 23 |
    24 | 25 | <%= form_for [@chat, @chat.messages.build] do |f| %> 26 |

    New Message

    27 | <%= f.text_area :text, :rows => 5 %> 28 | 29 |

    30 | <%= f.submit 'send' %> 31 |

    32 | <% end %> 33 | 34 | <%= link_to 'Back', chats_path %> 35 | 36 | <%= javascript_include_tag 'alondra-client' %> 37 | 38 | 50 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for?(:title) ? yield(:title) : "Untitled" %> 5 | <%= stylesheet_link_tag "application" %> 6 | <%= javascript_include_tag "application" %> 7 | <%= csrf_meta_tag %> 8 | <%= yield(:head) %> 9 | 10 | 11 |
    12 | <% flash.each do |name, msg| %> 13 | <%= content_tag :div, msg, :id => "flash_#{name}" %> 14 | <% end %> 15 | <%= content_tag :h1, yield(:title) if show_title? %> 16 | <%= yield %> 17 |
    18 | 19 | 20 | -------------------------------------------------------------------------------- /test/dummy/app/views/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <% title "Log in" %> 2 | 3 |

    Don't have an account? <%= link_to "Sign up!", signup_path %>

    4 | 5 | <%= form_tag sessions_path do %> 6 |

    7 | <%= label_tag :login, "Username or Email Address" %>
    8 | <%= text_field_tag :login, params[:login] %> 9 |

    10 |

    11 | <%= label_tag :password %>
    12 | <%= password_field_tag :password %> 13 |

    14 |

    <%= submit_tag "Log in" %>

    15 | <% end %> 16 | -------------------------------------------------------------------------------- /test/dummy/app/views/shared/_message.js.erb: -------------------------------------------------------------------------------- 1 | $('#messages').append('
    <%= @user.username %> says <%= @text %>
    '); -------------------------------------------------------------------------------- /test/dummy/app/views/users/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for @user do |f| %> 2 | <%= f.error_messages %> 3 |

    4 | <%= f.label :username %>
    5 | <%= f.text_field :username %> 6 |

    7 |

    8 | <%= f.label :email, "Email Address" %>
    9 | <%= f.text_field :email %> 10 |

    11 |

    12 | <%= f.label :password %>
    13 | <%= f.password_field :password %> 14 |

    15 |

    16 | <%= f.label :password_confirmation, "Confirm Password" %>
    17 | <%= f.password_field :password_confirmation %> 18 |

    19 |

    <%= f.submit (@user.new_record? ? "Sign up" : "Update") %>

    20 | <% end %> 21 | -------------------------------------------------------------------------------- /test/dummy/app/views/users/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% title "Update Profile" %> 2 | 3 | <%= render 'form' %> 4 | -------------------------------------------------------------------------------- /test/dummy/app/views/users/index.html.erb: -------------------------------------------------------------------------------- 1 |

    Listing users

    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% @users.each do |user| %> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <% end %> 21 |
    EmailAccess token
    <%= user.email %><%= user.access_token %><%= link_to 'Show', user %><%= link_to 'Edit', edit_user_path(user) %><%= link_to 'Destroy', user, confirm: 'Are you sure?', method: :delete %>
    22 | 23 |
    24 | 25 | <%= link_to 'New User', new_user_path %> 26 | -------------------------------------------------------------------------------- /test/dummy/app/views/users/new.html.erb: -------------------------------------------------------------------------------- 1 | <% title "Sign up" %> 2 | 3 |

    Already have an account? <%= link_to "Log in", login_path %>.

    4 | 5 | <%= render 'form' %> 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/users/show.html.erb: -------------------------------------------------------------------------------- 1 |

    <%= notice %>

    2 | 3 |

    4 | Email: 5 | <%= @user.email %> 6 |

    7 | 8 |

    9 | Access token: 10 | <%= @user.access_token %> 11 |

    12 | 13 | 14 | <%= link_to 'Edit', edit_user_path(@user) %> | 15 | <%= link_to 'Back', users_path %> 16 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Dummy::Application 5 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | Bundler.require 6 | require "alondra" 7 | 8 | module Dummy 9 | class Application < Rails::Application 10 | config.autoload_paths << "#{config.root}/lib" # Settings in config/environments/* take precedence over those specified here. 11 | # Application configuration should go into files in config/initializers 12 | # -- all .rb files in that directory are automatically loaded. 13 | 14 | # Custom directories with classes and modules you want to be autoloadable. 15 | # config.autoload_paths += %W(#{config.root}/extras) 16 | 17 | # Only load the plugins named here, in the order given (default is alphabetical). 18 | # :all can be used as a placeholder for all plugins not explicitly named. 19 | # config.plugins = [ :exception_notification, :ssl_requirement, :all ] 20 | 21 | # Activate listeners that should always be running. 22 | # config.active_record.listeners = :cacher, :garbage_collector, :forum_observer 23 | 24 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 25 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 26 | # config.time_zone = 'Central Time (US & Canada)' 27 | 28 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 29 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 30 | # config.i18n.default_locale = :de 31 | 32 | # Configure the default encoding used in templates for Ruby 1.9. 33 | config.encoding = "utf-8" 34 | 35 | # Configure sensitive parameters which will be filtered from the log file. 36 | config.filter_parameters += [:password] 37 | 38 | # Enable the asset pipeline 39 | config.assets.enabled = true 40 | end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | gemfile = File.expand_path('../../../../Gemfile', __FILE__) 3 | 4 | if File.exist?(gemfile) 5 | ENV['BUNDLE_GEMFILE'] = gemfile 6 | require 'bundler' 7 | Bundler.setup 8 | end 9 | 10 | $:.unshift File.expand_path('../../../../lib', __FILE__) -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | development: 7 | adapter: mysql2 8 | host: localhost 9 | encoding: utf8 10 | database: alondra_development 11 | pool: 5 12 | timeout: 5000 13 | 14 | # Warning: The database defined as "test" will be erased and 15 | # re-generated from your development database when you run "rake". 16 | # Do not set this db to the same as development or production. 17 | test: 18 | adapter: mysql2 19 | host: localhost 20 | encoding: utf8 21 | database: alondra_test 22 | pool: 5 23 | timeout: 5000 24 | 25 | production: 26 | adapter: mysql2 27 | host: localhost 28 | encoding: utf8 29 | database: alondra_production 30 | pool: 5 31 | timeout: 5000 -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the rails application 5 | Dummy::Application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Log error messages when you accidentally call methods on nil. 10 | config.whiny_nils = true 11 | 12 | # Show full error reports and disable caching 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger 20 | config.active_support.deprecation = :log 21 | 22 | # Only use best-standards-support built into browsers 23 | config.action_dispatch.best_standards_support = :builtin 24 | 25 | # Do not compress assets 26 | config.assets.compress = false 27 | end 28 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # Code is not reloaded between requests 5 | config.cache_classes = true 6 | 7 | # Full error reports are disabled and caching is turned on 8 | config.consider_all_requests_local = false 9 | config.action_controller.perform_caching = true 10 | 11 | # Disable Rails's static asset server (Apache or nginx will already do this) 12 | config.serve_static_assets = false 13 | 14 | # Compress JavaScripts and CSS 15 | config.assets.compress = true 16 | 17 | # Specify the default JavaScript compressor 18 | config.assets.js_compressor = :uglifier 19 | 20 | # Specifies the header that your server uses for sending files 21 | # (comment out if your front-end server doesn't support this) 22 | config.action_dispatch.x_sendfile_header = "X-Sendfile" # Use 'X-Accel-Redirect' for nginx 23 | 24 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 25 | # config.force_ssl = true 26 | 27 | # See everything in the log (default is :info) 28 | # config.log_level = :debug 29 | 30 | # Use a different logger for distributed setups 31 | # config.logger = SyslogLogger.new 32 | 33 | # Use a different cache store in production 34 | # config.cache_store = :mem_cache_store 35 | 36 | # Enable serving of images, stylesheets, and JavaScripts from an asset server 37 | # config.action_controller.asset_host = "http://assets.example.com" 38 | 39 | # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added) 40 | # config.assets.precompile += %w( search.js ) 41 | 42 | # Disable delivery errors, bad email addresses will be ignored 43 | # config.action_mailer.raise_delivery_errors = false 44 | 45 | # Enable threaded mode 46 | # config.threadsafe! 47 | 48 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 49 | # the I18n.default_locale when a translation can not be found) 50 | config.i18n.fallbacks = true 51 | 52 | # Send deprecation notices to registered listeners 53 | config.active_support.deprecation = :notify 54 | end 55 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Configure static asset server for tests with Cache-Control for performance 11 | config.serve_static_assets = true 12 | config.static_cache_control = "public, max-age=3600" 13 | 14 | # Log error messages when you accidentally call methods on nil 15 | config.whiny_nils = true 16 | 17 | # Show full error reports and disable caching 18 | config.consider_all_requests_local = true 19 | config.action_controller.perform_caching = false 20 | 21 | # Raise exceptions instead of rendering exception templates 22 | config.action_dispatch.show_exceptions = false 23 | 24 | # Disable request forgery protection in test environment 25 | config.action_controller.allow_forgery_protection = false 26 | 27 | # Tell Action Mailer not to deliver emails to the real world. 28 | # The :test delivery method accumulates sent emails in the 29 | # ActionMailer::Base.deliveries array. 30 | config.action_mailer.delivery_method = :test 31 | 32 | # Use SQL instead of Active Record's schema dumper when creating the test database. 33 | # This is necessary if your schema can't be completely dumped by the schema dumper, 34 | # like if you have constraints or database-specific column types 35 | # config.active_record.schema_format = :sql 36 | 37 | # Print deprecation notices to the stderr 38 | config.active_support.deprecation = :stderr 39 | end 40 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format 4 | # (all these examples are active by default): 5 | # ActiveSupport::Inflector.inflections do |inflect| 6 | # inflect.plural /^(ox)$/i, '\1en' 7 | # inflect.singular /^(ox)en/i, '\1' 8 | # inflect.irregular 'person', 'people' 9 | # inflect.uncountable %w( fish sheep ) 10 | # end 11 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | # Mime::Type.register_alias "text/html", :iphone 6 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | # Make sure the secret is at least 30 characters and all random, 6 | # no regular words or you'll be exposed to dictionary attacks. 7 | Dummy::Application.config.secret_token = 'baf3fe98ca3a7b2b5d5cf116cc3b9c2264db831b2bd91d1b056d5b81757455c3d6efcbdb07039ca75821229c32fde58677ac37861071fc2aa6b61a59a8dcda28' 8 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Dummy::Application.config.session_store :cookie_store, key: '_dummy_session' 4 | Dummy::Application.config.session_store :cookie_store, httponly: false 5 | 6 | # Use the database for sessions instead of the cookie-based default, 7 | # which shouldn't be used to store highly confidential information 8 | # (create the session table with "rails generate session_migration") 9 | # Dummy::Application.config.session_store :active_record_store 10 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActionController::Base.wrap_parameters format: [:json] 8 | 9 | # Disable root element in JSON by default. 10 | if defined?(ActiveRecord) 11 | ActiveRecord::Base.include_root_in_json = false 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Sample localization file for English. Add more files in this directory for other locales. 2 | # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. 3 | 4 | en: 5 | hello: "Hello world" 6 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.routes.draw do 2 | match 'user/edit' => 'users#edit', :as => :edit_current_user 3 | 4 | match 'signup' => 'users#new', :as => :signup 5 | 6 | match 'logout' => 'sessions#destroy', :as => :logout 7 | 8 | match 'login' => 'sessions#new', :as => :login 9 | 10 | resources :sessions 11 | 12 | resources :users 13 | 14 | resources :chats do 15 | resources :messages 16 | end 17 | 18 | root :to => 'chats#index' 19 | end 20 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20110719090458_create_chats.rb: -------------------------------------------------------------------------------- 1 | class CreateChats < ActiveRecord::Migration 2 | def change 3 | create_table :chats do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20110719090538_create_messages.rb: -------------------------------------------------------------------------------- 1 | class CreateMessages < ActiveRecord::Migration 2 | def change 3 | create_table :messages do |t| 4 | t.integer :chat_id 5 | t.string :text 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20110720193249_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration 2 | def self.up 3 | create_table :users do |t| 4 | t.string :username 5 | t.string :email 6 | t.string :password_hash 7 | t.string :password_salt 8 | t.string :access_token 9 | t.timestamps 10 | end 11 | end 12 | 13 | def self.down 14 | drop_table :users 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended to check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(:version => 20110720193249) do 14 | 15 | create_table "chats", :force => true do |t| 16 | t.string "name" 17 | t.datetime "created_at" 18 | t.datetime "updated_at" 19 | end 20 | 21 | create_table "messages", :force => true do |t| 22 | t.integer "chat_id" 23 | t.string "text" 24 | t.datetime "created_at" 25 | t.datetime "updated_at" 26 | end 27 | 28 | create_table "users", :force => true do |t| 29 | t.string "username" 30 | t.string "email" 31 | t.string "password_hash" 32 | t.string "password_salt" 33 | t.string "access_token" 34 | t.datetime "created_at" 35 | t.datetime "updated_at" 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /test/dummy/lib/controller_authentication.rb: -------------------------------------------------------------------------------- 1 | # This module is included in your application controller which makes 2 | # several methods available to all controllers and views. Here's a 3 | # common example you might add to your application layout file. 4 | # 5 | # <% if logged_in? %> 6 | # Welcome <%= current_user.username %>. 7 | # <%= link_to "Edit profile", edit_current_user_path %> or 8 | # <%= link_to "Log out", logout_path %> 9 | # <% else %> 10 | # <%= link_to "Sign up", signup_path %> or 11 | # <%= link_to "log in", login_path %>. 12 | # <% end %> 13 | # 14 | # You can also restrict unregistered users from accessing a controller using 15 | # a before filter. For example. 16 | # 17 | # before_filter :login_required, :except => [:index, :show] 18 | module ControllerAuthentication 19 | def self.included(controller) 20 | controller.send :helper_method, :current_user, :logged_in?, :redirect_to_target_or_default 21 | end 22 | 23 | def current_user 24 | @current_user ||= User.find(session[:user_id]) if session[:user_id] 25 | end 26 | 27 | def logged_in? 28 | current_user 29 | end 30 | 31 | def login_required 32 | unless logged_in? 33 | store_target_location 34 | redirect_to login_url, :alert => "You must first log in or sign up before accessing this page." 35 | end 36 | end 37 | 38 | def redirect_to_target_or_default(default, *args) 39 | redirect_to(session[:return_to] || default, *args) 40 | session[:return_to] = nil 41 | end 42 | 43 | private 44 | 45 | def store_target_location 46 | session[:return_to] = request.url 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/dummy/log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afcapel/alondra/071ec2fc677846cdb1fc3a964211ecdbf6c55d69/test/dummy/log/.gitkeep -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 17 | 18 | 19 | 20 | 21 |
    22 |

    The page you were looking for doesn't exist.

    23 |

    You may have mistyped the address or the page may have moved.

    24 |
    25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 17 | 18 | 19 | 20 | 21 |
    22 |

    The change you wanted was rejected.

    23 |

    Maybe you tried to change something you didn't have access to.

    24 |
    25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 17 | 18 | 19 | 20 | 21 |
    22 |

    We're sorry, but something went wrong.

    23 |

    We've been notified about this issue and we'll take a look at it shortly.

    24 |
    25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afcapel/alondra/071ec2fc677846cdb1fc3a964211ecdbf6c55d69/test/dummy/public/favicon.ico -------------------------------------------------------------------------------- /test/dummy/public/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #4B7399; 3 | font-family: Verdana, Helvetica, Arial; 4 | font-size: 14px; 5 | } 6 | 7 | a img { 8 | border: none; 9 | } 10 | 11 | a { 12 | color: #0000FF; 13 | } 14 | 15 | .clear { 16 | clear: both; 17 | height: 0; 18 | overflow: hidden; 19 | } 20 | 21 | #container { 22 | width: 75%; 23 | margin: 0 auto; 24 | background-color: #FFF; 25 | padding: 20px 40px; 26 | border: solid 1px black; 27 | margin-top: 20px; 28 | } 29 | 30 | #flash_notice, #flash_error, #flash_alert { 31 | padding: 5px 8px; 32 | margin: 10px 0; 33 | } 34 | 35 | #flash_notice { 36 | background-color: #CFC; 37 | border: solid 1px #6C6; 38 | } 39 | 40 | #flash_error, #flash_alert { 41 | background-color: #FCC; 42 | border: solid 1px #C66; 43 | } 44 | 45 | .fieldWithErrors { 46 | display: inline; 47 | } 48 | 49 | .error_messages { 50 | width: 400px; 51 | border: 2px solid #CF0000; 52 | padding: 0px; 53 | padding-bottom: 12px; 54 | margin-bottom: 20px; 55 | background-color: #f0f0f0; 56 | font-size: 12px; 57 | } 58 | 59 | .error_messages h2 { 60 | text-align: left; 61 | font-weight: bold; 62 | padding: 5px 10px; 63 | font-size: 12px; 64 | margin: 0; 65 | background-color: #c00; 66 | color: #fff; 67 | } 68 | 69 | .error_messages p { 70 | margin: 8px 10px; 71 | } 72 | 73 | .error_messages ul { 74 | margin: 0; 75 | } 76 | -------------------------------------------------------------------------------- /test/dummy/script/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 3 | 4 | APP_PATH = File.expand_path('../../config/application', __FILE__) 5 | require File.expand_path('../../config/boot', __FILE__) 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /test/integration/push_changes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | class ChatPushingTest < ActiveSupport::IntegrationCase 5 | 6 | self.use_transactional_fixtures = false 7 | 8 | setup do 9 | clean_db 10 | Capybara.default_driver = :webkit 11 | end 12 | 13 | teardown do 14 | clean_db 15 | end 16 | 17 | test "push chat changes to client" do 18 | 19 | user = FactoryGirl.create :user 20 | chat = FactoryGirl.create :chat, :name => 'A chat about nothing' 21 | 22 | login_as user 23 | 24 | chat_path = chat_path(chat) 25 | 26 | visit chat_path 27 | 28 | wait_until(5) do 29 | page.has_content? 'A chat about nothing' 30 | end 31 | 32 | chat.update_attributes! :name => 'A chat about everything' 33 | 34 | sleep(0.1) 35 | 36 | wait_until(15) do 37 | page.has_content? 'A chat about everything' 38 | end 39 | end 40 | end 41 | end -------------------------------------------------------------------------------- /test/integration/push_messages_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | class PushMessagesTest < ActiveSupport::IntegrationCase 5 | self.use_transactional_fixtures = false 6 | 7 | setup do 8 | clean_db 9 | Capybara.default_driver = :webkit 10 | end 11 | 12 | teardown do 13 | clean_db 14 | end 15 | 16 | test "execute messages in client" do 17 | self.extend Pushing 18 | 19 | @user = FactoryGirl.create :user 20 | @text = 'hola!' 21 | 22 | chat = FactoryGirl.create :chat, :name => 'A chat to receive messages' 23 | 24 | login_as @user 25 | 26 | chat_path = chat_path(chat) 27 | visit chat_path(chat) 28 | 29 | wait_until 10 do 30 | page.has_content? 'Subscribed to channel' 31 | end 32 | 33 | push :partial => '/shared/message', :to => chat_path 34 | 35 | sleep(0.1) 36 | 37 | wait_until 20 do 38 | page.has_content? "#{@user.username} says hola!" 39 | end 40 | end 41 | end 42 | end -------------------------------------------------------------------------------- /test/models/channel_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class ChannelTest < ActiveSupport::TestCase 6 | 7 | def setup 8 | @connection = MockConnection.new 9 | end 10 | 11 | test "it has a name" do 12 | channel = Channel.new('test name channel') 13 | assert_equal 'test name channel', channel.name 14 | end 15 | 16 | test "can fetch channel by name" do 17 | channel = Channel['dummy channel'] 18 | assert_equal 'dummy channel', channel.name 19 | end 20 | 21 | test "allow clients to subscribe" do 22 | channel = Channel.new('test subscriptions channel') 23 | assert_equal 0, channel.connections.size 24 | 25 | channel.subscribe @connection 26 | 27 | assert_equal 1, channel.connections.size 28 | assert channel.connections.keys.include? @connection 29 | end 30 | 31 | test "deliver events to all subscribed connections" do 32 | channel = Channel.new('test deliver events channel') 33 | channel.subscribe @connection 34 | 35 | assert @connection.channels.include?(channel) 36 | 37 | event = Event.new :event => :created, :resource => Chat.new, :channel => 'test deliver events channel' 38 | 39 | channel.receive event 40 | 41 | assert EM.reactor_running? 42 | 43 | sleep(0.1) # Leave event machine to catch up 44 | 45 | last_message = @connection.messages.last 46 | assert_equal event.to_json, last_message 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /test/models/command_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class CommandTest < ActiveSupport::TestCase 6 | 7 | def setup 8 | @connection = MockConnection.new 9 | end 10 | 11 | test "it is created with a connection and a hash" do 12 | command = Command.new @connection, :command => 'subscribe', :channel => 'test' 13 | 14 | assert_equal :subscribe, command.name 15 | assert_equal 'test', command.channel.name 16 | end 17 | 18 | test "subscribe to channel when subscribe command is executed" do 19 | channel = Channel['test'] 20 | assert_equal 0, channel.connections.size 21 | 22 | command = Command.new @connection, :command => 'subscribe', :channel => 'test' 23 | command.execute! 24 | 25 | assert_equal 1, channel.connections.size 26 | assert channel.connections.keys.include? @connection 27 | end 28 | end 29 | end -------------------------------------------------------------------------------- /test/models/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class ConfigurationTest < ActiveSupport::TestCase 6 | 7 | test "it has default values" do 8 | assert_equal 12346, Alondra.config.port 9 | end 10 | 11 | test "it allows to override default values" do 12 | assert_equal '0.0.0.0', Alondra.config.host 13 | Alondra.config.host = 'www.example.com' 14 | assert_equal 'www.example.com', Alondra.config.host 15 | end 16 | 17 | test "it allows to define new variables" do 18 | Alondra.config.test_variable = 'something' 19 | assert_equal 'something', Alondra.config.test_variable 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /test/models/connection_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class ConnectionTest < ActiveSupport::TestCase 6 | 7 | test "it is assigned an UUI on creation" do 8 | assert MockConnection.new.uuid.present? 9 | end 10 | 11 | test "can find if there is a session" do 12 | session = {:user_id => 10} 13 | connection = MockConnection.new(session) 14 | 15 | assert_equal session, connection.session 16 | end 17 | end 18 | end -------------------------------------------------------------------------------- /test/models/event_listener_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class ChatListener < EventListener 6 | 7 | def self.created_chat_ids 8 | @created_chat_ids ||= [] 9 | end 10 | 11 | def self.subscribed_user_ids 12 | @subscribed_user_ids ||= [] 13 | end 14 | 15 | def self.subscribed_to_collection 16 | @subscribed_to_collection ||= [] 17 | end 18 | 19 | def self.subscribed_to_member 20 | @subscribed_to_member ||= [] 21 | end 22 | 23 | def self.custom_events 24 | @custom_events ||= [] 25 | end 26 | 27 | on :created do |event| 28 | ChatListener.created_chat_ids << event.resource.id 29 | end 30 | 31 | on :destroyed do |event| 32 | chat = event.resource 33 | ChatListener.created_chat_ids.delete(chat.id) 34 | end 35 | 36 | on :subscribed do |event| 37 | ChatListener.subscribed_user_ids << session[:user_id] 38 | end 39 | 40 | on :unsubscribed do |event| 41 | ChatListener.subscribed_user_ids.delete(session[:user_id]) 42 | end 43 | 44 | on :subscribed, :to => :collection do |event| 45 | ChatListener.subscribed_to_collection << session[:user_id] 46 | end 47 | 48 | on :unsubscribed, :to => :collection do |event| 49 | ChatListener.subscribed_to_collection.delete(session[:user_id]) 50 | end 51 | 52 | on :subscribed, :to => :member do |event| 53 | ChatListener.subscribed_to_member << session[:user_id] 54 | end 55 | 56 | on :unsubscribed, :to => :member do |event| 57 | ChatListener.subscribed_to_member.delete(session[:user_id]) 58 | end 59 | 60 | on :custom do |event| 61 | ChatListener.custom_events << event 62 | end 63 | 64 | on :boom do |event| 65 | event.boom! 66 | end 67 | 68 | end 69 | 70 | 71 | class EventListenerTest < ActiveSupport::TestCase 72 | 73 | test "can listen to a specific channel providing a string pattern" do 74 | class TextPatternListener < EventListener 75 | listen_to 'string pattern' 76 | end 77 | 78 | assert TextPatternListener.listen_to?('string pattern') 79 | assert TextPatternListener.listen_to?('string pattern and more') 80 | assert !TextPatternListener.listen_to?('other string pattern') 81 | end 82 | 83 | test "can listen to specific channel providing a regexp as pattern" do 84 | class RegexpPatternListener < EventListener 85 | listen_to /man$/ 86 | end 87 | 88 | assert RegexpPatternListener.listen_to?('Superman') 89 | assert !RegexpPatternListener.listen_to?('Lex Luthor') 90 | end 91 | 92 | test "it has a default channel pattern" do 93 | class DefaultPatternsListener < EventListener; end 94 | 95 | assert DefaultPatternsListener.listen_to?('/default/patterns/') 96 | assert DefaultPatternsListener.listen_to?('/default/patterns/1') 97 | 98 | assert !DefaultPatternsListener.listen_to?('/default/other/') 99 | assert !DefaultPatternsListener.listen_to?('/other/patterns/') 100 | end 101 | 102 | test "default channel pattern is ignored if explicit listen_to pattern is called" do 103 | class OverwrittenDefaultPatternsListener < EventListener 104 | listen_to '/others' 105 | end 106 | 107 | assert OverwrittenDefaultPatternsListener.listen_to?('/others') 108 | assert OverwrittenDefaultPatternsListener.listen_to?('/others/1/') 109 | assert !OverwrittenDefaultPatternsListener.listen_to?('/overwritten/default/patterns') 110 | end 111 | 112 | 113 | test 'receive created and destroyes events' do 114 | ChatListener.listen_to '/chats/' 115 | 116 | chat = Chat.create(:name => 'Observed chat') 117 | 118 | sleep(0.1) 119 | 120 | assert ChatListener.created_chat_ids.include?(chat.id) 121 | 122 | chat.destroy 123 | 124 | sleep(0.1) 125 | 126 | assert !ChatListener.created_chat_ids.include?(chat.id) 127 | end 128 | 129 | test 'react to subscribed and unsubscribed events' do 130 | session = {:user_id => 28 } 131 | connection = MockConnection.new(session) 132 | 133 | assert !ChatListener.subscribed_user_ids.include?(28) 134 | 135 | Command.new(connection, :command => 'subscribe', :channel => '/chats/').execute! 136 | 137 | sleep(0.1) 138 | 139 | assert ChatListener.subscribed_user_ids.include?(28) 140 | 141 | Command.new(connection, :command => 'unsubscribe', :channel => '/chats/').execute! 142 | 143 | sleep(0.1) 144 | 145 | assert !ChatListener.subscribed_user_ids.include?(28) 146 | end 147 | 148 | test 'react to subscribed and unsubscribed events on collection' do 149 | session = {:user_id => 29 } 150 | connection = MockConnection.new(session) 151 | 152 | assert !ChatListener.subscribed_to_collection.include?(29) 153 | assert !ChatListener.subscribed_to_member.include?(29) 154 | 155 | Command.new(connection, :command => 'subscribe', :channel => '/chats/').execute! 156 | 157 | sleep(0.1) 158 | 159 | assert ChatListener.subscribed_to_collection.include?(29) 160 | assert !ChatListener.subscribed_to_member.include?(29) 161 | 162 | Command.new(connection, :command => 'unsubscribe', :channel => '/chats/').execute! 163 | 164 | sleep(0.1) 165 | 166 | assert !ChatListener.subscribed_to_collection.include?(29) 167 | assert !ChatListener.subscribed_to_member.include?(29) 168 | end 169 | 170 | test 'react to subscribed and unsubscribed events on member' do 171 | session = {:user_id => 30 } 172 | connection = MockConnection.new(session) 173 | 174 | chat = FactoryGirl.create :chat 175 | 176 | chat_channel = "/chats/#{chat.id}" 177 | 178 | assert !ChatListener.subscribed_to_collection.include?(30) 179 | assert !ChatListener.subscribed_to_member.include?(30) 180 | 181 | Command.new(connection, :command => 'subscribe', :channel => chat_channel).execute! 182 | 183 | sleep(0.1) 184 | 185 | assert !ChatListener.subscribed_to_collection.include?(30) 186 | assert ChatListener.subscribed_to_member.include?(30) 187 | 188 | Command.new(connection, :command => 'unsubscribe', :channel => chat_channel).execute! 189 | 190 | sleep(0.1) 191 | 192 | assert !ChatListener.subscribed_to_collection.include?(30) 193 | assert !ChatListener.subscribed_to_member.include?(30) 194 | end 195 | 196 | test 'receive customs events' do 197 | event = Event.new :event => :custom, :resource => Chat.new, :channel => '/chats/' 198 | EventRouter.new.process(event) 199 | 200 | sleep(0.1) 201 | 202 | assert_equal ChatListener.custom_events.last, event 203 | end 204 | 205 | test 'capture exceptions launched in event listener' do 206 | boom = BogusEvent.new :event => :boom, :resource => Chat.new, :channel => '/chats/' 207 | EventRouter.new.process(boom) 208 | 209 | event = Event.new :event => :custom, :resource => Chat.new, :channel => '/chats/' 210 | EventRouter.new.process(event) 211 | 212 | sleep(0.1) 213 | 214 | assert_equal ChatListener.custom_events.last, event 215 | end 216 | end 217 | end -------------------------------------------------------------------------------- /test/models/event_router_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class EventRouterTest < ActiveSupport::TestCase 6 | end 7 | end -------------------------------------------------------------------------------- /test/models/message_queue_client_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class MessageQueueClientTest < ActiveSupport::TestCase 6 | 7 | test "a sync client uses a sync zeromq context" do 8 | context = SyncMessageQueueClient.new.send :context 9 | assert context.class == ZMQ::Context 10 | end 11 | 12 | test "an async client uses an async zeromq context" do 13 | context = nil 14 | 15 | assert EM.reactor_running? 16 | 17 | EM.schedule do 18 | context = MessageQueueClient.instance.send :context 19 | end 20 | 21 | sleep(0.1) 22 | 23 | assert context.class == EM::ZeroMQ::Context 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /test/models/message_queue_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class MessageQueueTest < ActiveSupport::TestCase 6 | setup do 7 | @original_event_router = MessageQueue.instance.send :event_router 8 | @router = MockEventRouter.new 9 | 10 | @chat = Chat.create(:name => 'Silly chat') 11 | 12 | MessageQueue.instance.instance_variable_set :@event_router, @router 13 | @event = Event.new :event => :custom, :resource => @chat, :channel => '/chats/' 14 | end 15 | 16 | teardown do 17 | MessageQueue.instance.instance_variable_set :@event_router, @original_event_router 18 | end 19 | 20 | test "a message pushed asynchronously to the queue is received by the event router" do 21 | assert MessageQueueClient.instance.class == AsyncMessageQueueClient 22 | 23 | MessageQueueClient.push @event 24 | 25 | sleep(0.1) 26 | 27 | assert received(@event) 28 | end 29 | 30 | test "a message pushed synchronously to the queue is received by the event router" do 31 | 32 | client = MessageQueueClient.sync_instance 33 | context = client.send :context 34 | assert context.class == ZMQ::Context 35 | 36 | client.send_message(@event) 37 | 38 | sleep(0.1) 39 | 40 | assert received(@event) 41 | end 42 | 43 | test "message queue still works when an exception is thrown while processing an event" do 44 | 3.times do 45 | bogus = BogusEvent.new :event => :custom, :resource => @chat, :channel => '/chats/' 46 | 47 | begin 48 | MessageQueueClient.push bogus 49 | rescue BogusException 50 | puts "rescued exception" 51 | end 52 | end 53 | 54 | MessageQueueClient.push @event 55 | 56 | sleep(0.1) 57 | 58 | assert received(@event) 59 | end 60 | 61 | def received(event) 62 | @router.received_events.find do |matching_event| 63 | matching_event.type == event.type && 64 | matching_event.resource_type == event.resource_type && 65 | matching_event.resource.id == event.resource.id && 66 | matching_event.channel_name == event.channel_name 67 | end 68 | end 69 | end 70 | end -------------------------------------------------------------------------------- /test/models/pushing_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class PushingTest < ActiveSupport::TestCase 6 | 7 | test "publish created events to the specified channel" do 8 | chat = FactoryGirl.create :chat 9 | connection = MockConnection.new 10 | message = chat.messages.build(:text => 'test message') 11 | 12 | channel_name = Channel.default_name_for(chat) 13 | assert channel_name =~ /chats\/\d+/ 14 | 15 | channel = Channel[channel_name] 16 | channel.subscribe connection 17 | 18 | sleep(0.1) 19 | 20 | message.save! 21 | 22 | sleep(0.1) 23 | 24 | assert connection.messages.last, "should publish a message" 25 | 26 | last_event = ActiveSupport::JSON.decode(connection.messages.last) 27 | resource = last_event['resource'] 28 | 29 | assert_equal 'created', last_event['event'] 30 | assert_equal 'Message', last_event['resource_type'] 31 | assert_equal message.id, resource['id'] 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /test/performance/message_queue_performance.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Alondra 4 | 5 | class MessageQueuePerformanceTest < ActiveSupport::TestCase 6 | 7 | NUM_MESSAGES = 1_000 8 | 9 | setup do 10 | @event = Event.new :event => :custom, :resource => Chat.new, :channel => '/chats/' 11 | 12 | # Initialize queue 13 | MessageQueueClient.push @event 14 | 15 | @original_event_router = MessageQueue.instance.send :event_router 16 | @router = MockEventRouter.new 17 | 18 | MessageQueue.instance.instance_variable_set :@event_router, @router 19 | end 20 | 21 | teardown do 22 | MessageQueue.instance.instance_variable_set :@event_router, @original_event_router 23 | end 24 | 25 | test "message queue performance" do 26 | puts "send #{NUM_MESSAGES} messages to queue" 27 | 28 | time = Benchmark.measure do 29 | NUM_MESSAGES.times do 30 | MessageQueueClient.async_instance.send_message @event 31 | end 32 | 33 | while @router.received_events.size < NUM_MESSAGES 34 | sleep(0.1) 35 | end 36 | end 37 | 38 | events_per_second = NUM_MESSAGES/time.total 39 | 40 | puts "aprox. received events per second #{events_per_second}" 41 | 42 | assert events_per_second >= 500 43 | end 44 | 45 | 46 | test "message queue performance with sync client" do 47 | puts "send #{NUM_MESSAGES} messages to queue" 48 | 49 | time = Benchmark.measure do 50 | NUM_MESSAGES.times do 51 | MessageQueueClient.sync_instance.send_message @event 52 | end 53 | 54 | while @router.received_events.size < NUM_MESSAGES 55 | sleep(0.1) 56 | end 57 | end 58 | 59 | events_per_second = NUM_MESSAGES/time.total 60 | 61 | puts "aprox. received events per second #{events_per_second}" 62 | 63 | assert events_per_second >= 500 64 | end 65 | end 66 | end -------------------------------------------------------------------------------- /test/support/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :chat do 3 | name 'Test chat' 4 | end 5 | 6 | factory :message do 7 | association :chat 8 | text 'Test message' 9 | end 10 | 11 | sequence :username do |i| 12 | "user#{i}" 13 | end 14 | 15 | factory :user do 16 | username { FactoryGirl.generate(:username) } 17 | email { |u| "#{u.username}@example.com" } 18 | password 'secret' 19 | password_confirmation 'secret' 20 | end 21 | end -------------------------------------------------------------------------------- /test/support/integration_helper.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | module IntegrationHelper 3 | def login_as(user) 4 | visit new_session_path 5 | fill_in "login", :with => user.username 6 | fill_in "password", :with => "secret" 7 | click_button "Log in" 8 | end 9 | 10 | def log_out 11 | click_link _('logout') 12 | end 13 | 14 | def clean_db 15 | [User, Chat, ::Message].each { |model| model.delete_all } 16 | end 17 | end 18 | end -------------------------------------------------------------------------------- /test/support/integration_test.rb: -------------------------------------------------------------------------------- 1 | # Define a bare test case to use with Capybara 2 | class ActiveSupport::IntegrationCase < ActiveSupport::TestCase 3 | include Capybara::DSL 4 | include Rails.application.routes.url_helpers 5 | include Alondra::IntegrationHelper 6 | end -------------------------------------------------------------------------------- /test/support/mocks/bogus_event.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | 3 | class BogusException < StandardError; end 4 | 5 | class BogusEvent < Event 6 | 7 | def to_json 8 | boom! 9 | end 10 | 11 | def boom! 12 | raise BogusException.new("Ha ha ha, I'm evil!") 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /test/support/mocks/mock_connection.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class MockConnection < Connection 3 | 4 | def initialize(session = {}) 5 | super UUIDTools::UUID.random_create, session 6 | end 7 | 8 | def send(message) 9 | messages << message 10 | end 11 | 12 | def receive(event) 13 | messages << event.to_json 14 | end 15 | 16 | def channels 17 | @channels ||= [] 18 | end 19 | 20 | def messages 21 | @messages ||= [] 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /test/support/mocks/mock_event_router.rb: -------------------------------------------------------------------------------- 1 | module Alondra 2 | class MockEventRouter 3 | 4 | def process(event) 5 | received_events << event 6 | end 7 | 8 | def received_events 9 | @received_events ||= [] 10 | end 11 | end 12 | end -------------------------------------------------------------------------------- /test/support/mocks/mock_listener.rb: -------------------------------------------------------------------------------- 1 | class MockListener 2 | def received_events 3 | @received_events ||= [] 4 | end 5 | 6 | def receive(event) 7 | received_events << event 8 | end 9 | end -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | ENV["ALONDRA_SERVER"] = 'true' 4 | 5 | require File.expand_path("../dummy/config/environment.rb", __FILE__) 6 | require "rails/test_help" 7 | require 'capybara/rails' 8 | 9 | Alondra::Alondra.start_server_in_new_thread! 10 | 11 | Rails.backtrace_cleaner.remove_silencers! 12 | 13 | # Load support files 14 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 15 | --------------------------------------------------------------------------------