bayeux-rack 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+