bayeux-rack 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. data/README.rdoc +37 -0
  2. data/Rakefile +58 -0
  3. data/lib/bayeux.rb +343 -0
  4. metadata +132 -0
@@ -0,0 +1,37 @@
1
+ = bayeux.rack
2
+
3
+ Bayeux (COMET or long-polling) protocol server as a Sinatra application.
4
+ Light weight and high scalability are achieved by using the
5
+ asynchronous Rack extensions added to Thin by async_sinatra.
6
+
7
+ Because it uses async_sinatra, which uses EventMachine, it won't work in Passenger.
8
+ Sorry about that, but Apache doesn't really like long-polling anyhow. Use Thin.
9
+
10
+ == Usage
11
+
12
+ See http://github.com/cjheath/jquery.comet
13
+ for an example of usage and for a COMET client in Javascript.
14
+
15
+ == Installing
16
+
17
+ gem install bayeux-rack
18
+
19
+ == License
20
+
21
+ The jquery.comet.js and chat_server are subject to the MIT license.
22
+
23
+ == Developing
24
+
25
+ Fork on github https://github.com/cjheath/bayeux-rack or just clone to play:
26
+
27
+ git clone git://github.com/cjheath/bayeux-rack.git
28
+
29
+ Patches welcome! Fork and send a pull request. Please follow coding conventions already in use.
30
+ Please use jslint if you can. There are currently no warnings, please keep it that way.
31
+
32
+ == Status
33
+
34
+ Current release has a happy path (working ok). Significant drawbacks to be fixed are:
35
+
36
+ * Server-side timeout of long-polls to avoid possible loss of sent messages
37
+ * Detecting multiple connections (tabs?) from the same browser, to fall back to callback polling.
@@ -0,0 +1,58 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ require 'jeweler'
5
+ require './lib/bayeux'
6
+
7
+ Jeweler::Tasks.new do |gem|
8
+ gem.name = "bayeux-rack"
9
+ gem.version = Bayeux::VERSION
10
+ gem.homepage = "http://github.com/cjheath/bayeux-rack"
11
+ gem.license = "MIT"
12
+ gem.summary = %Q{Bayeux (COMET or long-polling) protocol server as a Sinatra application}
13
+ gem.description = %Q{
14
+ Bayeux (COMET or long-polling) protocol server as a Sinatra application.
15
+ Light weight and high scalability are achieved by using the
16
+ asynchronous Rack extensions added to Thin by async_sinatra.}
17
+ gem.email = %w[clifford.heath@gmail.com]
18
+ gem.authors = ["Clifford Heath"]
19
+ gem.add_runtime_dependency 'json', '>= 1.5.1'
20
+ gem.add_runtime_dependency 'async_sinatra', '> 0.1'
21
+ gem.add_runtime_dependency 'eventmachine', '>= 0.12'
22
+ gem.add_runtime_dependency 'thin', '>= 1.2'
23
+ # gem.add_development_dependency 'rspec', '> 1.2.3'
24
+ end
25
+ Jeweler::RubygemsDotOrgTasks.new
26
+
27
+ require 'rake/testtask'
28
+ Rake::TestTask.new(:test) do |test|
29
+ test.libs << 'lib' << 'test'
30
+ test.pattern = 'test/**/test_*.rb'
31
+ test.verbose = true
32
+ end
33
+ task :default => :test
34
+
35
+ require 'rdoc/task'
36
+ Rake::RDocTask.new do |rdoc|
37
+ rdoc.rdoc_dir = 'rdoc'
38
+ rdoc.title = "bayeux-rack #{Bayeux::VERSION}"
39
+ rdoc.rdoc_files.include('README.rdoc')
40
+ # rdoc.rdoc_files.include('History.rdoc')
41
+ rdoc.rdoc_files.include('lib/**/*.rb')
42
+ end
43
+
44
+ desc 'Generate website files'
45
+ task :website_generate do
46
+ sh %q{ruby script/txt2html website/index.txt > website/index.html}
47
+ end
48
+
49
+ desc 'Upload website files via rsync'
50
+ task :website_upload do
51
+ local_dir = 'website'
52
+ website_config = YAML.load(File.read("config/website.yml"))
53
+ host = website_config["host"]
54
+ host = host ? "#{host}:" : ""
55
+ remote_dir = website_config["remote_dir"]
56
+ sh %{rsync -aCv #{local_dir}/ #{host}#{remote_dir}}
57
+ end
58
+
@@ -0,0 +1,343 @@
1
+ #
2
+ # A Bayeux (COMET) server using Async Sinatra.
3
+ # This requires a web server built on EventMachine, such as Thin.
4
+ #
5
+ # Copyright: Clifford Heath http://dataconstellation.com 2011
6
+ # License: MIT
7
+ #
8
+ require 'sinatra'
9
+ require 'sinatra/async'
10
+ require 'json'
11
+ require 'eventmachine'
12
+
13
+ # A Sinatra application that handles PUTs and POSTs on the /cometd URL,
14
+ # implementing the COMET protocol.
15
+ class Bayeux < Sinatra::Base
16
+ # The Gem version of this implementation
17
+ VERSION = "0.6.0"
18
+ register Sinatra::Async
19
+
20
+ # A connected client
21
+ class Client
22
+ # The clientId we assigned
23
+ attr_accessor :clientId
24
+
25
+ # Timestamp when we last had activity from this client
26
+ #attr_accessor :lastSeen
27
+
28
+ # The EM::Channel on which this client subscribes
29
+ attr_accessor :channel
30
+
31
+ # The EM::Subscription a long-poll is currently active
32
+ attr_accessor :subscription
33
+
34
+ # Messages queued for this client (an Array)
35
+ attr_accessor :queue
36
+
37
+ # Array of channels this client is subscribed to
38
+ attr_accessor :channels
39
+
40
+ def initialize clientId #:nodoc:
41
+ @clientId = clientId
42
+ @channel = EM::Channel.new
43
+ @queue = []
44
+ @channels = []
45
+ end
46
+
47
+ def flush sinatra #:nodoc:
48
+ queued = @queue
49
+ sinatra.trace "Sending to #{@clientId}: #{queued.inspect}"
50
+ @queue = []
51
+
52
+ sinatra.respond(queued)
53
+ end
54
+ end
55
+
56
+ enable :show_exceptions
57
+
58
+ # Perhaps some initialisation here in future?
59
+ #def initialize *a, &b
60
+ # super
61
+ #end
62
+
63
+ configure do
64
+ set :tracing, false # Enable to get Bayeux tracing
65
+ set :poll_interval, 5 # 5 seconds for polling
66
+ set :long_poll_interval, 30 # maximum duration for a long-poll
67
+ end
68
+
69
+ # Trace to stdout if the :tracing setting is enabled
70
+ def trace s
71
+ if settings.tracing
72
+ puts s
73
+ end
74
+ end
75
+
76
+ # A Hash of channels by channel name. Each channel is an Array of subscribed clients
77
+ def channels
78
+ # Sinatra dup's this object, so we have to use class variables
79
+ @@channels ||= Hash.new {|h, k| h[k] = [] }
80
+ end
81
+
82
+ # A Hash of all clients by clientId
83
+ def clients
84
+ @@clients ||= {}
85
+ end
86
+
87
+ # ClientIds should be strong random numbers containing at least 128 bits of entropy. These aren't!
88
+ def next_client_id
89
+ @@next_client_id ||= 0
90
+ (@@next_client_id += 1).to_s
91
+ end
92
+
93
+ # Send a message (a Hash) to a channel.
94
+ # The message must have the channel name under the key :channel or "channel"
95
+ def publish message
96
+ channel = message['channel'] || message[:channel]
97
+ clients = channels[channel]
98
+ trace "publishing to #{channel} with #{clients.size} subscribers: #{message.inspect}"
99
+ clients.each do | client|
100
+ client.queue << message
101
+ client.channel.push true # Wake up the subscribed client
102
+ end
103
+ end
104
+
105
+ # Handle a request from a client. Normally over-ridden in the subclass to add server behaviour.
106
+ def deliver(message)
107
+ id = message['id']
108
+ clientId = message['clientId']
109
+ channel_name = message['channel']
110
+
111
+ response =
112
+ case channel_name
113
+ when '/meta/handshake' # Client says hello, greet them
114
+ clientId = next_client_id
115
+ clients[clientId] = Client.new(clientId)
116
+ trace "Client #{clientId} offers a handshake from #{request.ip}"
117
+ handshake message
118
+
119
+ when '/meta/subscribe' # Client wants to subscribe to a channel:
120
+ subscribe message
121
+
122
+ when '/meta/unsubscribe' # Client wants to unsubscribe from a channel:
123
+ unsubscribe message
124
+
125
+ # This is the long-polling request.
126
+ when '/meta/connect'
127
+ connect message
128
+
129
+ when '/meta/disconnect'
130
+ disconnect message
131
+
132
+ # Other meta channels are disallowed
133
+ when %r{/meta/(.*)}
134
+ trace "Client #{clientId} tried to send a message to #{channel_name}"
135
+ { :successful => false }
136
+
137
+ # Service channels default to no-op. Service messages are never broadcast.
138
+ when %r{/service/(.*)}
139
+ trace "Client #{clientId} sent a private message to #{channel_name}"
140
+ { :successful => true }
141
+
142
+ else
143
+ puts "Unknown channel in request: "+message.inspect
144
+ pass # 404
145
+ end
146
+
147
+ # Set the standard parameters for all response messages
148
+ if response
149
+ response[:channel] = channel_name
150
+ response[:clientId] = clientId
151
+ response[:id] = id
152
+ [response]
153
+ else
154
+ []
155
+ end
156
+ end
157
+
158
+ # Send an asynchronous JSON or JSONP response to an async_sinatra GET or POST
159
+ def respond messages
160
+ if jsonp = params['jsonp']
161
+ trace "responding jsonp=#{messages.to_json}"
162
+ headers({'Content-Type' => 'text/javascript'})
163
+ body "#{jsonp}(#{messages.to_json});\n"
164
+ else
165
+ trace "responding #{messages.to_json}"
166
+ headers({'Content-Type' => 'application/json'})
167
+ body messages.to_json
168
+ end
169
+ end
170
+
171
+ protected
172
+
173
+ # Handle a handshake request from a client
174
+ def handshake message
175
+ publish :channel => '/cometd/meta', :data => {}, :action => "handshake", :reestablish => false, :successful => true
176
+ publish :channel => '/cometd/meta', :data => {}, :action => "connect", :successful => true
177
+ interval = params['jsonp'] ? settings.poll_interval : settings.long_poll_interval
178
+ trace "Setting interval to #{interval}"
179
+ {
180
+ :version => '1.0',
181
+ :supportedConnectionTypes => ['long-polling','callback-polling'],
182
+ :successful => true,
183
+ :advice => { :reconnect => 'retry', :interval => interval*1000 },
184
+ :minimumVersion => message['minimumVersion'],
185
+ }
186
+ end
187
+
188
+ # Handle a request by a client to subscribe to a channel
189
+ def subscribe message
190
+ clientId = message['clientId']
191
+ subscription = message['subscription']
192
+ if subscription =~ %r{^/meta/}
193
+ # No-one may subscribe to meta channels.
194
+ # The Bayeux protocol allows server-side clients to (e.g. monitoring apps) but we don't.
195
+ trace "Client #{clientId} may not subscribe to #{subscription}"
196
+ { :successful => false, :error => "500" }
197
+ else
198
+ subscribed_channel = subscription
199
+ trace "Client #{clientId} wants messages from #{subscribed_channel}"
200
+ client_array = channels[subscribed_channel]
201
+ client = clients[clientId]
202
+ if client and !client_array.include?(client)
203
+ client_array << client
204
+ client.channels << subscribed_channel
205
+ end
206
+ publish message
207
+ {
208
+ :successful => true,
209
+ :subscription => subscribed_channel
210
+ }
211
+ end
212
+ end
213
+
214
+ # Handle a request by a client to unsubscribe from a channel
215
+ def unsubscribe message
216
+ clientId = message['clientId']
217
+ subscribed_channel = message['subscription']
218
+ trace "Client #{clientId} no longer wants messages from #{subscribed_channel}"
219
+ client_array = channels[subscribed_channel]
220
+ client = clients[clientId]
221
+ client.channels.delete(subscribed_channel)
222
+ client_array.delete(client)
223
+ publish message
224
+ {
225
+ :successful => true,
226
+ :subscription => subscribed_channel
227
+ }
228
+ end
229
+
230
+ # Handle a long-poll request by a client
231
+ def connect message
232
+ @is_connect = true
233
+ clientId = message['clientId']
234
+ # trace "Client #{clientId} is long-polling"
235
+ client = clients[clientId]
236
+ pass unless client # Or "not authorised", or "handshake"?
237
+
238
+ connect_response = {
239
+ :channel => '/meta/connect', :clientId => clientId, :id => message['id'], :successful => true
240
+ }
241
+
242
+ queued = client.queue
243
+ if !queued.empty? || client.subscription
244
+ if client.subscription
245
+ # If the client opened a second long-poll, finish that one and this:
246
+ client.channel.push true # Complete the outstanding poll
247
+ end
248
+ client.queue << connect_response
249
+ client.flush(self)
250
+ return
251
+ end
252
+
253
+ client.subscription =
254
+ client.channel.subscribe do |msg|
255
+ queued = client.queue
256
+ trace "Client #{clientId} awoke but found an empty queue" if queued.empty?
257
+ client.queue << connect_response
258
+ client.flush(self)
259
+ end
260
+
261
+ if client.subscription
262
+ # trace "Client #{clientId} is waiting on #{client.subscription}"
263
+ on_close {
264
+ client.channel.unsubscribe(client.subscription)
265
+ client.subscription = nil
266
+ }
267
+ else
268
+ trace "Client #{clientId} failed to wait"
269
+ end
270
+ nil
271
+ end
272
+
273
+ # Handle a disconnect request from a client
274
+ def disconnect message
275
+ clientId = message['clientId']
276
+ if client = clients[clientId]
277
+ # Unsubscribe all subscribed channels:
278
+ while !client.channels.empty?
279
+ unsubscribe({'clientId' => clientId, 'channel' => '/meta/unsubscribe', 'subscription' => client.channels[0]})
280
+ end
281
+ client.queue += [{:channel => '/cometd/meta', :data => {}, :action => "connect", :successful => false}]
282
+ # Finish an outstanding poll:
283
+ client.channel.push true if client.subscription
284
+ clients.delete(clientId)
285
+ { :successful => true }
286
+ else
287
+ { :successful => false }
288
+ end
289
+ end
290
+
291
+ # Deliver a Bayeux message or array of messages
292
+ def deliver_all(message)
293
+ begin
294
+ if message.is_a?(Array)
295
+ response = []
296
+ message.map do |m|
297
+ response += [deliver(m)].flatten
298
+ end
299
+ response
300
+ else
301
+ Array(deliver(message))
302
+ end
303
+ rescue NameError # Usually an "Uncaught throw" from calling pass
304
+ raise
305
+ rescue => e
306
+ puts "#{e.class.name}: #{e.to_s}\n#{e.backtrace*"\n\t"}"
307
+ end
308
+ end
309
+
310
+ # Parse a message (or array of messages) from an HTTP request and deliver the messages
311
+ def receive message_json
312
+ message = JSON.parse(message_json)
313
+
314
+ # The message here should either be a connect message (long-poll) or messages being sent.
315
+ # For a long-poll we return a reponse immediately only if messages are queued for this client.
316
+ # For a send-message, we always return a response immediately, even if it's just an acknowledgement.
317
+ @is_connect = false
318
+ response = deliver_all(message)
319
+ return if @is_connect
320
+
321
+ if clientId = params['clientId'] and client = clients[clientId]
322
+ client.queue += response
323
+ client.flush if params['jsonp'] || !client.queue.empty?
324
+ else
325
+ # No client so no queue. Respond immediately if we can, else long-poll
326
+ respond(response) unless response.empty?
327
+ end
328
+ rescue => e
329
+ respond([])
330
+ end
331
+
332
+ # Normal JSON operation uses a POST
333
+ apost '/cometd' do
334
+ receive params['message']
335
+ end
336
+
337
+ # JSONP always uses a GET, since it fulfils a script tag.
338
+ # GETs can only send data which fit into a single URL.
339
+ aget '/cometd' do
340
+ receive params['message']
341
+ end
342
+
343
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bayeux-rack
3
+ version: !ruby/object:Gem::Version
4
+ hash: 7
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 6
9
+ - 0
10
+ version: 0.6.0
11
+ platform: ruby
12
+ authors:
13
+ - Clifford Heath
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-06-17 00:00:00 Z
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: json
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ hash: 1
29
+ segments:
30
+ - 1
31
+ - 5
32
+ - 1
33
+ version: 1.5.1
34
+ type: :runtime
35
+ version_requirements: *id001
36
+ - !ruby/object:Gem::Dependency
37
+ name: async_sinatra
38
+ prerelease: false
39
+ requirement: &id002 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ">"
43
+ - !ruby/object:Gem::Version
44
+ hash: 9
45
+ segments:
46
+ - 0
47
+ - 1
48
+ version: "0.1"
49
+ type: :runtime
50
+ version_requirements: *id002
51
+ - !ruby/object:Gem::Dependency
52
+ name: eventmachine
53
+ prerelease: false
54
+ requirement: &id003 !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ hash: 19
60
+ segments:
61
+ - 0
62
+ - 12
63
+ version: "0.12"
64
+ type: :runtime
65
+ version_requirements: *id003
66
+ - !ruby/object:Gem::Dependency
67
+ name: thin
68
+ prerelease: false
69
+ requirement: &id004 !ruby/object:Gem::Requirement
70
+ none: false
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ hash: 11
75
+ segments:
76
+ - 1
77
+ - 2
78
+ version: "1.2"
79
+ type: :runtime
80
+ version_requirements: *id004
81
+ description: |-
82
+
83
+ Bayeux (COMET or long-polling) protocol server as a Sinatra application.
84
+ Light weight and high scalability are achieved by using the
85
+ asynchronous Rack extensions added to Thin by async_sinatra.
86
+ email:
87
+ - clifford.heath@gmail.com
88
+ executables: []
89
+
90
+ extensions: []
91
+
92
+ extra_rdoc_files:
93
+ - README.rdoc
94
+ files:
95
+ - README.rdoc
96
+ - Rakefile
97
+ - lib/bayeux.rb
98
+ homepage: http://github.com/cjheath/bayeux-rack
99
+ licenses:
100
+ - MIT
101
+ post_install_message:
102
+ rdoc_options: []
103
+
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ none: false
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ hash: 3
112
+ segments:
113
+ - 0
114
+ version: "0"
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ none: false
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ hash: 3
121
+ segments:
122
+ - 0
123
+ version: "0"
124
+ requirements: []
125
+
126
+ rubyforge_project:
127
+ rubygems_version: 1.8.5
128
+ signing_key:
129
+ specification_version: 3
130
+ summary: Bayeux (COMET or long-polling) protocol server as a Sinatra application
131
+ test_files: []
132
+