biryani 0.0.14 → 0.0.15
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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +3 -0
- data/example/README.md +30 -0
- data/example/echo.rb +1 -1
- data/example/hello_world.rb +1 -1
- data/example/kv_store.rb +112 -0
- data/example/nginx.conf +11 -0
- data/example/raise_error.rb +1 -1
- data/example/sqlite3.rb +170 -0
- data/example/tiles/index.html +47 -0
- data/example/tiles.rb +85 -0
- data/lib/biryani/connection.rb +18 -11
- data/lib/biryani/http/error.rb +3 -1
- data/lib/biryani/http/request.rb +48 -20
- data/lib/biryani/http/response.rb +5 -5
- data/lib/biryani/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68d7513a831a0ea2e4b4bf55400d1e1d464a5c16aa7577ba2c6c7937d0a62019
|
|
4
|
+
data.tar.gz: dad97d0529ef59f2e233da2a7042b6eeacbc95f77328ac0172ad45fcc24fd9db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb4574ee23c377e4919f64f8c84d8798e3f52224788555456cac185e923694bc9dec8647a2e495727652aa398ca44ce1bb5dc4ef57b850f066c1621f0714dcde
|
|
7
|
+
data.tar.gz: efed6f174ab3a74b6fb33d110ed53cb6cd56fef824a928723800b78c7629b620ef6ee647138cdd22b3e8094235eb49b484e7a2a6c88c1e4c9d3fedac354768dc
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.0.
|
|
1
|
+
4.0.7
|
data/Gemfile
CHANGED
data/example/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
## Usage
|
|
2
|
+
|
|
3
|
+
The examples run as follows:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
$ cd /path/to/biryani/example/
|
|
7
|
+
|
|
8
|
+
$ bundle exec ruby hello_world.rb
|
|
9
|
+
|
|
10
|
+
$ curl --http2-prior-knowledge http://localhost:8888
|
|
11
|
+
Hello, world!
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Web browsers do NOT support h2c. If you access the examples from a browser, you could use [nginx](https://nginx.org/en/docs/stream/ngx_stream_core_module.html) as a TLS termination proxy.
|
|
15
|
+
|
|
16
|
+
To terminate TLS, `nginx` requires PEM files of certificate and private key. For example, you could generate a locally-trusted certificate, `server.crt` and `server.key`, using [mkcert](https://github.com/FiloSottile/mkcert).
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
$ mkcert -install
|
|
20
|
+
|
|
21
|
+
$ mkcert -cert-file server.crt -key-file server.key localhost 127.0.0.1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run `nginx` with [nginx.conf](nginx.conf) in this directory.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
$ nginx -p "$PWD" -c nginx.conf -g 'daemon off; pid /dev/null; error_log stderr;'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then open https://localhost:4433 in your browser.
|
data/example/echo.rb
CHANGED
data/example/hello_world.rb
CHANGED
data/example/kv_store.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH << "#{__dir__}/../lib"
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'ractor/keylockhash'
|
|
8
|
+
require 'ractor/lockvar'
|
|
9
|
+
require 'biryani'
|
|
10
|
+
|
|
11
|
+
# `Ractor::LockVar` and `Ractor::KeyLockHash` (ko1/ractor-sharing) are shareable, so every stream Ractor uses NEXT_ID and STORE directly.
|
|
12
|
+
#
|
|
13
|
+
# +-- stream Ractor (POST /) --------------+ +-- NEXT_ID ---------------------+
|
|
14
|
+
# | NEXT_ID.increment --------------------+-------->| Ractor::LockVar |
|
|
15
|
+
# | STORE[id] = value --------------------+----+ +--------------------------------+
|
|
16
|
+
# +----------------------------------------+ |
|
|
17
|
+
# | +-- STORE -----------------------+
|
|
18
|
+
# +--->| Ractor::KeyLockHash |
|
|
19
|
+
# | | 1 => "hoge" |
|
|
20
|
+
# +-- stream Ractor (PUT /:id) ------------+ | | 2 => "piyo" |
|
|
21
|
+
# | STORE.update(id) { ... } -------------+----+ | |
|
|
22
|
+
# +----------------------------------------+ +--------------------------------+
|
|
23
|
+
NEXT_ID = Ractor::LockVar.new(0) # plays the role of AUTOINCREMENT in SQL
|
|
24
|
+
STORE = Ractor::KeyLockHash.new
|
|
25
|
+
|
|
26
|
+
module App
|
|
27
|
+
# @param req [Biryani::HTTP::Request]
|
|
28
|
+
# @param res [Biryani::HTTP::Response]
|
|
29
|
+
def self.call(req, res)
|
|
30
|
+
case req.method
|
|
31
|
+
when 'POST'
|
|
32
|
+
do_post(req.content, res)
|
|
33
|
+
when 'GET'
|
|
34
|
+
req.uri.path == '/' ? do_list(res) : do_get(item_id(req), res)
|
|
35
|
+
when 'PUT'
|
|
36
|
+
do_put(item_id(req), req.content, res)
|
|
37
|
+
when 'DELETE'
|
|
38
|
+
do_delete(item_id(req), res)
|
|
39
|
+
else
|
|
40
|
+
respond(res, 405, { error: 'HTTP Method not allowed' })
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.do_post(value, res)
|
|
45
|
+
id = NEXT_ID.increment
|
|
46
|
+
STORE[id] = value
|
|
47
|
+
respond(res, 201, { status: 'created', id: id })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.do_list(res)
|
|
51
|
+
items = STORE.to_h.compact.sort.map { |id, value| { id: id, value: value } }
|
|
52
|
+
respond(res, 200, items)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.do_get(id, res)
|
|
56
|
+
value = STORE[id]
|
|
57
|
+
return respond(res, 404, { error: 'not found' }) if value.nil?
|
|
58
|
+
|
|
59
|
+
respond(res, 200, { id: id, value: value })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.do_put(id, value, res)
|
|
63
|
+
updated = false
|
|
64
|
+
STORE.update(id) do |old|
|
|
65
|
+
next if old.nil?
|
|
66
|
+
|
|
67
|
+
updated = true
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
return respond(res, 404, { error: 'not found' }) unless updated
|
|
71
|
+
|
|
72
|
+
respond(res, 200, { status: 'ok' })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.do_delete(id, res)
|
|
76
|
+
return respond(res, 404, { error: 'not found' }) if STORE.delete(id).nil?
|
|
77
|
+
|
|
78
|
+
respond(res, 200, { status: 'ok' })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param req [Biryani::HTTP::Request]
|
|
82
|
+
#
|
|
83
|
+
# @return [Integer]
|
|
84
|
+
def self.item_id(req)
|
|
85
|
+
req.uri.path.split('/').reject(&:empty?).first.to_i
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.respond(res, status, body)
|
|
89
|
+
res.status = status
|
|
90
|
+
res.fields['content-type'] = 'application/json'
|
|
91
|
+
res.content = JSON.generate(body)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
port = ARGV[0] || 8888
|
|
96
|
+
socket = TCPServer.new(port)
|
|
97
|
+
|
|
98
|
+
server = Biryani::Server.new(
|
|
99
|
+
# @param req [Biryani::HTTP::Request]
|
|
100
|
+
# @param res [Biryani::HTTP::Response]
|
|
101
|
+
Ractor.shareable_proc do |req, res|
|
|
102
|
+
App.call(req, res)
|
|
103
|
+
end
|
|
104
|
+
)
|
|
105
|
+
server.run(socket)
|
|
106
|
+
|
|
107
|
+
# $ bundle exec ruby kv_store.rb
|
|
108
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -d 'hoge'
|
|
109
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/
|
|
110
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -d 'piyo'
|
|
111
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/1
|
|
112
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X DELETE
|
data/example/nginx.conf
ADDED
data/example/raise_error.rb
CHANGED
data/example/sqlite3.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH << "#{__dir__}/../lib"
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'sqlite3'
|
|
8
|
+
require 'biryani'
|
|
9
|
+
|
|
10
|
+
# `sqlite3` is not Ractor-safe, so the main Ractor owns the database, and handlers send queries to it through DB_PORT.
|
|
11
|
+
#
|
|
12
|
+
# +-- main Ractor ---------------------+ +-- stream Ractor (1 Ractor / stream) ------------+
|
|
13
|
+
# | | | |
|
|
14
|
+
# | +-- DB -----------------------+ | | +-- handler -------------------------------+ |
|
|
15
|
+
# | | SQLite3::Database | | | | App.call(req, res) | |
|
|
16
|
+
# | +-----------------------------+ | | | DB.execute(sql, *binds) | |
|
|
17
|
+
# | ^ | | | | | |
|
|
18
|
+
# | | sql | | | | sql | |
|
|
19
|
+
# | | | | | v | |
|
|
20
|
+
# | DB_PORT.receive <---------------+-----------+--+---- DB_PORT.send | |
|
|
21
|
+
# | reply.send ---------------------+-----------+--+---> reply.receive | |
|
|
22
|
+
# | | | +------------------------------------------+ |
|
|
23
|
+
# | Ractor.new(server, socket) | | |
|
|
24
|
+
# +----+-------------------------------+ +-------------------------------------------------+
|
|
25
|
+
# | ^
|
|
26
|
+
# v |
|
|
27
|
+
# +-- server Ractor ----------------------+ +-- connection Ractor (1 Ractor / connection) --+--+
|
|
28
|
+
# | server.run(socket) | | Stream.new(tx, stream_id, proc) | |
|
|
29
|
+
# | Ractor.new(socket.accept, @proc) --+------>| Ractor.new(tx, stream_id, proc) -----------+ |
|
|
30
|
+
# +---------------------------------------+ +--------------------------------------------------+
|
|
31
|
+
DB_PORT = Ractor::Port.new
|
|
32
|
+
|
|
33
|
+
module DB
|
|
34
|
+
class Error < StandardError; end
|
|
35
|
+
|
|
36
|
+
# @param sql [String]
|
|
37
|
+
# @param binds [Array<Object>]
|
|
38
|
+
#
|
|
39
|
+
# @raise [DB::Error] if the query fails on the main Ractor
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<Hash>] rows
|
|
42
|
+
# @return [Integer] changes
|
|
43
|
+
def self.execute(sql, *binds)
|
|
44
|
+
reply = Ractor::Port.new
|
|
45
|
+
DB_PORT.send([sql, binds, reply])
|
|
46
|
+
case reply.receive
|
|
47
|
+
in [:ok, rows, changes]
|
|
48
|
+
[rows, changes]
|
|
49
|
+
in [:error, klass, message]
|
|
50
|
+
raise Error, "#{klass}: #{message}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param path [String]
|
|
55
|
+
def self.serve(path)
|
|
56
|
+
db = SQLite3::Database.new(path)
|
|
57
|
+
db.results_as_hash = true
|
|
58
|
+
db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);')
|
|
59
|
+
|
|
60
|
+
loop do
|
|
61
|
+
sql, binds, reply = DB_PORT.receive
|
|
62
|
+
begin
|
|
63
|
+
rows = db.execute(sql, binds)
|
|
64
|
+
reply.send([:ok, rows, db.changes])
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
reply.send([:error, e.class.name, e.message])
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module App
|
|
73
|
+
# @param req [Biryani::HTTP::Request]
|
|
74
|
+
# @param res [Biryani::HTTP::Response]
|
|
75
|
+
def self.call(req, res)
|
|
76
|
+
case req.method
|
|
77
|
+
when 'POST'
|
|
78
|
+
do_post(req, res)
|
|
79
|
+
when 'GET'
|
|
80
|
+
do_get(req, res)
|
|
81
|
+
when 'PUT'
|
|
82
|
+
do_put(req, res)
|
|
83
|
+
when 'DELETE'
|
|
84
|
+
do_delete(req, res)
|
|
85
|
+
else
|
|
86
|
+
respond(res, 405, { error: 'HTTP Method not allowed' })
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.do_post(req, res)
|
|
91
|
+
name = parse_name(req.content)
|
|
92
|
+
return respond(res, 400, { error: 'not found `name`' }) if name.nil?
|
|
93
|
+
|
|
94
|
+
DB.execute('INSERT INTO users (name) VALUES (?);', name)
|
|
95
|
+
respond(res, 201, { status: 'created' })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.do_get(req, res)
|
|
99
|
+
if req.uri.path == '/'
|
|
100
|
+
users, = DB.execute('SELECT id, name FROM users ORDER BY id;')
|
|
101
|
+
return respond(res, 200, users)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
users, = DB.execute('SELECT id, name FROM users WHERE id = ?;', user_id(req))
|
|
105
|
+
return respond(res, 404, { error: 'not found' }) if users.empty?
|
|
106
|
+
|
|
107
|
+
respond(res, 200, users.first)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.do_put(req, res)
|
|
111
|
+
name = parse_name(req.content)
|
|
112
|
+
return respond(res, 400, { error: 'not found `name`' }) if name.nil?
|
|
113
|
+
|
|
114
|
+
_, changes = DB.execute('UPDATE users SET name = ? WHERE id = ?;', name, user_id(req))
|
|
115
|
+
return respond(res, 404, { error: 'not found' }) if changes.zero?
|
|
116
|
+
|
|
117
|
+
respond(res, 200, { status: 'ok' })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.do_delete(req, res)
|
|
121
|
+
_, changes = DB.execute('DELETE FROM users WHERE id = ?;', user_id(req))
|
|
122
|
+
return respond(res, 404, { error: 'not found' }) if changes.zero?
|
|
123
|
+
|
|
124
|
+
respond(res, 200, { status: 'ok' })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param content [String]
|
|
128
|
+
#
|
|
129
|
+
# @return [String, nil]
|
|
130
|
+
def self.parse_name(content)
|
|
131
|
+
user = JSON.parse(content)
|
|
132
|
+
user['name']&.to_s if user.is_a?(Hash)
|
|
133
|
+
rescue JSON::ParserError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param req [Biryani::HTTP::Request]
|
|
138
|
+
#
|
|
139
|
+
# @return [Integer]
|
|
140
|
+
def self.user_id(req)
|
|
141
|
+
req.uri.path.split('/').reject(&:empty?).first.to_i
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.respond(res, status, body)
|
|
145
|
+
res.status = status
|
|
146
|
+
res.fields['content-type'] = 'application/json'
|
|
147
|
+
res.content = JSON.generate(body)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
port = ARGV[0] || 8888
|
|
152
|
+
path = ARGV[1] || '/tmp/sqlite3.db'
|
|
153
|
+
socket = TCPServer.new(port)
|
|
154
|
+
|
|
155
|
+
server = Biryani::Server.new(
|
|
156
|
+
# @param req [Biryani::HTTP::Request]
|
|
157
|
+
# @param res [Biryani::HTTP::Response]
|
|
158
|
+
Ractor.shareable_proc do |req, res|
|
|
159
|
+
App.call(req, res)
|
|
160
|
+
end
|
|
161
|
+
)
|
|
162
|
+
Ractor.new(server, socket) { |s, sock| s.run(sock) }
|
|
163
|
+
DB.serve(path)
|
|
164
|
+
|
|
165
|
+
# $ bundle exec ruby sqlite3.rb
|
|
166
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -H "Content-Type: application/json" -d '{"name":"Alice","age":18}'
|
|
167
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/
|
|
168
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -H "Content-Type: application/json" -d '{"name":"Bob","age":20}'
|
|
169
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/
|
|
170
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X DELETE
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>svg tiles</title>
|
|
6
|
+
<style>
|
|
7
|
+
#tiles {
|
|
8
|
+
display: grid;
|
|
9
|
+
grid-template-columns: repeat(15, 32px);
|
|
10
|
+
}
|
|
11
|
+
#tiles img {
|
|
12
|
+
display: block;
|
|
13
|
+
width: 32px;
|
|
14
|
+
height: 32px;
|
|
15
|
+
}
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<p>
|
|
20
|
+
<a href="?latency=0">0 ms</a> | <a href="?latency=100">100 ms</a> |
|
|
21
|
+
<a href="?latency=500">500 ms</a>
|
|
22
|
+
</p>
|
|
23
|
+
<p id="result">loading...</p>
|
|
24
|
+
<div id="tiles"></div>
|
|
25
|
+
<script>
|
|
26
|
+
const GRID = 15;
|
|
27
|
+
const latency =
|
|
28
|
+
new URLSearchParams(location.search).get("latency") || "0";
|
|
29
|
+
const tiles = document.getElementById("tiles");
|
|
30
|
+
for (let y = 0; y < GRID; y++) {
|
|
31
|
+
for (let x = 0; x < GRID; x++) {
|
|
32
|
+
const img = document.createElement("img");
|
|
33
|
+
img.src = `/tile/${x}/${y}.svg?latency=${latency}`;
|
|
34
|
+
tiles.appendChild(img);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
window.addEventListener("load", () => {
|
|
39
|
+
const protocol =
|
|
40
|
+
performance.getEntriesByType("navigation")[0].nextHopProtocol;
|
|
41
|
+
const time = Math.round(performance.now());
|
|
42
|
+
document.getElementById("result").textContent =
|
|
43
|
+
`${protocol}, latency: ${latency} ms / tile, loaded in ${time} ms`;
|
|
44
|
+
});
|
|
45
|
+
</script>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
data/example/tiles.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH << "#{__dir__}/../lib"
|
|
4
|
+
|
|
5
|
+
require 'optparse'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
INDEX_HTML = File.read("#{__dir__}/tiles/index.html").freeze
|
|
9
|
+
TILE_SIZE = 32
|
|
10
|
+
|
|
11
|
+
module App
|
|
12
|
+
def self.call(path, query)
|
|
13
|
+
case path
|
|
14
|
+
when '/', '/index.html'
|
|
15
|
+
[200, 'text/html; charset=utf-8', INDEX_HTML]
|
|
16
|
+
when %r{^/tile/(\d+)/(\d+)\.svg$}
|
|
17
|
+
do_tile(Regexp.last_match(1).to_i, Regexp.last_match(2).to_i, latency(query))
|
|
18
|
+
else
|
|
19
|
+
[404, 'text/plain', 'not found']
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.do_tile(x, y, latency)
|
|
24
|
+
sleep(latency / 1000.0)
|
|
25
|
+
hue = (x + y) * 12 % 360
|
|
26
|
+
svg = <<~SVG
|
|
27
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="#{TILE_SIZE}" height="#{TILE_SIZE}">
|
|
28
|
+
<rect width="#{TILE_SIZE}" height="#{TILE_SIZE}" fill="hsl(#{hue}, 70%, 70%)"/>
|
|
29
|
+
</svg>
|
|
30
|
+
SVG
|
|
31
|
+
[200, 'image/svg+xml', svg]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.latency(query)
|
|
35
|
+
query = URI.decode_www_form(query || '').to_h
|
|
36
|
+
query['latency'].to_i.clamp(0, 500)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
http1 = false
|
|
41
|
+
opt = OptionParser.new
|
|
42
|
+
opt.banner = 'Usage: tiles.rb [--http2 | --http1.1] [port]'
|
|
43
|
+
opt.on('--http2', 'serve over HTTP/2 with biryani (default)') { http1 = false }
|
|
44
|
+
opt.on('--http1.1', 'serve over HTTP/1.1 with WEBrick') { http1 = true }
|
|
45
|
+
opt.parse!
|
|
46
|
+
port = ARGV[0] || 8888
|
|
47
|
+
|
|
48
|
+
if http1
|
|
49
|
+
require 'webrick'
|
|
50
|
+
|
|
51
|
+
server = WEBrick::HTTPServer.new(Port: port)
|
|
52
|
+
server.mount_proc('/') do |req, res|
|
|
53
|
+
status, content_type, content = App.call(req.path, req.query_string)
|
|
54
|
+
res.status = status
|
|
55
|
+
res['content-type'] = content_type
|
|
56
|
+
res['cache-control'] = 'no-store'
|
|
57
|
+
res.body = content
|
|
58
|
+
end
|
|
59
|
+
trap('INT') { server.shutdown }
|
|
60
|
+
server.start
|
|
61
|
+
else
|
|
62
|
+
require 'socket'
|
|
63
|
+
require 'biryani'
|
|
64
|
+
|
|
65
|
+
socket = TCPServer.new(port)
|
|
66
|
+
server = Biryani::Server.new(
|
|
67
|
+
# @param req [Biryani::HTTP::Request]
|
|
68
|
+
# @param res [Biryani::HTTP::Response]
|
|
69
|
+
Ractor.shareable_proc do |req, res|
|
|
70
|
+
status, content_type, content = App.call(req.uri.path, req.uri.query)
|
|
71
|
+
res.status = status
|
|
72
|
+
res.fields['content-type'] = content_type
|
|
73
|
+
res.content = content
|
|
74
|
+
res.fields['cache-control'] = 'no-store'
|
|
75
|
+
end
|
|
76
|
+
)
|
|
77
|
+
server.run(socket)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# $ bundle exec ruby tiles.rb --http1.1 8889
|
|
81
|
+
# $ open http://localhost:8889/?latency=500
|
|
82
|
+
#
|
|
83
|
+
# $ bundle exec ruby tiles.rb --http2
|
|
84
|
+
# $ nginx -p "$PWD" -c nginx.conf -g 'daemon off; pid /dev/null; error_log stderr;'
|
|
85
|
+
# $ open https://localhost:4433/?latency=500
|
data/lib/biryani/connection.rb
CHANGED
|
@@ -148,7 +148,8 @@ module Biryani
|
|
|
148
148
|
return [] if obj.nil?
|
|
149
149
|
|
|
150
150
|
settings_ack = obj
|
|
151
|
-
[
|
|
151
|
+
max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
152
|
+
[settings_ack] + @data_buffer.take!(@send_window, @streams_ctx, max_frame_size)
|
|
152
153
|
when FrameType::PING
|
|
153
154
|
obj = self.class.handle_ping(frame)
|
|
154
155
|
return [] if obj.nil?
|
|
@@ -185,7 +186,7 @@ module Biryani
|
|
|
185
186
|
return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
|
|
186
187
|
if [FrameType::SETTINGS, FrameType::PING, FrameType::GOAWAY].include?(typ)
|
|
187
188
|
|
|
188
|
-
max_streams = @
|
|
189
|
+
max_streams = @settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
|
|
189
190
|
send_initial_window_size = @peer_settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
|
|
190
191
|
recv_initial_window_size = @settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
|
|
191
192
|
obj = self.class.transition_stream_state_recv(frame, @streams_ctx, @data_buffer, stream_id, max_streams, send_initial_window_size, recv_initial_window_size)
|
|
@@ -365,8 +366,9 @@ module Biryani
|
|
|
365
366
|
# @param streams_ctx [StreamsContext]
|
|
366
367
|
# @param decoder [Decoder]
|
|
367
368
|
#
|
|
368
|
-
# @return [Array<WindowUpdate>, ConnectionError]
|
|
369
|
+
# @return [Array<WindowUpdate>, ConnectionError, StreamError]
|
|
369
370
|
# rubocop: disable Metrics/AbcSize
|
|
371
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
|
370
372
|
def self.handle_data(stream_id, data, recv_window, streams_ctx, decoder)
|
|
371
373
|
ctx = streams_ctx[stream_id]
|
|
372
374
|
return ConnectionError.new(ErrorCode::FLOW_CONTROL_ERROR, 'DATA Frame length exceeds flow-control window size') \
|
|
@@ -374,7 +376,8 @@ module Biryani
|
|
|
374
376
|
|
|
375
377
|
ctx.content << data
|
|
376
378
|
if ctx.half_closed_remote?
|
|
377
|
-
obj = http_request(ctx.fragment, ctx.content, decoder)
|
|
379
|
+
obj = http_request(stream_id, ctx.fragment, ctx.content, decoder)
|
|
380
|
+
ctx << nil if obj.is_a?(StreamError) # terminate the Stream Ractor waiting for a request
|
|
378
381
|
return obj if Biryani.err?(obj)
|
|
379
382
|
|
|
380
383
|
ctx << obj
|
|
@@ -386,16 +389,18 @@ module Biryani
|
|
|
386
389
|
window_updates
|
|
387
390
|
end
|
|
388
391
|
# rubocop: enable Metrics/AbcSize
|
|
392
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
|
389
393
|
|
|
390
394
|
# @param headers [Headers]
|
|
391
395
|
# @param ctx [StreamContext]
|
|
392
396
|
# @param decoder [Decoder]
|
|
393
397
|
#
|
|
394
|
-
# @return [nil, ConnectionError]
|
|
398
|
+
# @return [nil, ConnectionError, StreamError]
|
|
395
399
|
def self.handle_headers(headers, ctx, decoder)
|
|
396
400
|
ctx.fragment << headers.fragment
|
|
397
401
|
if ctx.half_closed_remote?
|
|
398
|
-
obj = http_request(ctx.fragment, ctx.content, decoder)
|
|
402
|
+
obj = http_request(headers.stream_id, ctx.fragment, ctx.content, decoder)
|
|
403
|
+
ctx << nil if obj.is_a?(StreamError) # terminate the Stream Ractor waiting for a request
|
|
399
404
|
return obj if Biryani.err?(obj)
|
|
400
405
|
|
|
401
406
|
ctx << obj
|
|
@@ -463,21 +468,23 @@ module Biryani
|
|
|
463
468
|
nil
|
|
464
469
|
end
|
|
465
470
|
|
|
471
|
+
# @param stream_id [Integer]
|
|
466
472
|
# @param fragment [String]
|
|
467
473
|
# @param content [String]
|
|
468
474
|
# @param decoder [Decoder]
|
|
469
475
|
#
|
|
470
|
-
# @return [HTTP::Request, ConnectionError]
|
|
471
|
-
def self.http_request(fragment, content, decoder)
|
|
476
|
+
# @return [HTTP::Request, ConnectionError, StreamError]
|
|
477
|
+
def self.http_request(stream_id, fragment, content, decoder)
|
|
472
478
|
obj = decoder.decode(fragment)
|
|
473
479
|
return obj if Biryani.err?(obj)
|
|
474
480
|
|
|
475
481
|
fields = obj
|
|
476
482
|
builder = HTTP::RequestBuilder.new
|
|
477
|
-
|
|
478
|
-
return err unless err.nil?
|
|
479
|
-
|
|
483
|
+
builder.fields(fields)
|
|
480
484
|
builder.build(content)
|
|
485
|
+
rescue HTTP::Error::MalformedRequestError => e
|
|
486
|
+
# https://datatracker.ietf.org/doc/html/rfc9113#section-8.1.1-3
|
|
487
|
+
StreamError.new(ErrorCode::PROTOCOL_ERROR, stream_id, e.message)
|
|
481
488
|
end
|
|
482
489
|
|
|
483
490
|
# @param res [HTTP::Response]
|
data/lib/biryani/http/error.rb
CHANGED
|
@@ -4,7 +4,9 @@ module Biryani
|
|
|
4
4
|
# Generic error, common for all classes under Biryani::HTTP::Error module.
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
-
class
|
|
7
|
+
class InvalidResponseError < Error; end
|
|
8
|
+
|
|
9
|
+
class MalformedRequestError < Error; end
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|
data/lib/biryani/http/request.rb
CHANGED
|
@@ -25,6 +25,8 @@ module Biryani
|
|
|
25
25
|
class RequestBuilder
|
|
26
26
|
PSEUDO_HEADER_FIELDS = [':authority', ':method', ':path', ':scheme'].freeze
|
|
27
27
|
Ractor.make_shareable(PSEUDO_HEADER_FIELDS)
|
|
28
|
+
REQUIRED_PSEUDO_HEADER_FIELDS = [':method', ':path', ':scheme'].freeze
|
|
29
|
+
Ractor.make_shareable(REQUIRED_PSEUDO_HEADER_FIELDS)
|
|
28
30
|
|
|
29
31
|
def initialize
|
|
30
32
|
@h = {}
|
|
@@ -33,23 +35,21 @@ module Biryani
|
|
|
33
35
|
# @param name [String]
|
|
34
36
|
# @param value [String]
|
|
35
37
|
#
|
|
36
|
-
# @
|
|
38
|
+
# @raise [Error::MalformedRequestError]
|
|
37
39
|
# rubocop: disable Metrics/AbcSize
|
|
38
40
|
# rubocop: disable Metrics/CyclomaticComplexity
|
|
39
41
|
# rubocop: disable Metrics/PerceivedComplexity
|
|
40
42
|
def field(name, value)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
raise Error::MalformedRequestError, 'field name has uppercase letter' if name.downcase != name
|
|
44
|
+
raise Error::MalformedRequestError, 'unknown pseudo-header field name' if name[0] == ':' && !PSEUDO_HEADER_FIELDS.include?(name)
|
|
45
|
+
raise Error::MalformedRequestError, 'appear pseudo-header fields after regular fields' if name[0] == ':' && @h.any? { |name_, _| name_[0] != ':' }
|
|
46
|
+
raise Error::MalformedRequestError, 'duplicated pseudo-header fields' if PSEUDO_HEADER_FIELDS.include?(name) && @h.key?(name)
|
|
47
|
+
raise Error::MalformedRequestError, "invalid `#{name}` field" if PSEUDO_HEADER_FIELDS.include?(name) && value.empty?
|
|
48
|
+
raise Error::MalformedRequestError, 'connection-specific field is forbidden' if name == 'connection'
|
|
49
|
+
raise Error::MalformedRequestError, '`TE` field has a value other than `trailers`' if name == 'te' && value != 'trailers'
|
|
48
50
|
|
|
49
51
|
@h[name] = [] unless @h.key?(name)
|
|
50
52
|
@h[name] << value
|
|
51
|
-
|
|
52
|
-
nil
|
|
53
53
|
end
|
|
54
54
|
# rubocop: enable Metrics/AbcSize
|
|
55
55
|
# rubocop: enable Metrics/CyclomaticComplexity
|
|
@@ -57,19 +57,18 @@ module Biryani
|
|
|
57
57
|
|
|
58
58
|
# @param arr [Array]
|
|
59
59
|
#
|
|
60
|
-
# @
|
|
60
|
+
# @raise [Error::MalformedRequestError]
|
|
61
61
|
def fields(arr)
|
|
62
62
|
arr.each do |name, value|
|
|
63
|
-
|
|
64
|
-
return err unless err.nil?
|
|
63
|
+
field(name, value)
|
|
65
64
|
end
|
|
66
|
-
|
|
67
|
-
nil
|
|
68
65
|
end
|
|
69
66
|
|
|
70
67
|
# @param s [String]
|
|
71
68
|
#
|
|
72
69
|
# @return [Request]
|
|
70
|
+
#
|
|
71
|
+
# @raise [Error::MalformedRequestError]
|
|
73
72
|
def build(s)
|
|
74
73
|
# `Ractor.send(req, move: true)` moves entries in HPACK::DynamicTable; therefore, a `dup` call is required.
|
|
75
74
|
h = @h.transform_values { |x| x.map(&:dup) }
|
|
@@ -79,19 +78,48 @@ module Biryani
|
|
|
79
78
|
# @param h [Hash<String, Array<String>>]
|
|
80
79
|
# @param s [String]
|
|
81
80
|
#
|
|
82
|
-
# @return [Request
|
|
81
|
+
# @return [Request]
|
|
82
|
+
#
|
|
83
|
+
# @raise [Error::MalformedRequestError]
|
|
83
84
|
def self.build(h, s)
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
# https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1-3
|
|
86
|
+
raise Error::MalformedRequestError, 'missing pseudo-header fields' unless REQUIRED_PSEUDO_HEADER_FIELDS.all? { |x| h.key?(x) }
|
|
87
|
+
raise Error::MalformedRequestError, 'invalid content-length' if h.key?('content-length') && !s.empty? && s.length != h['content-length'][0].to_i
|
|
86
88
|
|
|
89
|
+
authority = authority(h)
|
|
87
90
|
scheme = h[':scheme'][0]
|
|
88
|
-
domain = h[':authority'][0]
|
|
89
91
|
path = h[':path'][0]
|
|
90
|
-
uri = URI("#{scheme}://#{
|
|
92
|
+
uri = URI("#{scheme}://#{authority}#{path}")
|
|
91
93
|
method = h[':method'][0].upcase
|
|
92
94
|
h['cookie'] = [h['cookie'].join('; ')] if h.key?('cookie')
|
|
93
95
|
Request.new(method, uri, h.except(*PSEUDO_HEADER_FIELDS), s)
|
|
94
96
|
end
|
|
97
|
+
|
|
98
|
+
# @param h [Hash<String, Array<String>>]
|
|
99
|
+
#
|
|
100
|
+
# @return [String]
|
|
101
|
+
#
|
|
102
|
+
# @raise [Error::MalformedRequestError]
|
|
103
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
|
104
|
+
# rubocop: disable Metrics/PerceivedComplexity
|
|
105
|
+
def self.authority(h)
|
|
106
|
+
host = h['host']
|
|
107
|
+
# https://datatracker.ietf.org/doc/html/rfc9110#section-5.3-3
|
|
108
|
+
raise Error::MalformedRequestError, 'duplicated host fields' if !host.nil? && host.length > 1
|
|
109
|
+
|
|
110
|
+
authority = h[':authority']&.first
|
|
111
|
+
# https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1-2.3.3
|
|
112
|
+
raise Error::MalformedRequestError, 'mismatched :authority and host fields' if !authority.nil? && !host.nil? && authority.downcase != host[0].downcase
|
|
113
|
+
|
|
114
|
+
s = authority || host&.first
|
|
115
|
+
# https://datatracker.ietf.org/doc/html/rfc9110#section-4.2.1-4
|
|
116
|
+
# https://datatracker.ietf.org/doc/html/rfc9110#section-4.2.2-4
|
|
117
|
+
raise Error::MalformedRequestError, 'missing :authority and host fields' if s.nil? || s.empty?
|
|
118
|
+
|
|
119
|
+
s
|
|
120
|
+
end
|
|
121
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
|
122
|
+
# rubocop: enable Metrics/PerceivedComplexity
|
|
95
123
|
end
|
|
96
124
|
end
|
|
97
125
|
end
|
|
@@ -15,14 +15,14 @@ module Biryani
|
|
|
15
15
|
@content = content
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# @raise [
|
|
18
|
+
# @raise [InvalidResponseError]
|
|
19
19
|
# rubocop: disable Metrics/CyclomaticComplexity
|
|
20
20
|
def validate
|
|
21
21
|
# https://datatracker.ietf.org/doc/html/rfc9113#section-8.2.1
|
|
22
|
-
raise Error::
|
|
23
|
-
raise Error::
|
|
24
|
-
raise Error::
|
|
25
|
-
raise Error::
|
|
22
|
+
raise Error::InvalidResponseError, 'invalid HTTP status' if @status < 100 || @status >= 600
|
|
23
|
+
raise Error::InvalidResponseError, 'HTTP field name contains invalid characters' if (@fields.keys.join.downcase.bytes.uniq & FORBIDDEN_KEY_CHARS).any?
|
|
24
|
+
raise Error::InvalidResponseError, 'HTTP field value contains NUL, LF or CR' if (@fields.values.join.bytes.uniq & FORBIDDEN_VALUE_CHARS).any?
|
|
25
|
+
raise Error::InvalidResponseError, 'HTTP field value starts/ends with SP or HTAB' if @fields.values.filter { |s| s.start_with?("\t", ' ') || s.end_with?("\t", ' ') }.any?
|
|
26
26
|
end
|
|
27
27
|
# rubocop: enable Metrics/CyclomaticComplexity
|
|
28
28
|
|
data/lib/biryani/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: biryani
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- thekuwayama
|
|
@@ -39,9 +39,15 @@ files:
|
|
|
39
39
|
- biryani.gemspec
|
|
40
40
|
- conformance/server_spec.rb
|
|
41
41
|
- conformance/spec_helper.rb
|
|
42
|
+
- example/README.md
|
|
42
43
|
- example/echo.rb
|
|
43
44
|
- example/hello_world.rb
|
|
45
|
+
- example/kv_store.rb
|
|
46
|
+
- example/nginx.conf
|
|
44
47
|
- example/raise_error.rb
|
|
48
|
+
- example/sqlite3.rb
|
|
49
|
+
- example/tiles.rb
|
|
50
|
+
- example/tiles/index.html
|
|
45
51
|
- lib/biryani.rb
|
|
46
52
|
- lib/biryani/connection.rb
|
|
47
53
|
- lib/biryani/connection_error.rb
|
|
@@ -99,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
99
105
|
- !ruby/object:Gem::Version
|
|
100
106
|
version: '0'
|
|
101
107
|
requirements: []
|
|
102
|
-
rubygems_version: 4.0.
|
|
108
|
+
rubygems_version: 4.0.20
|
|
103
109
|
specification_version: 4
|
|
104
110
|
summary: An HTTP/2 server implemented using Ruby Ractor
|
|
105
111
|
test_files: []
|