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.
- data/README.rdoc +37 -0
- data/Rakefile +58 -0
- data/lib/bayeux.rb +343 -0
- metadata +132 -0
data/README.rdoc
ADDED
@@ -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.
|
data/Rakefile
ADDED
@@ -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
|
+
|
data/lib/bayeux.rb
ADDED
@@ -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
|
+
|