biryani 0.0.12 → 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 +43 -29
- data/lib/biryani/data_buffer.rb +2 -2
- data/lib/biryani/frame/data.rb +1 -4
- data/lib/biryani/frame/headers.rb +3 -10
- data/lib/biryani/frame/push_promise.rb +2 -4
- data/lib/biryani/frame.rb +16 -0
- data/lib/biryani/hpack/dynamic_table.rb +20 -0
- data/lib/biryani/hpack/field.rb +4 -29
- 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/stream.rb +1 -1
- data/lib/biryani/streams_context.rb +2 -2
- 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
|
@@ -12,20 +12,23 @@ module Biryani
|
|
|
12
12
|
class Connection
|
|
13
13
|
CONNECTION_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".freeze
|
|
14
14
|
CONNECTION_PREFACE_LENGTH = CONNECTION_PREFACE.length
|
|
15
|
+
DEFAULT_HEADER_TABLE_SIZE = 4_096
|
|
16
|
+
INITIAL_CONNECTION_WINDOW_SIZE = 65_535
|
|
15
17
|
|
|
16
|
-
private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH
|
|
18
|
+
private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH, :DEFAULT_HEADER_TABLE_SIZE, :INITIAL_CONNECTION_WINDOW_SIZE
|
|
17
19
|
Ractor.make_shareable(CONNECTION_PREFACE)
|
|
18
20
|
Ractor.make_shareable(CONNECTION_PREFACE_LENGTH)
|
|
19
21
|
|
|
20
22
|
# proc [Proc]
|
|
21
23
|
def initialize(proc)
|
|
22
|
-
@
|
|
24
|
+
@port = Ractor::Port.new
|
|
25
|
+
@recv_done = false
|
|
23
26
|
@proc = proc
|
|
24
|
-
@streams_ctx = StreamsContext.new(proc)
|
|
25
|
-
@encoder = HPACK::Encoder.new(
|
|
26
|
-
@decoder = HPACK::Decoder.new(
|
|
27
|
-
@send_window = Window.new(
|
|
28
|
-
@recv_window = Window.new(
|
|
27
|
+
@streams_ctx = StreamsContext.new(proc, @port)
|
|
28
|
+
@encoder = HPACK::Encoder.new(DEFAULT_HEADER_TABLE_SIZE)
|
|
29
|
+
@decoder = HPACK::Decoder.new(DEFAULT_HEADER_TABLE_SIZE)
|
|
30
|
+
@send_window = Window.new(INITIAL_CONNECTION_WINDOW_SIZE)
|
|
31
|
+
@recv_window = Window.new(INITIAL_CONNECTION_WINDOW_SIZE)
|
|
29
32
|
@data_buffer = DataBuffer.new
|
|
30
33
|
@settings = self.class.default_settings # Hash<Integer, Integer>
|
|
31
34
|
@peer_settings = self.class.default_settings # Hash<Integer, Integer>
|
|
@@ -53,12 +56,15 @@ module Biryani
|
|
|
53
56
|
|
|
54
57
|
# @param io [IO]
|
|
55
58
|
def recv_loop(io)
|
|
56
|
-
Ractor.new(io, @
|
|
59
|
+
Ractor.new(io, @port) do |io_, port_|
|
|
57
60
|
loop do
|
|
58
61
|
obj = Frame.read(io_)
|
|
59
|
-
|
|
62
|
+
if obj.nil?
|
|
63
|
+
port_.send(:eof, move: true)
|
|
64
|
+
break
|
|
65
|
+
end
|
|
60
66
|
|
|
61
|
-
|
|
67
|
+
port_.send([:frame, obj], move: true)
|
|
62
68
|
end
|
|
63
69
|
end
|
|
64
70
|
end
|
|
@@ -66,13 +72,14 @@ module Biryani
|
|
|
66
72
|
# @param io [IO]
|
|
67
73
|
def select_loop(io)
|
|
68
74
|
loop do
|
|
69
|
-
break if @
|
|
75
|
+
break if @recv_done && @streams_ctx.empty?
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
case @port.receive
|
|
78
|
+
in :eof
|
|
79
|
+
@recv_done = true
|
|
80
|
+
in [:frame, obj]
|
|
73
81
|
recv_dispatch(io, obj)
|
|
74
|
-
|
|
75
|
-
res, stream_id = obj
|
|
82
|
+
in [:response, res, stream_id]
|
|
76
83
|
handle_response(io, res, stream_id)
|
|
77
84
|
end
|
|
78
85
|
|
|
@@ -118,7 +125,7 @@ module Biryani
|
|
|
118
125
|
# @return [Array<Object>, Array<ConnectionError>, Array<StreamError>] frames or errors
|
|
119
126
|
def do_recv_dispatch(frame)
|
|
120
127
|
receiving_continuation_stream_id = @streams_ctx.receiving_continuation_stream_id
|
|
121
|
-
return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x',
|
|
128
|
+
return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', frame.f_type)} for stream identifier #{format('0x%02x', frame.stream_id)}")] \
|
|
122
129
|
if !receiving_continuation_stream_id.nil? && frame.stream_id != receiving_continuation_stream_id
|
|
123
130
|
|
|
124
131
|
if frame.stream_id.zero?
|
|
@@ -135,13 +142,14 @@ module Biryani
|
|
|
135
142
|
def handle_connection_frame(frame)
|
|
136
143
|
case frame.f_type
|
|
137
144
|
when FrameType::DATA, FrameType::HEADERS, FrameType::PRIORITY, FrameType::RST_STREAM, FrameType::PUSH_PROMISE, FrameType::CONTINUATION
|
|
138
|
-
[ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x',
|
|
145
|
+
[ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', frame.f_type)} for stream identifier 0x00")]
|
|
139
146
|
when FrameType::SETTINGS
|
|
140
147
|
obj = self.class.handle_settings(frame, @peer_settings, @decoder, @streams_ctx)
|
|
141
148
|
return [] if obj.nil?
|
|
142
149
|
|
|
143
150
|
settings_ack = obj
|
|
144
|
-
[
|
|
151
|
+
max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
152
|
+
[settings_ack] + @data_buffer.take!(@send_window, @streams_ctx, max_frame_size)
|
|
145
153
|
when FrameType::PING
|
|
146
154
|
obj = self.class.handle_ping(frame)
|
|
147
155
|
return [] if obj.nil?
|
|
@@ -178,7 +186,7 @@ module Biryani
|
|
|
178
186
|
return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
|
|
179
187
|
if [FrameType::SETTINGS, FrameType::PING, FrameType::GOAWAY].include?(typ)
|
|
180
188
|
|
|
181
|
-
max_streams = @
|
|
189
|
+
max_streams = @settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
|
|
182
190
|
send_initial_window_size = @peer_settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
|
|
183
191
|
recv_initial_window_size = @settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
|
|
184
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)
|
|
@@ -358,8 +366,9 @@ module Biryani
|
|
|
358
366
|
# @param streams_ctx [StreamsContext]
|
|
359
367
|
# @param decoder [Decoder]
|
|
360
368
|
#
|
|
361
|
-
# @return [Array<WindowUpdate>, ConnectionError]
|
|
369
|
+
# @return [Array<WindowUpdate>, ConnectionError, StreamError]
|
|
362
370
|
# rubocop: disable Metrics/AbcSize
|
|
371
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
|
363
372
|
def self.handle_data(stream_id, data, recv_window, streams_ctx, decoder)
|
|
364
373
|
ctx = streams_ctx[stream_id]
|
|
365
374
|
return ConnectionError.new(ErrorCode::FLOW_CONTROL_ERROR, 'DATA Frame length exceeds flow-control window size') \
|
|
@@ -367,7 +376,8 @@ module Biryani
|
|
|
367
376
|
|
|
368
377
|
ctx.content << data
|
|
369
378
|
if ctx.half_closed_remote?
|
|
370
|
-
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
|
|
371
381
|
return obj if Biryani.err?(obj)
|
|
372
382
|
|
|
373
383
|
ctx << obj
|
|
@@ -379,16 +389,18 @@ module Biryani
|
|
|
379
389
|
window_updates
|
|
380
390
|
end
|
|
381
391
|
# rubocop: enable Metrics/AbcSize
|
|
392
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
|
382
393
|
|
|
383
394
|
# @param headers [Headers]
|
|
384
395
|
# @param ctx [StreamContext]
|
|
385
396
|
# @param decoder [Decoder]
|
|
386
397
|
#
|
|
387
|
-
# @return [nil, ConnectionError]
|
|
398
|
+
# @return [nil, ConnectionError, StreamError]
|
|
388
399
|
def self.handle_headers(headers, ctx, decoder)
|
|
389
400
|
ctx.fragment << headers.fragment
|
|
390
401
|
if ctx.half_closed_remote?
|
|
391
|
-
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
|
|
392
404
|
return obj if Biryani.err?(obj)
|
|
393
405
|
|
|
394
406
|
ctx << obj
|
|
@@ -456,21 +468,23 @@ module Biryani
|
|
|
456
468
|
nil
|
|
457
469
|
end
|
|
458
470
|
|
|
471
|
+
# @param stream_id [Integer]
|
|
459
472
|
# @param fragment [String]
|
|
460
473
|
# @param content [String]
|
|
461
474
|
# @param decoder [Decoder]
|
|
462
475
|
#
|
|
463
|
-
# @return [HTTP::Request, ConnectionError]
|
|
464
|
-
def self.http_request(fragment, content, decoder)
|
|
476
|
+
# @return [HTTP::Request, ConnectionError, StreamError]
|
|
477
|
+
def self.http_request(stream_id, fragment, content, decoder)
|
|
465
478
|
obj = decoder.decode(fragment)
|
|
466
479
|
return obj if Biryani.err?(obj)
|
|
467
480
|
|
|
468
481
|
fields = obj
|
|
469
482
|
builder = HTTP::RequestBuilder.new
|
|
470
|
-
|
|
471
|
-
return err unless err.nil?
|
|
472
|
-
|
|
483
|
+
builder.fields(fields)
|
|
473
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)
|
|
474
488
|
end
|
|
475
489
|
|
|
476
490
|
# @param res [HTTP::Response]
|
|
@@ -486,7 +500,7 @@ module Biryani
|
|
|
486
500
|
def self.default_settings
|
|
487
501
|
# https://datatracker.ietf.org/doc/html/rfc9113#section-6.5.2
|
|
488
502
|
{
|
|
489
|
-
SettingsID::SETTINGS_HEADER_TABLE_SIZE =>
|
|
503
|
+
SettingsID::SETTINGS_HEADER_TABLE_SIZE => DEFAULT_HEADER_TABLE_SIZE,
|
|
490
504
|
SettingsID::SETTINGS_ENABLE_PUSH => 1,
|
|
491
505
|
SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS => 0xffffffff,
|
|
492
506
|
SettingsID::SETTINGS_INITIAL_WINDOW_SIZE => 65_535,
|
data/lib/biryani/data_buffer.rb
CHANGED
|
@@ -20,14 +20,14 @@ module Biryani
|
|
|
20
20
|
datas = []
|
|
21
21
|
@buffer.each do |stream_id, data|
|
|
22
22
|
frames, remains = streams_ctx.sendable_datas(stream_id, data, send_window, max_frame_size)
|
|
23
|
-
datas
|
|
23
|
+
datas.concat(frames)
|
|
24
24
|
if remains.empty?
|
|
25
25
|
@buffer.delete(stream_id)
|
|
26
26
|
else
|
|
27
27
|
@buffer[stream_id] = remains
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
len = frames.
|
|
30
|
+
len = frames.sum(&:length)
|
|
31
31
|
send_window.consume!(len)
|
|
32
32
|
streams_ctx[stream_id].send_window.consume!(len)
|
|
33
33
|
end
|
data/lib/biryani/frame/data.rb
CHANGED
|
@@ -51,10 +51,7 @@ module Biryani
|
|
|
51
51
|
|
|
52
52
|
if padded
|
|
53
53
|
io = IO::Buffer.for(s)
|
|
54
|
-
pad_length =
|
|
55
|
-
data_length = s.bytesize - pad_length - 1
|
|
56
|
-
data = io.get_string(1, data_length)
|
|
57
|
-
padding = io.get_string(1 + data_length)
|
|
54
|
+
data, padding, pad_length = Frame.read_padded_payload(io, s, 1)
|
|
58
55
|
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if padding.bytesize != pad_length
|
|
59
56
|
else
|
|
60
57
|
data = s
|
|
@@ -64,7 +64,6 @@ module Biryani
|
|
|
64
64
|
# @return [Headers]
|
|
65
65
|
# rubocop: disable Metrics/AbcSize
|
|
66
66
|
# rubocop: disable Metrics/CyclomaticComplexity
|
|
67
|
-
# rubocop: disable Metrics/MethodLength
|
|
68
67
|
# rubocop: disable Metrics/PerceivedComplexity
|
|
69
68
|
def self.read(s, flags, stream_id)
|
|
70
69
|
priority = Frame.read_priority(flags)
|
|
@@ -74,14 +73,12 @@ module Biryani
|
|
|
74
73
|
|
|
75
74
|
if priority && padded
|
|
76
75
|
io = IO::Buffer.for(s)
|
|
77
|
-
|
|
78
|
-
fragment_length = s.bytesize - pad_length - 6
|
|
76
|
+
_pad_length, stream_dependency, weight = io.get_values(%i[U8 U32 U8], 0)
|
|
79
77
|
# exclusive = (stream_dependency / 2**31).positive?
|
|
80
78
|
stream_dependency %= 2**31
|
|
81
79
|
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'cannot depend on itself') if stream_dependency == stream_id
|
|
82
80
|
|
|
83
|
-
fragment =
|
|
84
|
-
padding = io.get_string(6 + fragment_length)
|
|
81
|
+
fragment, padding, pad_length = Frame.read_padded_payload(io, s, 6)
|
|
85
82
|
return ConnectionError.new(ErrorCode::FRAME_SIZE_ERROR, 'invalid frame') if padding.bytesize != pad_length
|
|
86
83
|
elsif priority
|
|
87
84
|
io = IO::Buffer.for(s)
|
|
@@ -94,10 +91,7 @@ module Biryani
|
|
|
94
91
|
return ConnectionError.new(ErrorCode::FRAME_SIZE_ERROR, 'invalid frame') if fragment.bytesize + 5 != s.bytesize
|
|
95
92
|
elsif padded
|
|
96
93
|
io = IO::Buffer.for(s)
|
|
97
|
-
pad_length =
|
|
98
|
-
fragment_length = s.bytesize - pad_length - 1
|
|
99
|
-
fragment = io.get_string(1, fragment_length)
|
|
100
|
-
padding = io.get_string(1 + fragment_length)
|
|
94
|
+
fragment, padding, pad_length = Frame.read_padded_payload(io, s, 1)
|
|
101
95
|
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if pad_length >= s.bytesize
|
|
102
96
|
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if padding.bytesize != pad_length
|
|
103
97
|
else
|
|
@@ -108,7 +102,6 @@ module Biryani
|
|
|
108
102
|
end
|
|
109
103
|
# rubocop: enable Metrics/AbcSize
|
|
110
104
|
# rubocop: enable Metrics/CyclomaticComplexity
|
|
111
|
-
# rubocop: enable Metrics/MethodLength
|
|
112
105
|
# rubocop: enable Metrics/PerceivedComplexity
|
|
113
106
|
end
|
|
114
107
|
end
|
|
@@ -53,11 +53,9 @@ module Biryani
|
|
|
53
53
|
|
|
54
54
|
io = IO::Buffer.for(s)
|
|
55
55
|
if padded
|
|
56
|
-
|
|
56
|
+
_pad_length, promised_stream_id = io.get_values(%i[U8 U32], 0)
|
|
57
57
|
promised_stream_id %= 2**31 # Promised Stream ID (31)
|
|
58
|
-
|
|
59
|
-
fragment = io.get_string(5, fragment_length)
|
|
60
|
-
padding = io.get_string(5 + fragment_length)
|
|
58
|
+
fragment, padding, pad_length = Frame.read_padded_payload(io, s, 5)
|
|
61
59
|
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if pad_length >= s.bytesize
|
|
62
60
|
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if padding.bytesize != pad_length
|
|
63
61
|
else
|
data/lib/biryani/frame.rb
CHANGED
|
@@ -98,6 +98,22 @@ module Biryani
|
|
|
98
98
|
def self.to_binary_s_header(payload_length, f_type, flags, stream_id)
|
|
99
99
|
[payload_length, f_type, flags, stream_id].pack('NCCN')[1..]
|
|
100
100
|
end
|
|
101
|
+
|
|
102
|
+
# @param io [IO::Buffer]
|
|
103
|
+
# @param s [String]
|
|
104
|
+
# @param header_length [Integer] bytes preceding the payload, including the 1-byte Pad Length field
|
|
105
|
+
#
|
|
106
|
+
# @return [String] payload
|
|
107
|
+
# @return [String] padding
|
|
108
|
+
# @return [Integer] pad_length
|
|
109
|
+
def self.read_padded_payload(io, s, header_length)
|
|
110
|
+
pad_length = io.get_value(:U8, 0)
|
|
111
|
+
payload_length = s.bytesize - pad_length - header_length
|
|
112
|
+
payload = io.get_string(header_length, payload_length)
|
|
113
|
+
padding = io.get_string(header_length + payload_length)
|
|
114
|
+
|
|
115
|
+
[payload, padding, pad_length]
|
|
116
|
+
end
|
|
101
117
|
end
|
|
102
118
|
end
|
|
103
119
|
|
|
@@ -47,6 +47,26 @@ module Biryani
|
|
|
47
47
|
@table[index]
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# @param index [Integer]
|
|
51
|
+
#
|
|
52
|
+
# @return [Array] [name, value]
|
|
53
|
+
def entry_at(index)
|
|
54
|
+
raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + count_entries
|
|
55
|
+
|
|
56
|
+
if index <= STATIC_TABLE_SIZE
|
|
57
|
+
STATIC_TABLE[index - 1]
|
|
58
|
+
else
|
|
59
|
+
self[index - 1 - STATIC_TABLE_SIZE]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param index [Integer]
|
|
64
|
+
#
|
|
65
|
+
# @return [String]
|
|
66
|
+
def name_at(index)
|
|
67
|
+
entry_at(index)[0]
|
|
68
|
+
end
|
|
69
|
+
|
|
50
70
|
# @param new_limit [Integer]
|
|
51
71
|
def chomp!(new_limit)
|
|
52
72
|
while @size > new_limit
|
data/lib/biryani/hpack/field.rb
CHANGED
|
@@ -145,15 +145,8 @@ module Biryani
|
|
|
145
145
|
# @return [Integer]
|
|
146
146
|
def self.decode_indexed(io, cursor, dynamic_table)
|
|
147
147
|
index, c = Integer.decode(io, 7, cursor)
|
|
148
|
-
raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + dynamic_table.count_entries
|
|
149
148
|
|
|
150
|
-
|
|
151
|
-
STATIC_TABLE[index - 1]
|
|
152
|
-
else
|
|
153
|
-
dynamic_table[index - 1 - STATIC_TABLE_SIZE]
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
[field, c]
|
|
149
|
+
[dynamic_table.entry_at(index), c]
|
|
157
150
|
end
|
|
158
151
|
|
|
159
152
|
# 0 1 2 3 4 5 6 7
|
|
@@ -202,13 +195,7 @@ module Biryani
|
|
|
202
195
|
# @return [Integer]
|
|
203
196
|
def self.decode_literal_value_incremental_indexing(io, cursor, dynamic_table)
|
|
204
197
|
index, c = Integer.decode(io, 6, cursor)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
name = if index <= STATIC_TABLE_SIZE
|
|
208
|
-
STATIC_TABLE[index - 1][0]
|
|
209
|
-
else
|
|
210
|
-
dynamic_table[index - 1 - STATIC_TABLE_SIZE][0]
|
|
211
|
-
end
|
|
198
|
+
name = dynamic_table.name_at(index)
|
|
212
199
|
value, c = String.decode(io, c)
|
|
213
200
|
dynamic_table.store(name, value)
|
|
214
201
|
|
|
@@ -281,13 +268,7 @@ module Biryani
|
|
|
281
268
|
# @return [Integer]
|
|
282
269
|
def self.decode_literal_value_never_indexed(io, cursor, dynamic_table)
|
|
283
270
|
index, c = Integer.decode(io, 4, cursor)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
name = if index <= STATIC_TABLE_SIZE
|
|
287
|
-
STATIC_TABLE[index - 1][0]
|
|
288
|
-
else
|
|
289
|
-
dynamic_table[index - 1 - STATIC_TABLE_SIZE][0]
|
|
290
|
-
end
|
|
271
|
+
name = dynamic_table.name_at(index)
|
|
291
272
|
value, c = String.decode(io, c)
|
|
292
273
|
|
|
293
274
|
[[name, value], c]
|
|
@@ -336,13 +317,7 @@ module Biryani
|
|
|
336
317
|
# @return [Integer]
|
|
337
318
|
def self.decode_literal_value_without_indexing(io, cursor, dynamic_table)
|
|
338
319
|
index, c = Integer.decode(io, 4, cursor)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
name = if index <= STATIC_TABLE_SIZE
|
|
342
|
-
STATIC_TABLE[index - 1][0]
|
|
343
|
-
else
|
|
344
|
-
dynamic_table[index - 1 - STATIC_TABLE_SIZE][0]
|
|
345
|
-
end
|
|
320
|
+
name = dynamic_table.name_at(index)
|
|
346
321
|
value, c = String.decode(io, c)
|
|
347
322
|
|
|
348
323
|
[[name, value], c]
|
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/stream.rb
CHANGED
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: []
|