angstrom 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +5 -0
- data/README.md +109 -0
- data/Rakefile +22 -0
- data/angstrom.gemspec +16 -0
- data/bin/angstrom +108 -0
- data/bin/armstrong +108 -0
- data/demo/mongrel2.conf +32 -0
- data/lib/angstrom.rb +130 -0
- data/lib/angstrom/connection.rb +142 -0
- data/lib/angstrom/data_structures.rb +10 -0
- data/lib/angstrom/main_actors.rb +183 -0
- data/lib/angstrom/version.rb +3 -0
- metadata +137 -0
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
# Angstrom #
|
2
|
+
An evented, fiber-based server for Ruby. This project is heavily based on [Brubeck](http://brubeck.io). The goal was to make it really easy to make an evented server that acted quick and scaled infinitely. This is accomplished by using [Mongrel2](http://mongrel2.org), [ZeroMQ](http://zeromq.org) and [Rubinius](rubini.us). Rubinius has the actor gem included already so it makes it really convenient to just use rubinius. Also, the 2.0.0dev branch has super nice thread handling, allowing for true Ruby concurrency that MRI just can't offer with its GIL.
|
3
|
+
|
4
|
+
## Mongrel2 and ZeroMQ ##
|
5
|
+
Although it seems like a strange direction to start writing servers in, eventually most companies end up in the realm of evented servers. This is because it offers nearly infinite scalability for free.
|
6
|
+
|
7
|
+
This is possible because of Mongrel2 and ZeroMQ. Mongrel2 acts as your server and parses requests. It then sends out ZeroMQ messages to your handlers and proxies and then returns their responses. Because it uses ZeroMQ messages, Mongrel2 can send messages anywhere and to any language. Conversely, it sends messages in a round-robin style, so scalability is achieved by just starting up another instance of your server.
|
8
|
+
|
9
|
+
## setup ##
|
10
|
+
#### Rubinius ####
|
11
|
+
|
12
|
+
rvm install rbx
|
13
|
+
rvm use rbx
|
14
|
+
|
15
|
+
#### ZeroMQ ####
|
16
|
+
Go grab the zip from [zeromq/zeromq2-1](https://github.com/zeromq/zeromq2-1), unzip it, and in the directory run:
|
17
|
+
|
18
|
+
./autogen.sh; ./configure; make; sudo make install
|
19
|
+
|
20
|
+
#### Angstrom as a gem ####
|
21
|
+
|
22
|
+
gem install angstrom
|
23
|
+
|
24
|
+
#### ZMQ and other gems ####
|
25
|
+
gem install ffi-rzmq
|
26
|
+
gem install lazy
|
27
|
+
|
28
|
+
it should also install `ffi` and `ffi-rzmq` which are to dynamically load libs and call functions from them. Interesting stuff, but out of the scope of this measly README.
|
29
|
+
|
30
|
+
#### Mongrel2 ####
|
31
|
+
Finally, go grab a copy of mongrel2 (1.7.5 tested) from the [Mongrel2](http://mongrel2.org) website.
|
32
|
+
|
33
|
+
There's a sample `mongrel2.conf` and `config.sqlite` in the `demo` folder, feel free to use those. Otherwise, load the `mongrel2.conf` into `m2sh` and then start the server.
|
34
|
+
|
35
|
+
m2sh load -config mongrel2.conf -db config.sqlite
|
36
|
+
m2sh start -host localhost
|
37
|
+
|
38
|
+
## minimal example ##
|
39
|
+
|
40
|
+
require 'angstrom'
|
41
|
+
|
42
|
+
get "/" do
|
43
|
+
"hello world"
|
44
|
+
end
|
45
|
+
|
46
|
+
Just like in Sinatra, we state the verb we want to use, the path, and give it a block with the relevant code to execute. So far only 'GET' requests are supported but more will come out in later builds.
|
47
|
+
|
48
|
+
Now you should run `ruby angstrom_test.rb` and then visit [localhost:6767](http://localhost:6767/) and relish in the 'Hello World'.
|
49
|
+
|
50
|
+
## more functionality ##
|
51
|
+
|
52
|
+
commit e86c74aed added functionality for parameters in your path. These are simply demonstrated in the `demo/angstrom_test.rb` file. For instance, you can extract the id of a certain part of your path like so:
|
53
|
+
|
54
|
+
require 'angstrom'
|
55
|
+
|
56
|
+
get "/:id" do |env|
|
57
|
+
"id: #{env[:params]["id"]}"
|
58
|
+
end
|
59
|
+
|
60
|
+
The params are always going to be stored in `env`, naturally.
|
61
|
+
|
62
|
+
You can also return other codes and custom headers by returning an array with the signature:
|
63
|
+
[code, headers, response]
|
64
|
+
|
65
|
+
## benchmarking ##
|
66
|
+
|
67
|
+
#### Armstrong ####
|
68
|
+
$ siege -d 1 -c 150 -t 10s localhost:6767/
|
69
|
+
** SIEGE 2.70
|
70
|
+
** Preparing 150 concurrent users for battle.
|
71
|
+
The server is now under siege...
|
72
|
+
Lifting the server siege... done.
|
73
|
+
Transactions: 5029 hits
|
74
|
+
Availability: 100.00 %
|
75
|
+
Elapsed time: 9.06 secs
|
76
|
+
Data transferred: 0.05 MB
|
77
|
+
Response time: 0.26 secs
|
78
|
+
Transaction rate: 555.08 trans/sec
|
79
|
+
Throughput: 0.01 MB/sec
|
80
|
+
Concurrency: 146.56
|
81
|
+
Successful transactions: 5029
|
82
|
+
Failed transactions: 0
|
83
|
+
Longest transaction: 0.67
|
84
|
+
Shortest transaction: 0.02
|
85
|
+
|
86
|
+
#### Sinatra ####
|
87
|
+
|
88
|
+
_These benchmarks were done using Rubinius as the Ruby interpreter. You will get much better results for sinatra with MRI 1.9.2 but the concurrency will still plateau at about 110. I could not start up more than 110 concurrent users without sinatra closing all connections and blowing up._
|
89
|
+
|
90
|
+
$ siege -d1 -c 110 -t 10s localhost:4567/
|
91
|
+
** SIEGE 2.70
|
92
|
+
** Preparing 20 concurrent users for battle.
|
93
|
+
The server is now under siege...
|
94
|
+
Lifting the server siege... done.
|
95
|
+
Transactions: 1192 hits
|
96
|
+
Availability: 97.23 %
|
97
|
+
Elapsed time: 9.39 secs
|
98
|
+
Data transferred: 0.01 MB
|
99
|
+
Response time: 0.70 secs
|
100
|
+
Transaction rate: 126.94 trans/sec
|
101
|
+
Throughput: 0.00 MB/sec
|
102
|
+
Concurrency: 88.98
|
103
|
+
Successful transactions: 1192
|
104
|
+
Failed transactions: 34
|
105
|
+
Longest transaction: 1.39
|
106
|
+
Shortest transaction: 0.20
|
107
|
+
|
108
|
+
## License ##
|
109
|
+
GPLv3
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
desc "Push actors to github, switch to master, merge actors, push master to github"
|
2
|
+
namespace :super do
|
3
|
+
task :push do
|
4
|
+
`go master`
|
5
|
+
`git merge actors`
|
6
|
+
`git push github`
|
7
|
+
`go actors`
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
desc "Build gem"
|
12
|
+
task :gb do
|
13
|
+
`gem build angstrom.gemspec`
|
14
|
+
end
|
15
|
+
|
16
|
+
desc "Push gem"
|
17
|
+
task :gp do
|
18
|
+
Rake::Task[:gb].invoke
|
19
|
+
gem_file = `ls *.gem`.to_a.last.chomp
|
20
|
+
puts "pushing #{gem_file}"
|
21
|
+
puts `gem push #{gem_file}`
|
22
|
+
end
|
data/angstrom.gemspec
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
|
2
|
+
require 'angstrom/version'
|
3
|
+
|
4
|
+
Gem::Specification.new 'angstrom', Aleph::VERSION do |s|
|
5
|
+
s.description = "Angstrom is an Mongrel2 fronted, actor-based web development framework similar in style to sinatra. With natively-threaded interpreters (Rubinius2), Angstrom provides true concurrency and high stability, by design."
|
6
|
+
s.summary = "Highly concurrent, sinatra-like framework"
|
7
|
+
s.author = "Artem Titoulenko"
|
8
|
+
s.email = "artem.titoulenko@gmail.com"
|
9
|
+
s.homepage = "https://www.github.com/artemtitoulenko/angstrom"
|
10
|
+
s.files = `git ls-files`.split("\n") - %w[.gitignore .travis.yml response_benchmark.rb demo/config.sqlite]
|
11
|
+
s.executables = %w[ angstrom ]
|
12
|
+
|
13
|
+
s.add_dependency 'ffi', '~> 1.0', '>= 1.0.10'
|
14
|
+
s.add_dependency 'ffi-rzmq', '~> 0.9', '>= 0.9.0'
|
15
|
+
s.add_dependency 'lazy', '>= 0.9.6'
|
16
|
+
end
|
data/bin/angstrom
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
include FileUtils
|
5
|
+
|
6
|
+
# this is a cli tool to make getting running with angstrom easier.
|
7
|
+
def inform
|
8
|
+
puts """
|
9
|
+
Angstrom is an asynchronous ruby web framework that's fronted by mongrel2
|
10
|
+
and makes use of Actors in order to handle requests. It is preferred to use
|
11
|
+
rubinius2.0.0dev in order to take advantage of true concurrency in Ruby.
|
12
|
+
|
13
|
+
usage: angstrom <command>
|
14
|
+
|
15
|
+
commands:
|
16
|
+
create <name> [port] Creates an angstrom app with a sample
|
17
|
+
mongrel2.conf and config.sqlite running on
|
18
|
+
[port] or the default 6767.
|
19
|
+
|
20
|
+
start [host] [db.sqlite] Starts a mongrel2 server in this directory
|
21
|
+
and then runs the app called by the current
|
22
|
+
directorys name. This is equivalent to:
|
23
|
+
$m2sh start -host localhost -db config.sqlite >> /dev/null &
|
24
|
+
$ruby app.rb
|
25
|
+
|
26
|
+
stop [host] Kills the default running mongrel2
|
27
|
+
server or [host]. This is like:
|
28
|
+
$m2sh stop -host localhost
|
29
|
+
|
30
|
+
short commands:
|
31
|
+
c = create
|
32
|
+
s = start
|
33
|
+
t = stop
|
34
|
+
"""
|
35
|
+
end
|
36
|
+
|
37
|
+
mongrel2_conf = """
|
38
|
+
angstrom_handler = Handler(
|
39
|
+
send_spec='tcp://127.0.0.1:9999',
|
40
|
+
send_ident='34f9ceee-cd52-4b7f-b197-88bf2f0ec378',
|
41
|
+
recv_spec='tcp://127.0.0.1:9998',
|
42
|
+
recv_ident='')
|
43
|
+
|
44
|
+
media_dir = Dir(
|
45
|
+
base='media/',
|
46
|
+
index_file='index.html',
|
47
|
+
default_ctype='text/plain')
|
48
|
+
|
49
|
+
angstrom_host = Host(
|
50
|
+
name=\"localhost\",
|
51
|
+
routes={
|
52
|
+
'/media/': media_dir,
|
53
|
+
'/': angstrom_handler})
|
54
|
+
|
55
|
+
angstrom_serv = Server(
|
56
|
+
uuid=\"%s\",
|
57
|
+
access_log=\"/log/mongrel2.access.log\",
|
58
|
+
error_log=\"/log/mongrel2.error.log\",
|
59
|
+
chroot=\"./\",
|
60
|
+
default_host=\"localhost\",
|
61
|
+
name=\"angstrom test\",
|
62
|
+
pid_file=\"/run/mongrel2.pid\",
|
63
|
+
port=%i,
|
64
|
+
hosts = [angstrom_host]
|
65
|
+
)
|
66
|
+
|
67
|
+
settings = {\"zeromq.threads\": 2, \"limits.min_ping\": 15, \"limits.kill_limit\": 2}
|
68
|
+
|
69
|
+
servers = [angstrom_serv]
|
70
|
+
"""
|
71
|
+
|
72
|
+
inform if ARGV.empty?
|
73
|
+
|
74
|
+
case ARGV[0]
|
75
|
+
when 'create', 'c'
|
76
|
+
inform if ARGV[1].nil?
|
77
|
+
port = (ARGV[2].nil? ? 6767 : ARGV[2])
|
78
|
+
mkdir ARGV[1]
|
79
|
+
cd ARGV[1]
|
80
|
+
%w[log run tmp].map(&method(:mkdir))
|
81
|
+
File.open("mongrel2.conf", 'w') {|file| file.puts(mongrel2_conf % [`m2sh uuid`.chomp, port]) }
|
82
|
+
puts "loading in the mongrel2.conf config"
|
83
|
+
puts `m2sh load -config mongrel2.conf -db config.sqlite`
|
84
|
+
File.open("#{ARGV[1]}.rb", 'w') {|file| file.puts "require 'rubygems'\nrequire 'angstrom'\n\n"}
|
85
|
+
puts "Created app #{ARGV[1]} that will run on port #{port}"
|
86
|
+
|
87
|
+
when 'start', 's'
|
88
|
+
host = (ARGV[1].nil? ? 'localhost' : ARGV[1])
|
89
|
+
db = (ARGV[2].nil? ? 'config.sqlite' : ARGV[2])
|
90
|
+
|
91
|
+
output = `m2sh start -host #{host} -config #{db} > /dev/null &`
|
92
|
+
if output.match /Aborting/
|
93
|
+
puts "Error starting up mongrel2.\n\nTrace:\n#{output}"
|
94
|
+
break
|
95
|
+
end
|
96
|
+
puts "Started #{host} using the #{db} db"
|
97
|
+
|
98
|
+
file = Dir.pwd.split('/').last
|
99
|
+
puts "now running #{file}.rb"
|
100
|
+
puts `ruby #{file}.rb`
|
101
|
+
|
102
|
+
when 'stop', 't'
|
103
|
+
host = (ARGV[1].nil? ? 'localhost' : ARGV[1])
|
104
|
+
puts `m2sh stop -host #{host}`
|
105
|
+
puts "Stopped #{host}, I think"
|
106
|
+
else
|
107
|
+
inform
|
108
|
+
end
|
data/bin/armstrong
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
include FileUtils
|
5
|
+
|
6
|
+
# this is a cli tool to make getting running with armstrong easier.
|
7
|
+
def inform
|
8
|
+
puts """
|
9
|
+
Armstrong is an asynchronous ruby web framework that's fronted by mongrel2
|
10
|
+
and makes use of Actors in order to handle requests. It is preferred to use
|
11
|
+
rubinius2.0.0dev in order to take advantage of true concurrency in Ruby.
|
12
|
+
|
13
|
+
usage: armstrong <command>
|
14
|
+
|
15
|
+
commands:
|
16
|
+
create <name> [port] Creates an armstrong app with a sample
|
17
|
+
mongrel2.conf and config.sqlite running on
|
18
|
+
[port] or the default 6767.
|
19
|
+
|
20
|
+
start [host] [db.sqlite] Starts a mongrel2 server in this directory
|
21
|
+
and then runs the app called by the current
|
22
|
+
directorys name. This is equivalent to:
|
23
|
+
$m2sh start -host localhost -db config.sqlite >> /dev/null &
|
24
|
+
$ruby app.rb
|
25
|
+
|
26
|
+
stop [host] Kills the default running mongrel2
|
27
|
+
server or [host]. This is like:
|
28
|
+
$m2sh stop -host localhost
|
29
|
+
|
30
|
+
short commands:
|
31
|
+
c = create
|
32
|
+
s = start
|
33
|
+
t = stop
|
34
|
+
"""
|
35
|
+
end
|
36
|
+
|
37
|
+
mongrel2_conf = """
|
38
|
+
armstrong_handler = Handler(
|
39
|
+
send_spec='tcp://127.0.0.1:9999',
|
40
|
+
send_ident='34f9ceee-cd52-4b7f-b197-88bf2f0ec378',
|
41
|
+
recv_spec='tcp://127.0.0.1:9998',
|
42
|
+
recv_ident='')
|
43
|
+
|
44
|
+
media_dir = Dir(
|
45
|
+
base='media/',
|
46
|
+
index_file='index.html',
|
47
|
+
default_ctype='text/plain')
|
48
|
+
|
49
|
+
armstrong_host = Host(
|
50
|
+
name=\"localhost\",
|
51
|
+
routes={
|
52
|
+
'/media/': media_dir,
|
53
|
+
'/': armstrong_handler})
|
54
|
+
|
55
|
+
armstrong_serv = Server(
|
56
|
+
uuid=\"%s\",
|
57
|
+
access_log=\"/log/mongrel2.access.log\",
|
58
|
+
error_log=\"/log/mongrel2.error.log\",
|
59
|
+
chroot=\"./\",
|
60
|
+
default_host=\"localhost\",
|
61
|
+
name=\"armstrong test\",
|
62
|
+
pid_file=\"/run/mongrel2.pid\",
|
63
|
+
port=%i,
|
64
|
+
hosts = [armstrong_host]
|
65
|
+
)
|
66
|
+
|
67
|
+
settings = {\"zeromq.threads\": 2, \"limits.min_ping\": 15, \"limits.kill_limit\": 2}
|
68
|
+
|
69
|
+
servers = [armstrong_serv]
|
70
|
+
"""
|
71
|
+
|
72
|
+
inform if ARGV.empty?
|
73
|
+
|
74
|
+
case ARGV[0]
|
75
|
+
when 'create', 'c'
|
76
|
+
inform if ARGV[1].nil?
|
77
|
+
port = (ARGV[2].nil? ? 6767 : ARGV[2])
|
78
|
+
mkdir ARGV[1]
|
79
|
+
cd ARGV[1]
|
80
|
+
%w[log run tmp].map(&method(:mkdir))
|
81
|
+
File.open("mongrel2.conf", 'w') {|file| file.puts(mongrel2_conf % [`m2sh uuid`.chomp, port]) }
|
82
|
+
puts "loading in the mongrel2.conf config"
|
83
|
+
puts `m2sh load -config mongrel2.conf -db config.sqlite`
|
84
|
+
File.open("#{ARGV[1]}.rb", 'w') {|file| file.puts "require 'rubygems'\nrequire 'armstrong'\n\n"}
|
85
|
+
puts "Created app #{ARGV[1]} that will run on port #{port}"
|
86
|
+
|
87
|
+
when 'start', 's'
|
88
|
+
host = (ARGV[1].nil? ? 'localhost' : ARGV[1])
|
89
|
+
db = (ARGV[2].nil? ? 'config.sqlite' : ARGV[2])
|
90
|
+
|
91
|
+
output = `m2sh start -host #{host} -config #{db} > /dev/null &`
|
92
|
+
if output.match /Aborting/
|
93
|
+
puts "Error starting up mongrel2.\n\nTrace:\n#{output}"
|
94
|
+
break
|
95
|
+
end
|
96
|
+
puts "Started #{host} using the #{db} db"
|
97
|
+
|
98
|
+
file = Dir.pwd.split('/').last
|
99
|
+
puts "now running #{file}.rb"
|
100
|
+
puts `ruby #{file}.rb`
|
101
|
+
|
102
|
+
when 'stop', 't'
|
103
|
+
host = (ARGV[1].nil? ? 'localhost' : ARGV[1])
|
104
|
+
puts `m2sh stop -host #{host}`
|
105
|
+
puts "Stopped #{host}, I think"
|
106
|
+
else
|
107
|
+
inform
|
108
|
+
end
|
data/demo/mongrel2.conf
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
angstrom_handler = Handler(
|
2
|
+
send_spec='tcp://127.0.0.1:9999',
|
3
|
+
send_ident='34f9ceee-cd52-4b7f-b197-88bf2f0ec378',
|
4
|
+
recv_spec='tcp://127.0.0.1:9998',
|
5
|
+
recv_ident='')
|
6
|
+
|
7
|
+
media_dir = Dir(
|
8
|
+
base='media/',
|
9
|
+
index_file='index.html',
|
10
|
+
default_ctype='text/plain')
|
11
|
+
|
12
|
+
angstrom_host = Host(
|
13
|
+
name="localhost",
|
14
|
+
routes={
|
15
|
+
'/media/': media_dir,
|
16
|
+
'/': angstrom_handler})
|
17
|
+
|
18
|
+
angstrom_serv = Server(
|
19
|
+
uuid="f400bf85-4538-4f7a-8908-67e313d515c2",
|
20
|
+
access_log="/log/mongrel2.access.log",
|
21
|
+
error_log="/log/mongrel2.error.log",
|
22
|
+
chroot="./",
|
23
|
+
default_host="localhost",
|
24
|
+
name="angstrom test",
|
25
|
+
pid_file="/run/mongrel2.pid",
|
26
|
+
port=6767,
|
27
|
+
hosts = [angstrom_host]
|
28
|
+
)
|
29
|
+
|
30
|
+
settings = {"zeromq.threads": 2, "limits.min_ping": 15, "limits.kill_limit": 2}
|
31
|
+
|
32
|
+
servers = [angstrom_serv]
|
data/lib/angstrom.rb
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'actor'
|
2
|
+
require 'rubygems'
|
3
|
+
require 'lazy'
|
4
|
+
|
5
|
+
libdir = File.dirname(__FILE__)
|
6
|
+
$LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
|
7
|
+
|
8
|
+
require "angstrom/connection"
|
9
|
+
require 'angstrom/data_structures'
|
10
|
+
require 'angstrom/main_actors'
|
11
|
+
|
12
|
+
module Aleph
|
13
|
+
class Base
|
14
|
+
class << self
|
15
|
+
attr_accessor :conn, :routes
|
16
|
+
|
17
|
+
def get(path, &block) route "GET", path, &block end
|
18
|
+
def put(path, &block) route "PUT", path, &block end
|
19
|
+
def post(path, &block) route "POST", path, &block end
|
20
|
+
def head(path, &block) route "HEAD", path, &block end
|
21
|
+
def delete(path, &block) route "DELETE", path, &block end
|
22
|
+
def patch(path, &block) route "PATCH", path, &block end
|
23
|
+
|
24
|
+
def route(verb, path, &block)
|
25
|
+
@routes ||= {}
|
26
|
+
(@routes[verb] ||= []) << AddRoute.new(compile(path), block)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
def compile(path)
|
31
|
+
keys = []
|
32
|
+
if path.respond_to? :to_str
|
33
|
+
pattern = path.to_str.gsub(/[^\?\%\\\/\:\*\w]/) { |c| encoded(c) }
|
34
|
+
pattern.gsub!(/((:\w+)|\*)/) do |match|
|
35
|
+
if match == "*"
|
36
|
+
keys << 'splat'
|
37
|
+
"(.*?)"
|
38
|
+
else
|
39
|
+
keys << $2[1..-1]
|
40
|
+
"([^/?#]+)"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
[/^#{pattern}$/, keys]
|
44
|
+
elsif path.respond_to?(:keys) && path.respond_to?(:match)
|
45
|
+
[path, path.keys]
|
46
|
+
elsif path.respond_to?(:names) && path.respond_to?(:match)
|
47
|
+
[path, path.names]
|
48
|
+
elsif path.respond_to? :match
|
49
|
+
[path, keys]
|
50
|
+
else
|
51
|
+
raise TypeError, path
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def encoded(char)
|
56
|
+
enc = URI.encode(char)
|
57
|
+
enc = "(?:#{Regexp.escape enc}|#{URI.encode char, /./})" if enc == char
|
58
|
+
enc = "(?:#{enc}|#{encoded('+')})" if char == " "
|
59
|
+
enc
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class Angstrom < Base
|
65
|
+
|
66
|
+
# the kicker. It all gets launched from here.
|
67
|
+
# this function makes a new connection object to handle the communication,
|
68
|
+
# promises to start the replier, request handler, and their supervisor,
|
69
|
+
# gives the replier the connection information, tells the request_handler
|
70
|
+
# what routes it should be able to match, then checks that all of the services
|
71
|
+
# are running correctly, gives us a launch time, then jumps into our main loop
|
72
|
+
# that waits for an incoming message, parses it, and sends it off to be
|
73
|
+
# operated on by the request handler. Boom.
|
74
|
+
def self.run!
|
75
|
+
#ensure that all actors are launched. Yea.
|
76
|
+
done = Lazy::demand(Lazy::promise do |done|
|
77
|
+
Actor.spawn(&Aleph::Base.supervisor_proc)
|
78
|
+
done = true
|
79
|
+
end)
|
80
|
+
|
81
|
+
if done
|
82
|
+
done2 = Lazy::demand(Lazy::Promise.new do |done2|
|
83
|
+
Actor[:supervisor] << SpawnRequestHandlers.new(4)
|
84
|
+
Actor[:supervisor] << SpawnReceivers.new(1)
|
85
|
+
Actor[:supervisor] << AddRoutes.new(@routes)
|
86
|
+
done2 = true
|
87
|
+
end)
|
88
|
+
end
|
89
|
+
|
90
|
+
if Aleph::Base.supervisor && Aleph::Base.replier && done2
|
91
|
+
puts "","="*56,"Angstrom has launched on #{Time.now}","="*56, ""
|
92
|
+
end
|
93
|
+
|
94
|
+
# main loop
|
95
|
+
loop do
|
96
|
+
gets
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# thank you sinatra!
|
102
|
+
# Sinatra delegation mixin. Mixing this module into an object causes all
|
103
|
+
# methods to be delegated to the Aleph::Angstrom class. Used primarily
|
104
|
+
# at the top-level.
|
105
|
+
module Delegator
|
106
|
+
def self.delegate(*methods)
|
107
|
+
methods.each do |method_name|
|
108
|
+
define_method(method_name) do |*args, &block|
|
109
|
+
return super(*args, &block) if respond_to? method_name
|
110
|
+
Delegator.target.send(method_name, *args, &block)
|
111
|
+
end
|
112
|
+
private method_name
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
delegate :get, :post, :put, :patch, :delete, :head
|
117
|
+
|
118
|
+
class << self
|
119
|
+
attr_accessor :target
|
120
|
+
end
|
121
|
+
|
122
|
+
self.target = Angstrom
|
123
|
+
end
|
124
|
+
|
125
|
+
# Sinatras secret sauce.
|
126
|
+
at_exit { Angstrom.run! }
|
127
|
+
end
|
128
|
+
|
129
|
+
include Aleph::Delegator
|
130
|
+
|
@@ -0,0 +1,142 @@
|
|
1
|
+
require 'ffi'
|
2
|
+
require 'ffi-rzmq'
|
3
|
+
require 'json'
|
4
|
+
require 'cgi'
|
5
|
+
|
6
|
+
class Connection
|
7
|
+
attr_reader :app_id, :sub_addr, :pub_addr, :request_sock, :response_sock, :context
|
8
|
+
|
9
|
+
def initialize(app_id, zmq_sub_pub_addr=["tcp://127.0.0.1", 9999, "tcp://127.0.0.1", 9998])
|
10
|
+
@app_id = app_id
|
11
|
+
@sub_addr = zmq_sub_pub_addr[0..1].join(":")
|
12
|
+
@pub_addr = zmq_sub_pub_addr[2..3].join(":")
|
13
|
+
|
14
|
+
@request_sock = @response_sock = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def connect
|
18
|
+
@context = ZMQ::Context.new 1
|
19
|
+
@request_sock = @context.socket ZMQ::PULL
|
20
|
+
@request_sock.connect @sub_addr
|
21
|
+
|
22
|
+
@response_sock = @context.socket ZMQ::PUB
|
23
|
+
@response_sock.setsockopt ZMQ::IDENTITY, @app_id
|
24
|
+
@response_sock.connect @pub_addr
|
25
|
+
end
|
26
|
+
|
27
|
+
#raw recv, unparsed message
|
28
|
+
def recv
|
29
|
+
msg = ""
|
30
|
+
rc = @request_sock.recv_string(msg)
|
31
|
+
puts "errno [#{ZMQ::Util.errno}] with description [#{ZMQ::Util.error_string}]" unless ZMQ::Util.resultcode_ok?(rc)
|
32
|
+
msg
|
33
|
+
end
|
34
|
+
|
35
|
+
#parse the request, this is the best way to get stuff back, as a Hash
|
36
|
+
def receive
|
37
|
+
parse(recv)
|
38
|
+
end
|
39
|
+
|
40
|
+
# sends the message off, formatted for Mongrel2 to understand
|
41
|
+
def send(uuid, conn_id, msg)
|
42
|
+
header = "%s %d:%s" % [uuid, conn_id.join(' ').length, conn_id.join(' ')]
|
43
|
+
string = header + ', ' + msg
|
44
|
+
#puts "\t\treplying to #{conn_id} with: ", string
|
45
|
+
rc = @response_sock.send_string string, ZMQ::NOBLOCK
|
46
|
+
puts "errno [#{ZMQ::Util.errno}] with description [#{ZMQ::Util.error_string}]" unless ZMQ::Util.resultcode_ok?(rc)
|
47
|
+
end
|
48
|
+
|
49
|
+
# reply to an env with `message` string
|
50
|
+
def reply(env, message)
|
51
|
+
self.send(env[:sender], [env[:conn_id]], message)
|
52
|
+
end
|
53
|
+
|
54
|
+
# reply to a req with a valid http header
|
55
|
+
def reply_http(env, body, code=200, headers={"Content-type" => "text/html"})
|
56
|
+
self.reply(env, http_response(body, code, headers))
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
def http_response(body, code, headers)
|
61
|
+
headers['Content-Length'] = body.size
|
62
|
+
headers_s = headers.map{|k, v| "%s: %s" % [k,v]}.join("\r\n")
|
63
|
+
|
64
|
+
"HTTP/1.1 #{code} #{StatusMessage[code.to_i]}\r\n#{headers_s}\r\n\r\n#{body}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def parse_netstring(ns)
|
68
|
+
len, rest = ns.split(':', 2)
|
69
|
+
len = len.to_i
|
70
|
+
raise "Netstring did not end in ','" unless rest[len].chr == ','
|
71
|
+
[ rest[0...len], rest[(len+1)..-1] ]
|
72
|
+
end
|
73
|
+
|
74
|
+
def parse(msg)
|
75
|
+
if msg.nil? || msg.empty?
|
76
|
+
return nil
|
77
|
+
end
|
78
|
+
|
79
|
+
env = {}
|
80
|
+
env[:sender], env[:conn_id], env[:path], rest = msg.split(' ', 4)
|
81
|
+
env[:headers], head_rest = parse_netstring(rest)
|
82
|
+
env[:body], _ = parse_netstring(head_rest)
|
83
|
+
|
84
|
+
env[:headers] = JSON.parse(env[:headers])
|
85
|
+
if(env[:headers]["METHOD"] == "POST")
|
86
|
+
env[:post] = parse_params(env)
|
87
|
+
end
|
88
|
+
|
89
|
+
return env
|
90
|
+
end
|
91
|
+
|
92
|
+
def parse_params(env)
|
93
|
+
r = {}
|
94
|
+
env[:body].split('&').map{|x| x.scan(/(.*?)=(.*?)$/)}.each_slice(2) { |k| r[CGI::unescape(k[0].to_s)] = CGI::unescape(k[1]) }
|
95
|
+
return r
|
96
|
+
end
|
97
|
+
|
98
|
+
# From WEBrick: thanks dawg.
|
99
|
+
StatusMessage = {
|
100
|
+
100 => 'Continue',
|
101
|
+
101 => 'Switching Protocols',
|
102
|
+
200 => 'OK',
|
103
|
+
201 => 'Created',
|
104
|
+
202 => 'Accepted',
|
105
|
+
203 => 'Non-Authoritative Information',
|
106
|
+
204 => 'No Content',
|
107
|
+
205 => 'Reset Content',
|
108
|
+
206 => 'Partial Content',
|
109
|
+
300 => 'Multiple Choices',
|
110
|
+
301 => 'Moved Permanently',
|
111
|
+
302 => 'Found',
|
112
|
+
303 => 'See Other',
|
113
|
+
304 => 'Not Modified',
|
114
|
+
305 => 'Use Proxy',
|
115
|
+
307 => 'Temporary Redirect',
|
116
|
+
400 => 'Bad Request',
|
117
|
+
401 => 'Unauthorized',
|
118
|
+
402 => 'Payment Required',
|
119
|
+
403 => 'Forbidden',
|
120
|
+
404 => 'Not Found',
|
121
|
+
405 => 'Method Not Allowed',
|
122
|
+
406 => 'Not Acceptable',
|
123
|
+
407 => 'Proxy Authentication Required',
|
124
|
+
408 => 'Request Timeout',
|
125
|
+
409 => 'Conflict',
|
126
|
+
410 => 'Gone',
|
127
|
+
411 => 'Length Required',
|
128
|
+
412 => 'Precondition Failed',
|
129
|
+
413 => 'Request Entity Too Large',
|
130
|
+
414 => 'Request-URI Too Large',
|
131
|
+
415 => 'Unsupported Media Type',
|
132
|
+
416 => 'Request Range Not Satisfiable',
|
133
|
+
417 => 'Expectation Failed',
|
134
|
+
500 => 'Internal Server Error',
|
135
|
+
501 => 'Not Implemented',
|
136
|
+
502 => 'Bad Gateway',
|
137
|
+
503 => 'Service Unavailable',
|
138
|
+
504 => 'Gateway Timeout',
|
139
|
+
505 => 'HTTP Version Not Supported'
|
140
|
+
}
|
141
|
+
end
|
142
|
+
|
@@ -0,0 +1,10 @@
|
|
1
|
+
AddRoute = Struct.new :route, :method
|
2
|
+
AddRoutes = Struct.new :routes
|
3
|
+
Request = Struct.new :env
|
4
|
+
ConnectionInformation = Struct.new :connection
|
5
|
+
Reply = Struct.new :env, :code, :headers, :body
|
6
|
+
MessageAndProc = Struct.new :env, :proccess
|
7
|
+
|
8
|
+
SpawnRequestHandlers = Struct.new :num
|
9
|
+
SpawnReceivers = Struct.new :num
|
10
|
+
Num = Struct.new :index
|
@@ -0,0 +1,183 @@
|
|
1
|
+
module Aleph
|
2
|
+
class Base
|
3
|
+
class << self
|
4
|
+
attr_accessor :replier, :supervisor
|
5
|
+
attr_accessor :message_receiver_proc, :request_handler_proc, :supervisor_proc, :container_proc
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
# take the route and pattern and keys and this function will match the keyworded params in
|
11
|
+
# the url with the pattern. Example:
|
12
|
+
#
|
13
|
+
# url: /user/2/view/345
|
14
|
+
# pattern: /user/:id/view/:comment
|
15
|
+
#
|
16
|
+
# returns:
|
17
|
+
#
|
18
|
+
# params = {id: 2, comment: 345}
|
19
|
+
#
|
20
|
+
def process_route(route, pattern, keys, values = [])
|
21
|
+
return unless match = pattern.match(route)
|
22
|
+
values += match.captures.map { |v| URI.decode(v) if v }
|
23
|
+
params = {}
|
24
|
+
|
25
|
+
if values.any?
|
26
|
+
keys.zip(values) { |k,v| (params[k] ||= '') << v if v }
|
27
|
+
end
|
28
|
+
params
|
29
|
+
end
|
30
|
+
|
31
|
+
# uuid generator. There's a pretty low chance of collision.
|
32
|
+
def new_uuid
|
33
|
+
values = [
|
34
|
+
rand(0x0010000),
|
35
|
+
rand(0x0010000),
|
36
|
+
rand(0x0010000),
|
37
|
+
rand(0x0010000),
|
38
|
+
rand(0x0010000),
|
39
|
+
rand(0x1000000),
|
40
|
+
rand(0x1000000),
|
41
|
+
]
|
42
|
+
"%04x%04x-%04x-%04x-%04x%06x%06x" % values
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
|
47
|
+
|
48
|
+
|
49
|
+
|
50
|
+
|
51
|
+
# this nifty mess helps us just to use the output of the Procs that handle
|
52
|
+
# the request instead of making the user manually catch messages and send
|
53
|
+
# them out to the replier.
|
54
|
+
Aleph::Base.container_proc = Proc.new do
|
55
|
+
data = Actor.receive
|
56
|
+
env, proccess = data.env, data.proccess
|
57
|
+
response = proccess.call(env)
|
58
|
+
puts "[container] using response: ", response
|
59
|
+
if response.is_a? Array
|
60
|
+
#just like Rack: env, code, headers, body. HINT HINT ( can't work because it's all async )
|
61
|
+
env[:conn].reply_http(env, response[1], response[2], response[0])
|
62
|
+
else
|
63
|
+
puts "[container] sending http_reply"
|
64
|
+
env[:conn].reply_http(env, response, 200, {"Content-Type", "text/html;charset=utf-8", "Connection", "keep-alive", "Server", "Angstrom", "X-Frame-Options", "sameorigin", "X-XSS_Protection", "1; mode=block"})
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
Aleph::Base.message_receiver_proc = Proc.new do
|
69
|
+
@name = "message_receiver"
|
70
|
+
puts "started (#{@name})"
|
71
|
+
|
72
|
+
uuid = new_uuid
|
73
|
+
conn = Connection.new uuid
|
74
|
+
conn.connect
|
75
|
+
puts "replying as mongrel2 service #{uuid}"
|
76
|
+
|
77
|
+
loop do
|
78
|
+
puts "waiting..."
|
79
|
+
env = conn.receive
|
80
|
+
env[:conn] = conn
|
81
|
+
puts "[message_receiver] got request, req = #{!req.nil?}"
|
82
|
+
Actor[:supervisor] << Request.new(env) if !req.nil?
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
|
87
|
+
|
88
|
+
Aleph::Base.request_handler_proc = Proc.new do
|
89
|
+
@name = "request_handler"
|
90
|
+
@num = 0
|
91
|
+
puts "started (#{@name})"
|
92
|
+
Actor.trap_exit = true
|
93
|
+
|
94
|
+
routes = {}
|
95
|
+
loop do
|
96
|
+
Actor.receive do |f|
|
97
|
+
f.when(Num) do |n|
|
98
|
+
@num = n.index
|
99
|
+
end
|
100
|
+
|
101
|
+
f.when(AddRoutes) do |r|
|
102
|
+
routes = r.routes
|
103
|
+
end
|
104
|
+
|
105
|
+
f.when(Request) do |r|
|
106
|
+
failure = true
|
107
|
+
verb = r.env[:headers]["METHOD"]
|
108
|
+
routes[verb].each do |route|
|
109
|
+
if route.route[0].match(r.env[:path])
|
110
|
+
puts "[request_handler:#{@num}] route matched! Making container..."
|
111
|
+
r.env[:params] = process_route(r.env[:path], route.route[0], route.route[1])
|
112
|
+
Actor.spawn(&Aleph::Base.container_proc) << MessageAndProc.new(r.env, route.method)
|
113
|
+
failure = false
|
114
|
+
break
|
115
|
+
end
|
116
|
+
end
|
117
|
+
env[:conn].reply_http(r.env, "<h1>404</h1>", 404, {'Content-type'=>'text/html'} ) if failure
|
118
|
+
end
|
119
|
+
|
120
|
+
f.when(Actor::DeadActorError) do |exit|
|
121
|
+
puts "[request_handler] #{exit.actor} died with reason: [#{exit.reason}]"
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
#if this dies, all hell will break loose
|
128
|
+
Aleph::Base.supervisor_proc = Proc.new do
|
129
|
+
Actor.register(:supervisor, Actor.current)
|
130
|
+
Aleph::Base.supervisor = Actor.current
|
131
|
+
Actor.trap_exit = true
|
132
|
+
puts "started (supervisor)"
|
133
|
+
|
134
|
+
handlers = []
|
135
|
+
receivers = []
|
136
|
+
handler_turn = 0
|
137
|
+
receiver_turn = 0
|
138
|
+
|
139
|
+
loop do
|
140
|
+
Actor.receive do |f|
|
141
|
+
|
142
|
+
f.when(AddRoutes) do |r|
|
143
|
+
puts "Adding routes"
|
144
|
+
handlers.each { |h| h << r }
|
145
|
+
end
|
146
|
+
|
147
|
+
f.when(SpawnRequestHandlers) do |r|
|
148
|
+
r.num.times do
|
149
|
+
puts "spawning a request_handler in handlers[#{handlers.size}]"
|
150
|
+
handlers << (Actor.spawn_link(&Aleph::Base.request_handler_proc) << Num.new(handlers.size))
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
f.when(Request) do |req|
|
155
|
+
puts "[supervisor] routing request"
|
156
|
+
handlers[handler_turn] << req
|
157
|
+
if(handler_turn == handlers.size)
|
158
|
+
handler_turn = 0
|
159
|
+
else
|
160
|
+
handler_turn += 1
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
f.when(SpawnReceivers) do |r|
|
165
|
+
r.num.times do
|
166
|
+
puts "spawning a receiver in receivers[#{receivers.size}]"
|
167
|
+
receivers << Actor.spawn_link(&Aleph::Base.message_receiver_proc)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
f.when(Actor::DeadActorError) do |exit|
|
172
|
+
"[supervisor] #{exit.actor} died with reason: [#{exit.reason}]"
|
173
|
+
# case exit.actor.name
|
174
|
+
# when "request_handler"
|
175
|
+
# # lets replace that failed request_handler with a new one. NO DOWN TIME
|
176
|
+
# handlers[exit.actor.num] = (Actor.spawn_link(&Aleph::Base.request_handler_proc) << Num.new(exit.actor.num))
|
177
|
+
# when "message_receiver"
|
178
|
+
# repliers[exit.actor.num] = (Actor.spawn_link(&Aleph::Base.message_receiver_proc) << Num.new(exit.actor.num))
|
179
|
+
# end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
metadata
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: angstrom
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 1694540110752149318
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 5
|
9
|
+
- 0
|
10
|
+
version: 0.5.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Artem Titoulenko
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-12-01 00:00:00 Z
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: ffi
|
22
|
+
prerelease: false
|
23
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
24
|
+
none: false
|
25
|
+
requirements:
|
26
|
+
- - ~>
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
hash: 4428665182548103036
|
29
|
+
segments:
|
30
|
+
- 1
|
31
|
+
- 0
|
32
|
+
version: "1.0"
|
33
|
+
- - ">="
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
hash: 4428537186080172355
|
36
|
+
segments:
|
37
|
+
- 1
|
38
|
+
- 0
|
39
|
+
- 10
|
40
|
+
version: 1.0.10
|
41
|
+
type: :runtime
|
42
|
+
version_requirements: *id001
|
43
|
+
- !ruby/object:Gem::Dependency
|
44
|
+
name: ffi-rzmq
|
45
|
+
prerelease: false
|
46
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
47
|
+
none: false
|
48
|
+
requirements:
|
49
|
+
- - ~>
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
hash: 2854635824043747355
|
52
|
+
segments:
|
53
|
+
- 0
|
54
|
+
- 9
|
55
|
+
version: "0.9"
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
hash: 1509009585894205680
|
59
|
+
segments:
|
60
|
+
- 0
|
61
|
+
- 9
|
62
|
+
- 0
|
63
|
+
version: 0.9.0
|
64
|
+
type: :runtime
|
65
|
+
version_requirements: *id002
|
66
|
+
- !ruby/object:Gem::Dependency
|
67
|
+
name: lazy
|
68
|
+
prerelease: false
|
69
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
70
|
+
none: false
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
hash: 2770302470405238287
|
75
|
+
segments:
|
76
|
+
- 0
|
77
|
+
- 9
|
78
|
+
- 6
|
79
|
+
version: 0.9.6
|
80
|
+
type: :runtime
|
81
|
+
version_requirements: *id003
|
82
|
+
description: Angstrom is an Mongrel2 fronted, actor-based web development framework similar in style to sinatra. With natively-threaded interpreters (Rubinius2), Angstrom provides true concurrency and high stability, by design.
|
83
|
+
email: artem.titoulenko@gmail.com
|
84
|
+
executables:
|
85
|
+
- angstrom
|
86
|
+
extensions: []
|
87
|
+
|
88
|
+
extra_rdoc_files: []
|
89
|
+
|
90
|
+
files:
|
91
|
+
- Gemfile
|
92
|
+
- README.md
|
93
|
+
- Rakefile
|
94
|
+
- angstrom.gemspec
|
95
|
+
- bin/angstrom
|
96
|
+
- bin/armstrong
|
97
|
+
- demo/mongrel2.conf
|
98
|
+
- lib/angstrom.rb
|
99
|
+
- lib/angstrom/connection.rb
|
100
|
+
- lib/angstrom/data_structures.rb
|
101
|
+
- lib/angstrom/main_actors.rb
|
102
|
+
- lib/angstrom/version.rb
|
103
|
+
homepage: https://www.github.com/artemtitoulenko/angstrom
|
104
|
+
licenses: []
|
105
|
+
|
106
|
+
post_install_message:
|
107
|
+
rdoc_options: []
|
108
|
+
|
109
|
+
require_paths:
|
110
|
+
- lib
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
112
|
+
none: false
|
113
|
+
requirements:
|
114
|
+
- - ">="
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
hash: 2002549777813010636
|
117
|
+
segments:
|
118
|
+
- 0
|
119
|
+
version: "0"
|
120
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
hash: 2002549777813010636
|
126
|
+
segments:
|
127
|
+
- 0
|
128
|
+
version: "0"
|
129
|
+
requirements: []
|
130
|
+
|
131
|
+
rubyforge_project:
|
132
|
+
rubygems_version: 1.8.11
|
133
|
+
signing_key:
|
134
|
+
specification_version: 3
|
135
|
+
summary: Highly concurrent, sinatra-like framework
|
136
|
+
test_files: []
|
137
|
+
|