biryani 0.0.14 → 0.0.16
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/conformance/server_spec.rb +3 -2
- 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 +171 -0
- data/example/tiles/index.html +47 -0
- data/example/tiles.rb +85 -0
- data/lib/biryani/connection.rb +76 -31
- data/lib/biryani/frame.rb +4 -1
- data/lib/biryani/hpack/decoder.rb +2 -0
- data/lib/biryani/hpack/dynamic_table.rb +1 -1
- data/lib/biryani/hpack/field.rb +3 -2
- data/lib/biryani/hpack/huffman.rb +2 -2
- data/lib/biryani/hpack/integer.rb +11 -0
- data/lib/biryani/hpack/string.rb +7 -0
- 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/server.rb +7 -3
- data/lib/biryani/stream.rb +5 -1
- data/lib/biryani/streams_context.rb +5 -0
- data/lib/biryani/utils.rb +9 -0
- data/lib/biryani/version.rb +1 -1
- data/lib/biryani.rb +1 -0
- 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: 1a92a6898bad6aab7de417a5eff15af74a99a41d33e380fd93447f8b15d77ba5
|
|
4
|
+
data.tar.gz: 473061a7d89df0b3956bc396a29102f76f6205b7e3795a452244f30f1f84da3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c76936ffedc6058d29c946a526217f47840248b0e7a1042a2fee816a3488f79aa2a848621f3c443d5d58eed16df8c3f013906fc3f34730eb71a24da6b2022287
|
|
7
|
+
data.tar.gz: e9b25fdf0d6d1481d12faae276941d80d8910361e0916f22d617b059982c3a56cc7310579e63c0b0466797181b2976eb4213b9650b613be55717f7d05060c250
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.1-dev
|
data/Gemfile
CHANGED
data/conformance/server_spec.rb
CHANGED
|
@@ -4,7 +4,7 @@ RSpec.describe Server do
|
|
|
4
4
|
before do
|
|
5
5
|
@tcpserver = TCPServer.open(PORT)
|
|
6
6
|
|
|
7
|
-
Ractor.new
|
|
7
|
+
r = Ractor.new do
|
|
8
8
|
server = Server.new(
|
|
9
9
|
Ractor.shareable_proc do |_, res|
|
|
10
10
|
res.status = 200
|
|
@@ -12,8 +12,9 @@ RSpec.describe Server do
|
|
|
12
12
|
end
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
-
server.run(
|
|
15
|
+
server.run(Ractor.receive)
|
|
16
16
|
end
|
|
17
|
+
r.send(@tcpserver.dup, move: true)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
let(:junit_report_file_path) do
|
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,171 @@
|
|
|
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
|
+
# | r.send(socket, move: true) | | |
|
|
24
|
+
# +----+-------------------------------+ +-------------------------------------------------+
|
|
25
|
+
# | ^
|
|
26
|
+
# v |
|
|
27
|
+
# +-- server Ractor ----------------------+ +-- connection Ractor (1 Ractor / connection) --+--+
|
|
28
|
+
# | server.run(socket) | | Stream.new(tx, stream_id, proc) | |
|
|
29
|
+
# | r.send(io, move: true) ------------+------>| 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
|
+
r = Ractor.new(server) { |s| s.run(Ractor.receive) }
|
|
163
|
+
r.send(socket, move: true)
|
|
164
|
+
DB.serve(path)
|
|
165
|
+
|
|
166
|
+
# $ bundle exec ruby sqlite3.rb
|
|
167
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -H "Content-Type: application/json" -d '{"name":"Alice","age":18}'
|
|
168
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/
|
|
169
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -H "Content-Type: application/json" -d '{"name":"Bob","age":20}'
|
|
170
|
+
# $ curl -v --http2-prior-knowledge http://localhost:8888/
|
|
171
|
+
# $ 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
|
@@ -14,8 +14,10 @@ module Biryani
|
|
|
14
14
|
CONNECTION_PREFACE_LENGTH = CONNECTION_PREFACE.length
|
|
15
15
|
DEFAULT_HEADER_TABLE_SIZE = 4_096
|
|
16
16
|
INITIAL_CONNECTION_WINDOW_SIZE = 65_535
|
|
17
|
+
LINGER_TIMEOUT = 5
|
|
17
18
|
|
|
18
|
-
private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH, :DEFAULT_HEADER_TABLE_SIZE, :INITIAL_CONNECTION_WINDOW_SIZE
|
|
19
|
+
private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH, :DEFAULT_HEADER_TABLE_SIZE, :INITIAL_CONNECTION_WINDOW_SIZE,
|
|
20
|
+
:LINGER_TIMEOUT
|
|
19
21
|
Ractor.make_shareable(CONNECTION_PREFACE)
|
|
20
22
|
Ractor.make_shareable(CONNECTION_PREFACE_LENGTH)
|
|
21
23
|
|
|
@@ -47,32 +49,33 @@ module Biryani
|
|
|
47
49
|
|
|
48
50
|
recv_loop(io.clone)
|
|
49
51
|
select_loop(io)
|
|
52
|
+
rescue *PEER_CLOSED_ERRORS
|
|
53
|
+
# peer has already closed the connection
|
|
50
54
|
rescue StandardError => e
|
|
51
55
|
puts e.backtrace
|
|
52
|
-
|
|
56
|
+
Biryani.ignore_peer_closed do
|
|
57
|
+
self.class.do_send(io, Frame::Goaway.new(@streams_ctx.last_stream_id, ErrorCode::INTERNAL_ERROR, 'internal error'), true)
|
|
58
|
+
end
|
|
53
59
|
ensure
|
|
54
|
-
io.close_write
|
|
60
|
+
Biryani.ignore_peer_closed { io.close_write }
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
# @param io [IO]
|
|
58
64
|
def recv_loop(io)
|
|
59
|
-
Ractor.new(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
break
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
port_.send([:frame, obj], move: true)
|
|
68
|
-
end
|
|
65
|
+
r = Ractor.new(@port) do |port_|
|
|
66
|
+
io_ = Ractor.receive
|
|
67
|
+
Connection.recv_frames(io_, port_)
|
|
68
|
+
ensure
|
|
69
|
+
Biryani.ignore_peer_closed { io_&.close }
|
|
69
70
|
end
|
|
71
|
+
r.send(io, move: true)
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
# @param io [IO]
|
|
73
75
|
def select_loop(io)
|
|
74
76
|
loop do
|
|
75
|
-
|
|
77
|
+
# After EOF, no frames arrive from the peer, so only half_closed_remote streams can still progress: by the handler's response.
|
|
78
|
+
break if closed? || (@recv_done && @streams_ctx.none_half_closed_remote?)
|
|
76
79
|
|
|
77
80
|
case @port.receive
|
|
78
81
|
in :eof
|
|
@@ -82,8 +85,6 @@ module Biryani
|
|
|
82
85
|
in [:response, res, stream_id]
|
|
83
86
|
handle_response(io, res, stream_id)
|
|
84
87
|
end
|
|
85
|
-
|
|
86
|
-
break if closed?
|
|
87
88
|
end
|
|
88
89
|
end
|
|
89
90
|
|
|
@@ -148,7 +149,8 @@ module Biryani
|
|
|
148
149
|
return [] if obj.nil?
|
|
149
150
|
|
|
150
151
|
settings_ack = obj
|
|
151
|
-
[
|
|
152
|
+
max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
153
|
+
[settings_ack] + @data_buffer.take!(@send_window, @streams_ctx, max_frame_size)
|
|
152
154
|
when FrameType::PING
|
|
153
155
|
obj = self.class.handle_ping(frame)
|
|
154
156
|
return [] if obj.nil?
|
|
@@ -185,7 +187,7 @@ module Biryani
|
|
|
185
187
|
return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
|
|
186
188
|
if [FrameType::SETTINGS, FrameType::PING, FrameType::GOAWAY].include?(typ)
|
|
187
189
|
|
|
188
|
-
max_streams = @
|
|
190
|
+
max_streams = @settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
|
|
189
191
|
send_initial_window_size = @peer_settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
|
|
190
192
|
recv_initial_window_size = @settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
|
|
191
193
|
obj = self.class.transition_stream_state_recv(frame, @streams_ctx, @data_buffer, stream_id, max_streams, send_initial_window_size, recv_initial_window_size)
|
|
@@ -235,6 +237,14 @@ module Biryani
|
|
|
235
237
|
# @param res [HTTP::Response]
|
|
236
238
|
# @param stream_id [Integer]
|
|
237
239
|
def handle_response(io, res, stream_id)
|
|
240
|
+
ctx = @streams_ctx[stream_id]
|
|
241
|
+
# The RST_STREAM frame fully terminates the referenced stream and
|
|
242
|
+
# causes it to enter the "closed" state. After receiving a RST_STREAM
|
|
243
|
+
# on a stream, the receiver MUST NOT send additional frames for that
|
|
244
|
+
# stream, except for PRIORITY.
|
|
245
|
+
# https://datatracker.ietf.org/doc/html/rfc9113#section-6.4-5
|
|
246
|
+
return if ctx.nil? || ctx.closed?
|
|
247
|
+
|
|
238
248
|
fragment, data = self.class.http_response(res, @encoder)
|
|
239
249
|
max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
240
250
|
self.class.send_headers(io, stream_id, fragment, data.empty?, max_frame_size, @streams_ctx)
|
|
@@ -359,14 +369,44 @@ module Biryani
|
|
|
359
369
|
ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid connection preface') if s != CONNECTION_PREFACE
|
|
360
370
|
end
|
|
361
371
|
|
|
372
|
+
# @param io [IO]
|
|
373
|
+
# @param port [Ractor::Port]
|
|
374
|
+
def self.recv_frames(io, port)
|
|
375
|
+
loop do
|
|
376
|
+
obj = Frame.read(io)
|
|
377
|
+
break if obj.nil?
|
|
378
|
+
|
|
379
|
+
port.send([:frame, obj], move: true)
|
|
380
|
+
rescue Ractor::ClosedError
|
|
381
|
+
# the connection has finished before the peer closes
|
|
382
|
+
return linger(io)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
port.send(:eof, move: true)
|
|
386
|
+
rescue Ractor::ClosedError
|
|
387
|
+
# the connection has finished after the peer closes
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Lingering close: discard incoming data until EOF so that close does not send RST.
|
|
391
|
+
#
|
|
392
|
+
# @param io [IO]
|
|
393
|
+
def self.linger(io)
|
|
394
|
+
Timeout.timeout(LINGER_TIMEOUT) do
|
|
395
|
+
loop { io.readpartial(16_384) }
|
|
396
|
+
end
|
|
397
|
+
rescue *PEER_CLOSED_ERRORS, Timeout::Error
|
|
398
|
+
# EOF, timeout, connection reset, etc.
|
|
399
|
+
end
|
|
400
|
+
|
|
362
401
|
# @param stream_id [Integer]
|
|
363
402
|
# @param data [String]
|
|
364
403
|
# @param recv_window [Window]
|
|
365
404
|
# @param streams_ctx [StreamsContext]
|
|
366
405
|
# @param decoder [Decoder]
|
|
367
406
|
#
|
|
368
|
-
# @return [Array<WindowUpdate>, ConnectionError]
|
|
407
|
+
# @return [Array<WindowUpdate>, ConnectionError, StreamError]
|
|
369
408
|
# rubocop: disable Metrics/AbcSize
|
|
409
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
|
370
410
|
def self.handle_data(stream_id, data, recv_window, streams_ctx, decoder)
|
|
371
411
|
ctx = streams_ctx[stream_id]
|
|
372
412
|
return ConnectionError.new(ErrorCode::FLOW_CONTROL_ERROR, 'DATA Frame length exceeds flow-control window size') \
|
|
@@ -374,7 +414,8 @@ module Biryani
|
|
|
374
414
|
|
|
375
415
|
ctx.content << data
|
|
376
416
|
if ctx.half_closed_remote?
|
|
377
|
-
obj = http_request(ctx.fragment, ctx.content, decoder)
|
|
417
|
+
obj = http_request(stream_id, ctx.fragment, ctx.content, decoder)
|
|
418
|
+
ctx << nil if Biryani.err?(obj) # terminate the Stream Ractor waiting for a request
|
|
378
419
|
return obj if Biryani.err?(obj)
|
|
379
420
|
|
|
380
421
|
ctx << obj
|
|
@@ -386,16 +427,18 @@ module Biryani
|
|
|
386
427
|
window_updates
|
|
387
428
|
end
|
|
388
429
|
# rubocop: enable Metrics/AbcSize
|
|
430
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
|
389
431
|
|
|
390
432
|
# @param headers [Headers]
|
|
391
433
|
# @param ctx [StreamContext]
|
|
392
434
|
# @param decoder [Decoder]
|
|
393
435
|
#
|
|
394
|
-
# @return [nil, ConnectionError]
|
|
436
|
+
# @return [nil, ConnectionError, StreamError]
|
|
395
437
|
def self.handle_headers(headers, ctx, decoder)
|
|
396
438
|
ctx.fragment << headers.fragment
|
|
397
439
|
if ctx.half_closed_remote?
|
|
398
|
-
obj = http_request(ctx.fragment, ctx.content, decoder)
|
|
440
|
+
obj = http_request(headers.stream_id, ctx.fragment, ctx.content, decoder)
|
|
441
|
+
ctx << nil if Biryani.err?(obj) # terminate the Stream Ractor waiting for a request
|
|
399
442
|
return obj if Biryani.err?(obj)
|
|
400
443
|
|
|
401
444
|
ctx << obj
|
|
@@ -463,21 +506,23 @@ module Biryani
|
|
|
463
506
|
nil
|
|
464
507
|
end
|
|
465
508
|
|
|
509
|
+
# @param stream_id [Integer]
|
|
466
510
|
# @param fragment [String]
|
|
467
511
|
# @param content [String]
|
|
468
512
|
# @param decoder [Decoder]
|
|
469
513
|
#
|
|
470
|
-
# @return [HTTP::Request, ConnectionError]
|
|
471
|
-
def self.http_request(fragment, content, decoder)
|
|
472
|
-
|
|
473
|
-
return obj if Biryani.err?(obj)
|
|
474
|
-
|
|
475
|
-
fields = obj
|
|
514
|
+
# @return [HTTP::Request, ConnectionError, StreamError]
|
|
515
|
+
def self.http_request(stream_id, fragment, content, decoder)
|
|
516
|
+
fields = decoder.decode(fragment)
|
|
476
517
|
builder = HTTP::RequestBuilder.new
|
|
477
|
-
|
|
478
|
-
return err unless err.nil?
|
|
479
|
-
|
|
518
|
+
builder.fields(fields)
|
|
480
519
|
builder.build(content)
|
|
520
|
+
rescue HPACK::Error::Error => e
|
|
521
|
+
# https://datatracker.ietf.org/doc/html/rfc9113#section-4.3-3
|
|
522
|
+
ConnectionError.new(ErrorCode::COMPRESSION_ERROR, e.message)
|
|
523
|
+
rescue HTTP::Error::MalformedRequestError => e
|
|
524
|
+
# https://datatracker.ietf.org/doc/html/rfc9113#section-8.1.1-3
|
|
525
|
+
StreamError.new(ErrorCode::PROTOCOL_ERROR, stream_id, e.message)
|
|
481
526
|
end
|
|
482
527
|
|
|
483
528
|
# @param res [HTTP::Response]
|
data/lib/biryani/frame.rb
CHANGED
|
@@ -157,10 +157,13 @@ module Biryani
|
|
|
157
157
|
|
|
158
158
|
payload_length, f_type, flags, stream_id = read_header(s)
|
|
159
159
|
payload = io.read(payload_length)
|
|
160
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if payload.bytesize != payload_length
|
|
160
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if payload.nil? || payload.bytesize != payload_length
|
|
161
161
|
return Frame::Unknown.new(f_type, flags, stream_id, payload) unless FRAME_MAP.key?(f_type)
|
|
162
162
|
|
|
163
163
|
FRAME_MAP[f_type].read(payload, flags, stream_id)
|
|
164
|
+
rescue *PEER_CLOSED_ERRORS
|
|
165
|
+
# the peer has closed the connection (e.g. ECONNRESET): treat it as EOF
|
|
166
|
+
nil
|
|
164
167
|
rescue StandardError
|
|
165
168
|
ConnectionError.new(ErrorCode::FRAME_SIZE_ERROR, 'invalid frame')
|
|
166
169
|
end
|
|
@@ -51,7 +51,7 @@ module Biryani
|
|
|
51
51
|
#
|
|
52
52
|
# @return [Array] [name, value]
|
|
53
53
|
def entry_at(index)
|
|
54
|
-
raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + count_entries
|
|
54
|
+
raise Error::HPACKDecodeError, "invalid index #{index}" if index.zero? || index > STATIC_TABLE_SIZE + count_entries
|
|
55
55
|
|
|
56
56
|
if index <= STATIC_TABLE_SIZE
|
|
57
57
|
STATIC_TABLE[index - 1]
|
data/lib/biryani/hpack/field.rb
CHANGED
|
@@ -215,10 +215,11 @@ module Biryani
|
|
|
215
215
|
# @return [nil]
|
|
216
216
|
# @return [Integer]
|
|
217
217
|
def self.decode_dynamic_table_size_update(io, cursor, dynamic_table)
|
|
218
|
-
raise Error::HPACKDecodeError
|
|
218
|
+
raise Error::HPACKDecodeError, 'misplaced dynamic table size update' \
|
|
219
|
+
unless cursor.zero? || (io.get_value(:U8, 0) & 0b00100000).positive? && Integer.decode(io, 5, 0)[1] == cursor
|
|
219
220
|
|
|
220
221
|
max_size, c = Integer.decode(io, 5, cursor)
|
|
221
|
-
raise Error::HPACKDecodeError if max_size > dynamic_table.limit
|
|
222
|
+
raise Error::HPACKDecodeError, 'dynamic table size update exceeds the limit' if max_size > dynamic_table.limit
|
|
222
223
|
|
|
223
224
|
dynamic_table.chomp!(max_size)
|
|
224
225
|
[nil, c]
|
|
@@ -550,7 +550,7 @@ module Biryani
|
|
|
550
550
|
(length * 8).times do |i|
|
|
551
551
|
bits_len += 1
|
|
552
552
|
bits += 1 if (bytes[i / 8] & (1 << 7 - (i % 8))).positive?
|
|
553
|
-
raise Error::HuffmanDecodeError if bits == EOS
|
|
553
|
+
raise Error::HuffmanDecodeError, 'EOS in huffman-encoded string' if bits == EOS
|
|
554
554
|
|
|
555
555
|
if (chr = DECODE_TABLE[[bits, bits_len]])
|
|
556
556
|
res << chr
|
|
@@ -560,7 +560,7 @@ module Biryani
|
|
|
560
560
|
bits <<= 1
|
|
561
561
|
end
|
|
562
562
|
end
|
|
563
|
-
raise Error::HuffmanDecodeError if bits_len.positive? && bits != (2 << bits_len) - 2 || bits_len >= 8
|
|
563
|
+
raise Error::HuffmanDecodeError, 'invalid huffman padding' if bits_len.positive? && bits != (2 << bits_len) - 2 || bits_len >= 8
|
|
564
564
|
|
|
565
565
|
res
|
|
566
566
|
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
module Biryani
|
|
2
2
|
module HPACK
|
|
3
3
|
module Integer
|
|
4
|
+
# https://datatracker.ietf.org/doc/html/rfc7541#section-7.4
|
|
5
|
+
MAX_DECODED_INTEGER = (1 << 32) - 1
|
|
6
|
+
private_constant :MAX_DECODED_INTEGER
|
|
7
|
+
|
|
4
8
|
# if I < 2^N - 1, encode I on N bits
|
|
5
9
|
# else
|
|
6
10
|
# encode (2^N - 1) on N bits
|
|
@@ -44,10 +48,14 @@ module Biryani
|
|
|
44
48
|
# @param cursor [Integer]
|
|
45
49
|
# @param n [Integer]
|
|
46
50
|
#
|
|
51
|
+
# @raise [HPACKDecodeError]
|
|
52
|
+
#
|
|
47
53
|
# @return [Integer]
|
|
48
54
|
# @return [Integer]
|
|
49
55
|
def self.decode(io, n, cursor)
|
|
50
56
|
limit = (1 << n) - 1
|
|
57
|
+
raise Error::HPACKDecodeError, 'integer exceeds field block' if cursor >= io.size
|
|
58
|
+
|
|
51
59
|
h = io.get_value(:U8, cursor)
|
|
52
60
|
return [h & limit, cursor + 1] if (h & limit) != limit
|
|
53
61
|
|
|
@@ -55,8 +63,11 @@ module Biryani
|
|
|
55
63
|
c = cursor + 1
|
|
56
64
|
i = 0
|
|
57
65
|
loop do
|
|
66
|
+
raise Error::HPACKDecodeError, 'integer exceeds field block' if c + i >= io.size
|
|
67
|
+
|
|
58
68
|
byte = io.get_value(:U8, c + i)
|
|
59
69
|
res += (byte & 127) << (i * 7)
|
|
70
|
+
raise Error::HPACKDecodeError, 'integer overflow' if res > MAX_DECODED_INTEGER
|
|
60
71
|
|
|
61
72
|
break if (byte & 128).zero?
|
|
62
73
|
|
data/lib/biryani/hpack/string.rb
CHANGED
|
@@ -26,11 +26,18 @@ module Biryani
|
|
|
26
26
|
# @param io [IO::Buffer]
|
|
27
27
|
# @param cursor [Integer]
|
|
28
28
|
#
|
|
29
|
+
# @raise [HPACKDecodeError]
|
|
30
|
+
# @raise [HuffmanDecodeError]
|
|
31
|
+
#
|
|
29
32
|
# @return [String]
|
|
30
33
|
# @return [Integer]
|
|
31
34
|
def self.decode(io, cursor)
|
|
35
|
+
raise Error::HPACKDecodeError, 'string exceeds field block' if cursor >= io.size
|
|
36
|
+
|
|
32
37
|
h = (io.get_value(:U8, cursor) & 0b10000000).positive?
|
|
33
38
|
len, c = Integer.decode(io, 7, cursor)
|
|
39
|
+
raise Error::HPACKDecodeError, 'string exceeds field block' if c + len > io.size
|
|
40
|
+
|
|
34
41
|
return [Huffman.decode(io, c, len), c + len] if h
|
|
35
42
|
|
|
36
43
|
[io.get_string(c, len), c + len]
|
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/server.rb
CHANGED
|
@@ -8,11 +8,15 @@ module Biryani
|
|
|
8
8
|
# @param socket [Socket]
|
|
9
9
|
def run(socket)
|
|
10
10
|
loop do
|
|
11
|
-
|
|
11
|
+
io = socket.accept
|
|
12
|
+
r = Ractor.new(@proc) do |proc|
|
|
13
|
+
io_ = Ractor.receive
|
|
12
14
|
conn = Connection.new(proc)
|
|
13
|
-
conn.serve(
|
|
14
|
-
|
|
15
|
+
conn.serve(io_)
|
|
16
|
+
ensure
|
|
17
|
+
Biryani.ignore_peer_closed { io_&.close }
|
|
15
18
|
end
|
|
19
|
+
r.send(io, move: true)
|
|
16
20
|
end
|
|
17
21
|
end
|
|
18
22
|
end
|
data/lib/biryani/stream.rb
CHANGED
|
@@ -18,7 +18,11 @@ module Biryani
|
|
|
18
18
|
res = HTTP::Response.internal_server_error
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
begin
|
|
22
|
+
tx.send([:response, res, stream_id], move: true)
|
|
23
|
+
rescue Ractor::ClosedError
|
|
24
|
+
# the Connection has already finished, so discard the response
|
|
25
|
+
end
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
end
|
|
@@ -50,6 +50,11 @@ module Biryani
|
|
|
50
50
|
@h.values.filter(&:active?).length
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def none_half_closed_remote?
|
|
55
|
+
@h.each_value.none?(&:half_closed_remote?)
|
|
56
|
+
end
|
|
57
|
+
|
|
53
58
|
# @return [Integer, nil]
|
|
54
59
|
def receiving_continuation_stream_id
|
|
55
60
|
@h.find { |_, ctx| ctx.receiving_continuation? }&.first
|
data/lib/biryani/utils.rb
CHANGED
|
@@ -20,4 +20,13 @@ module Biryani
|
|
|
20
20
|
obj
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
# Errors raised when the peer has already closed the connection (EOF, EPIPE, ECONNRESET, etc.)
|
|
25
|
+
PEER_CLOSED_ERRORS = Ractor.make_shareable([IOError, SystemCallError])
|
|
26
|
+
|
|
27
|
+
def self.ignore_peer_closed
|
|
28
|
+
yield
|
|
29
|
+
rescue *PEER_CLOSED_ERRORS
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
23
32
|
end
|
data/lib/biryani/version.rb
CHANGED
data/lib/biryani.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.16
|
|
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: []
|