spinel-spinner 0.1.0.pre.1
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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/lib/spinner/body_storage.rb +106 -0
- data/lib/spinner/chunk_decoder.rb +72 -0
- data/lib/spinner/host_authority.rb +86 -0
- data/lib/spinner/input.rb +58 -0
- data/lib/spinner/proxy_metadata.rb +93 -0
- data/lib/spinner/reactor.rb +575 -0
- data/lib/spinner/request_parser.rb +183 -0
- data/lib/spinner/response_writer.rb +123 -0
- data/lib/spinner/server.rb +384 -0
- data/lib/spinner/task_pool.rb +62 -0
- data/lib/spinner/thread_pool.rb +229 -0
- data/lib/spinner/tls.rb +116 -0
- data/lib/spinner/transport_socket.rb +121 -0
- data/lib/spinner/upgrade.rb +20 -0
- data/lib/spinner/version.rb +5 -0
- data/lib/spinner/websocket.rb +179 -0
- data/lib/spinner.rb +21 -0
- data/sig/spinner.rbs +4 -0
- metadata +63 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module Spinner
|
|
2
|
+
class RequestError < StandardError
|
|
3
|
+
attr_reader :status
|
|
4
|
+
|
|
5
|
+
def initialize(status, message)
|
|
6
|
+
super(message)
|
|
7
|
+
@status = status
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Incremental framing stays separate from socket reads and application code.
|
|
12
|
+
class RequestParser
|
|
13
|
+
MAX_BODY_BYTES = 1048576
|
|
14
|
+
SUPPORTED_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
15
|
+
|
|
16
|
+
attr_reader :method, :path, :query, :headers, :remainder, :content_length, :protocol
|
|
17
|
+
|
|
18
|
+
def initialize(max_header_bytes = 32768, server_port = "4000", scheme = "http", max_body_bytes = MAX_BODY_BYTES)
|
|
19
|
+
@max_body_bytes = max_body_bytes
|
|
20
|
+
@max_header_bytes = max_header_bytes
|
|
21
|
+
@buffer = "".b
|
|
22
|
+
@complete = false
|
|
23
|
+
@method = ""
|
|
24
|
+
@path = ""
|
|
25
|
+
@query = ""
|
|
26
|
+
@headers = {}
|
|
27
|
+
@remainder = "".b
|
|
28
|
+
@content_length = 0
|
|
29
|
+
@chunked = false
|
|
30
|
+
@expect_continue = false
|
|
31
|
+
@server_name = ""
|
|
32
|
+
@server_port = server_port
|
|
33
|
+
@scheme = scheme
|
|
34
|
+
@protocol = ""
|
|
35
|
+
@http_headers = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def complete?
|
|
39
|
+
@complete
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def chunked?
|
|
43
|
+
@chunked
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expect_continue?
|
|
47
|
+
@expect_continue
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def keep_alive?
|
|
51
|
+
tokens = @headers.fetch("connection", "").downcase.split(",").map(&:strip)
|
|
52
|
+
return false if tokens.include?("close")
|
|
53
|
+
@protocol == "HTTP/1.1" || tokens.include?("keep-alive")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def feed(bytes)
|
|
57
|
+
raise RequestError.new(400, "request already complete") if @complete
|
|
58
|
+
@buffer += bytes
|
|
59
|
+
boundary = @buffer.index("\r\n\r\n")
|
|
60
|
+
if boundary.nil?
|
|
61
|
+
raise RequestError.new(431, "headers too large") if @buffer.bytesize > @max_header_bytes
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
size = boundary + 4
|
|
65
|
+
raise RequestError.new(431, "headers too large") if size > @max_header_bytes
|
|
66
|
+
head = @buffer.byteslice(0, boundary)
|
|
67
|
+
@remainder = @buffer.byteslice(size, @buffer.bytesize - size)
|
|
68
|
+
parse(head)
|
|
69
|
+
@buffer = "".b
|
|
70
|
+
@complete = true
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def environment(body = "".b)
|
|
75
|
+
raise RequestError.new(400, "incomplete headers") unless @complete
|
|
76
|
+
env = {"REQUEST_METHOD" => @method, "SCRIPT_NAME" => "", "PATH_INFO" => @path,
|
|
77
|
+
"QUERY_STRING" => @query, "SERVER_NAME" => @server_name,
|
|
78
|
+
"SERVER_PORT" => @server_port, "SERVER_PROTOCOL" => @protocol,
|
|
79
|
+
"rack.url_scheme" => @scheme, "rack.input" => (body.is_a?(Input) ? body : Input.new(body))}
|
|
80
|
+
env["CONTENT_LENGTH"] = @headers["content-length"] if @headers.key?("content-length")
|
|
81
|
+
env["CONTENT_TYPE"] = @headers["content-type"] if @headers.key?("content-type")
|
|
82
|
+
@http_headers.each { |key, value| env[key] = value }
|
|
83
|
+
env
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse(head)
|
|
87
|
+
lines = head.split("\r\n")
|
|
88
|
+
request = lines.shift.to_s
|
|
89
|
+
unless request.match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+ [^\x00-\x20\x7f-\xff]+ HTTP\/1\.[01]\z/n)
|
|
90
|
+
raise RequestError.new(400, "invalid request line")
|
|
91
|
+
end
|
|
92
|
+
parts = request.split(" ")
|
|
93
|
+
@method = parts[0]
|
|
94
|
+
@protocol = parts[2]
|
|
95
|
+
target = parts[1]
|
|
96
|
+
raise RequestError.new(400, "invalid request target") if target.include?("#")
|
|
97
|
+
@target_authority = nil
|
|
98
|
+
@target_scheme = nil
|
|
99
|
+
if target.start_with?("http://") || target.start_with?("https://")
|
|
100
|
+
match = target.match(/\A(https?):\/\/([^\/\?]+)(.*)\z/)
|
|
101
|
+
raise RequestError.new(400, "invalid absolute target") if match.nil?
|
|
102
|
+
@target_scheme = match[1]
|
|
103
|
+
@target_authority = match[2]
|
|
104
|
+
HostAuthority.new(@target_authority)
|
|
105
|
+
target = match[3]
|
|
106
|
+
target = "/" + target if target.empty? || target.start_with?("?")
|
|
107
|
+
end
|
|
108
|
+
raise RequestError.new(405, "unsupported method") if @method == "CONNECT"
|
|
109
|
+
unless target.start_with?("/") || (target == "*" && @method == "OPTIONS")
|
|
110
|
+
raise RequestError.new(400, "unsupported request target")
|
|
111
|
+
end
|
|
112
|
+
path_and_query = target.split("?", 2)
|
|
113
|
+
@path = path_and_query[0]
|
|
114
|
+
@query = path_and_query.length == 2 ? path_and_query[1] : ""
|
|
115
|
+
|
|
116
|
+
normalized_names = {}
|
|
117
|
+
lines.each do |line|
|
|
118
|
+
pair = line.split(":", 2)
|
|
119
|
+
unless pair.length == 2 && pair[0].match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/)
|
|
120
|
+
raise RequestError.new(400, "invalid header")
|
|
121
|
+
end
|
|
122
|
+
name = pair[0].downcase
|
|
123
|
+
value = pair[1].strip
|
|
124
|
+
if pair[1].match?(/[\x00-\x08\x0a-\x1f\x7f]/)
|
|
125
|
+
raise RequestError.new(400, "invalid or duplicate header")
|
|
126
|
+
end
|
|
127
|
+
normalized = name.upcase.tr("-", "_")
|
|
128
|
+
if normalized_names.key?(normalized) && normalized_names[normalized] != name
|
|
129
|
+
raise RequestError.new(400, "ambiguous header normalization")
|
|
130
|
+
end
|
|
131
|
+
if (normalized == "CONTENT_TYPE" && name != "content-type") ||
|
|
132
|
+
(normalized == "CONTENT_LENGTH" && name != "content-length")
|
|
133
|
+
raise RequestError.new(400, "ambiguous content header")
|
|
134
|
+
end
|
|
135
|
+
normalized_names[normalized] = name
|
|
136
|
+
if @headers.key?(name)
|
|
137
|
+
if ["host", "content-length", "transfer-encoding", "content-type", "expect", "authorization", "sec-websocket-key", "sec-websocket-version"].include?(name)
|
|
138
|
+
raise RequestError.new(400, "duplicate singleton header")
|
|
139
|
+
end
|
|
140
|
+
value = @headers[name] + (name == "cookie" ? "; " : ", ") + value
|
|
141
|
+
end
|
|
142
|
+
@headers[name] = value
|
|
143
|
+
unless name == "content-type" || name == "content-length"
|
|
144
|
+
@http_headers["HTTP_#{normalized}"] = value
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
host = @headers.fetch("host", "")
|
|
148
|
+
if host.empty? && @protocol == "HTTP/1.0"
|
|
149
|
+
@server_name = "localhost"
|
|
150
|
+
else
|
|
151
|
+
@server_name = HostAuthority.new(host).name
|
|
152
|
+
end
|
|
153
|
+
unless @target_authority.nil?
|
|
154
|
+
default_port = @target_scheme == "https" ? "443" : "80"
|
|
155
|
+
canonical_target = @target_authority.downcase.delete_suffix(":" + default_port)
|
|
156
|
+
canonical_host = host.downcase.delete_suffix(":" + default_port)
|
|
157
|
+
raise RequestError.new(400, "conflicting target authority") unless canonical_target == canonical_host
|
|
158
|
+
raise RequestError.new(400, "conflicting target scheme") unless @target_scheme == @scheme
|
|
159
|
+
end
|
|
160
|
+
if @headers.key?("transfer-encoding")
|
|
161
|
+
raise RequestError.new(400, "HTTP/1.0 transfer encoding unsupported") if @protocol != "HTTP/1.1"
|
|
162
|
+
raise RequestError.new(400, "ambiguous message framing") if @headers.key?("content-length")
|
|
163
|
+
raise RequestError.new(400, "unsupported transfer encoding") unless @headers["transfer-encoding"].downcase == "chunked"
|
|
164
|
+
@chunked = true
|
|
165
|
+
end
|
|
166
|
+
length = @headers.fetch("content-length", "0")
|
|
167
|
+
raise RequestError.new(400, "invalid Content-Length") unless length.match?(/\A[0-9]+\z/)
|
|
168
|
+
# Compare decimal strings before conversion; even enormous valid lengths
|
|
169
|
+
# must be rejected without overflowing the compiler's integer type.
|
|
170
|
+
digits = length.sub(/\A0+/, "")
|
|
171
|
+
limit = @max_body_bytes.to_s
|
|
172
|
+
if digits.length > limit.length || (digits.length == limit.length && digits > limit)
|
|
173
|
+
raise RequestError.new(413, "request body exceeds configured limit")
|
|
174
|
+
end
|
|
175
|
+
@content_length = digits.empty? ? 0 : digits.to_i
|
|
176
|
+
raise RequestError.new(405, "unsupported method") unless SUPPORTED_METHODS.include?(@method)
|
|
177
|
+
if @headers.key?("expect")
|
|
178
|
+
raise RequestError.new(417, "expectations unsupported") unless @headers["expect"].downcase == "100-continue" && @protocol == "HTTP/1.1"
|
|
179
|
+
@expect_continue = true
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module Spinner
|
|
2
|
+
class ResponseWriter
|
|
3
|
+
def upgrade_header(response)
|
|
4
|
+
status, headers, body = response
|
|
5
|
+
raise ArgumentError, "expected upgrade response" unless status == 101 && body.is_a?(Upgrade)
|
|
6
|
+
unless headers.is_a?(Hash) && headers["upgrade"].is_a?(String) && headers["upgrade"].match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:\/[!#$%&'*+.^_`|~0-9A-Za-z-]+)?\z/)
|
|
7
|
+
raise ArgumentError, "expected upgrade protocol"
|
|
8
|
+
end
|
|
9
|
+
# Reuse header validation, replacing only server-owned connection framing.
|
|
10
|
+
head = header(200, headers, nil, false, "HTTP/1.1", false)
|
|
11
|
+
head.sub("200 OK", "101 Switching Protocols").sub("connection: close", "connection: Upgrade")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.reason(status)
|
|
15
|
+
reasons = {200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content",
|
|
16
|
+
205 => "Reset Content", 301 => "Moved Permanently", 302 => "Found",
|
|
17
|
+
304 => "Not Modified", 400 => "Bad Request", 404 => "Not Found",
|
|
18
|
+
405 => "Method Not Allowed", 408 => "Request Timeout",
|
|
19
|
+
413 => "Content Too Large", 417 => "Expectation Failed",
|
|
20
|
+
431 => "Request Header Fields Too Large", 500 => "Internal Server Error",
|
|
21
|
+
503 => "Service Unavailable"}
|
|
22
|
+
reasons.fetch(status, "Response")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Validate and serialize before any bytes reach the client.
|
|
26
|
+
def prepare(response, keep_alive = false, protocol = "HTTP/1.1", head_request = false)
|
|
27
|
+
unless response.is_a?(Array) && response.length == 3
|
|
28
|
+
raise ArgumentError, "expected [status, headers, body]"
|
|
29
|
+
end
|
|
30
|
+
status, headers, body = response
|
|
31
|
+
unless status.is_a?(Integer) && status >= 200 && status <= 599
|
|
32
|
+
raise ArgumentError, "expected a final HTTP status"
|
|
33
|
+
end
|
|
34
|
+
raise ArgumentError, "expected header hash" unless headers.is_a?(Hash)
|
|
35
|
+
raise ArgumentError, "expected body array" unless body.is_a?(Array)
|
|
36
|
+
length = 0
|
|
37
|
+
body.each do |chunk|
|
|
38
|
+
raise ArgumentError, "expected string body chunks" unless chunk.is_a?(String)
|
|
39
|
+
length += chunk.bytesize
|
|
40
|
+
end
|
|
41
|
+
if [204, 205, 304].include?(status) && length != 0
|
|
42
|
+
raise ArgumentError, "status requires an empty body"
|
|
43
|
+
end
|
|
44
|
+
head = header(status, headers, length, keep_alive, protocol, false)
|
|
45
|
+
chunks = [head]
|
|
46
|
+
body.each { |chunk| chunks << chunk } unless head_request
|
|
47
|
+
chunks
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def prepare_stream(response, keep_alive = false, protocol = "HTTP/1.1")
|
|
51
|
+
unless response.is_a?(Array) && response.length == 3
|
|
52
|
+
raise ArgumentError, "expected [status, headers, body]"
|
|
53
|
+
end
|
|
54
|
+
status, headers, body = response
|
|
55
|
+
raise ArgumentError, "expected enumerable body" unless body.respond_to?(:each)
|
|
56
|
+
raise ArgumentError, "bodyless status cannot stream" if [204, 205, 304].include?(status)
|
|
57
|
+
chunked = protocol == "HTTP/1.1"
|
|
58
|
+
[header(status, headers, nil, keep_alive && chunked, protocol, chunked), chunked]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def header(status, headers, length, keep_alive, protocol, chunked)
|
|
62
|
+
unless status.is_a?(Integer) && status >= 200 && status <= 599
|
|
63
|
+
raise ArgumentError, "expected a final HTTP status"
|
|
64
|
+
end
|
|
65
|
+
raise ArgumentError, "expected header hash" unless headers.is_a?(Hash)
|
|
66
|
+
head = "#{protocol} #{status} #{ResponseWriter.reason(status)}\r\n"
|
|
67
|
+
headers.each do |name, value|
|
|
68
|
+
unless name.is_a?(String) && name.match?(/\A[!#$%&'*+.^_`|~0-9a-z-]+\z/)
|
|
69
|
+
raise ArgumentError, "expected lowercase header names"
|
|
70
|
+
end
|
|
71
|
+
values = value.is_a?(Array) ? value : [value]
|
|
72
|
+
values.each do |field|
|
|
73
|
+
raise ArgumentError, "invalid header value" unless field.is_a?(String) && !field.match?(/[\x00-\x08\x0a-\x1f\x7f]/)
|
|
74
|
+
end
|
|
75
|
+
raise ArgumentError, "transfer encoding unsupported" if name == "transfer-encoding"
|
|
76
|
+
next if name == "content-length" || name == "connection"
|
|
77
|
+
values.each { |field| head += "#{name}: #{field}\r\n" }
|
|
78
|
+
end
|
|
79
|
+
head += "transfer-encoding: chunked\r\n" if chunked
|
|
80
|
+
head += "content-length: #{length}\r\n" if !length.nil? && status != 204 && status != 304
|
|
81
|
+
head += "allow: #{RequestParser::SUPPORTED_METHODS.join(', ')}\r\n" if status == 405 && !headers.key?("allow")
|
|
82
|
+
head += "connection: #{keep_alive ? 'keep-alive' : 'close'}\r\n\r\n"
|
|
83
|
+
head
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_stream(transport, response, deadline, head, chunked, head_request = false, idle_timeout = 30.0, client = nil)
|
|
87
|
+
body = response[2]
|
|
88
|
+
begin
|
|
89
|
+
write(transport, [head], deadline)
|
|
90
|
+
unless head_request
|
|
91
|
+
body.each do |part|
|
|
92
|
+
raise ArgumentError, "expected string body chunks" unless part.is_a?(String)
|
|
93
|
+
active_deadline = client.nil? ? deadline : client.stream_deadline
|
|
94
|
+
raise IOError, "stream idle timeout" if Transport.now >= active_deadline
|
|
95
|
+
next if part.empty?
|
|
96
|
+
deadline = Transport.now + idle_timeout
|
|
97
|
+
client.stream_deadline = deadline unless client.nil?
|
|
98
|
+
if chunked
|
|
99
|
+
write(transport, ["#{part.bytesize.to_s(16)}\r\n", part, "\r\n"], deadline)
|
|
100
|
+
else
|
|
101
|
+
write(transport, [part], deadline)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
write(transport, ["0\r\n\r\n"], deadline) if chunked
|
|
105
|
+
end
|
|
106
|
+
ensure
|
|
107
|
+
body.close if body.respond_to?(:close)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write(transport, chunks, deadline)
|
|
112
|
+
chunks.each do |chunk|
|
|
113
|
+
offset = 0
|
|
114
|
+
while offset < chunk.bytesize
|
|
115
|
+
remaining = chunk.byteslice(offset, chunk.bytesize - offset)
|
|
116
|
+
count = transport.write_some(remaining, deadline)
|
|
117
|
+
raise IOError, "invalid write progress" if count <= 0 || count > remaining.bytesize
|
|
118
|
+
offset += count
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|