tipi 0.30
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/.github/workflows/test.yml +27 -0
- data/.gitignore +56 -0
- data/CHANGELOG.md +33 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +50 -0
- data/LICENSE +21 -0
- data/README.md +23 -0
- data/Rakefile +12 -0
- data/TODO.md +66 -0
- data/bin/tipi +12 -0
- data/docs/README.md +62 -0
- data/docs/summary.md +60 -0
- data/examples/cuba.ru +23 -0
- data/examples/hanami-api.ru +23 -0
- data/examples/http_server.js +24 -0
- data/examples/http_server.rb +21 -0
- data/examples/http_server_forked.rb +29 -0
- data/examples/http_server_graceful.rb +27 -0
- data/examples/http_server_simple.rb +11 -0
- data/examples/http_server_throttled.rb +15 -0
- data/examples/http_server_timeout.rb +35 -0
- data/examples/http_ws_server.rb +37 -0
- data/examples/https_server.rb +24 -0
- data/examples/https_server_forked.rb +32 -0
- data/examples/https_wss_server.rb +39 -0
- data/examples/rack_server.rb +12 -0
- data/examples/rack_server_https.rb +19 -0
- data/examples/rack_server_https_forked.rb +27 -0
- data/examples/websocket_secure_server.rb +27 -0
- data/examples/websocket_server.rb +24 -0
- data/examples/ws_page.html +34 -0
- data/examples/wss_page.html +34 -0
- data/lib/tipi.rb +54 -0
- data/lib/tipi/http1_adapter.rb +268 -0
- data/lib/tipi/http2_adapter.rb +74 -0
- data/lib/tipi/http2_stream.rb +134 -0
- data/lib/tipi/rack_adapter.rb +67 -0
- data/lib/tipi/request.rb +118 -0
- data/lib/tipi/version.rb +5 -0
- data/lib/tipi/websocket.rb +61 -0
- data/test/coverage.rb +45 -0
- data/test/eg.rb +27 -0
- data/test/helper.rb +51 -0
- data/test/run.rb +5 -0
- data/test/test_http_server.rb +321 -0
- data/tipi.gemspec +34 -0
- metadata +241 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'http/2'
|
4
|
+
require_relative './request'
|
5
|
+
|
6
|
+
module Tipi
|
7
|
+
# Manages an HTTP 2 stream
|
8
|
+
class HTTP2StreamHandler
|
9
|
+
attr_accessor :__next__
|
10
|
+
|
11
|
+
def initialize(stream, &block)
|
12
|
+
@stream = stream
|
13
|
+
@connection_fiber = Fiber.current
|
14
|
+
@stream_fiber = spin { |req| handle_request(req, &block) }
|
15
|
+
|
16
|
+
# Stream callbacks occur on the connection fiber (see HTTP2Adapter#each).
|
17
|
+
# The request handler is run on a separate fiber for each stream, allowing
|
18
|
+
# concurrent handling of incoming requests on the same HTTP/2 connection.
|
19
|
+
#
|
20
|
+
# The different stream adapter APIs suspend the stream fiber, waiting for
|
21
|
+
# stream callbacks to be called. The callbacks, in turn, transfer control to
|
22
|
+
# the stream fiber, effectively causing the return of the adapter API calls.
|
23
|
+
#
|
24
|
+
# Note: the request handler is run once headers are received. Reading the
|
25
|
+
# request body, if present, is at the discretion of the request handler.
|
26
|
+
# This mirrors the behaviour of the HTTP/1 adapter.
|
27
|
+
stream.on(:headers, &method(:on_headers))
|
28
|
+
stream.on(:data, &method(:on_data))
|
29
|
+
stream.on(:half_close, &method(:on_half_close))
|
30
|
+
end
|
31
|
+
|
32
|
+
def handle_request(request, &block)
|
33
|
+
error = nil
|
34
|
+
block.(request)
|
35
|
+
@connection_fiber.schedule
|
36
|
+
rescue Polyphony::MoveOn
|
37
|
+
# ignore
|
38
|
+
rescue Exception => e
|
39
|
+
error = e
|
40
|
+
ensure
|
41
|
+
@done = true
|
42
|
+
@connection_fiber.schedule error
|
43
|
+
end
|
44
|
+
|
45
|
+
def on_headers(headers)
|
46
|
+
@request = Request.new(headers.to_h, self)
|
47
|
+
@stream_fiber.schedule @request
|
48
|
+
end
|
49
|
+
|
50
|
+
def on_data(data)
|
51
|
+
if @waiting_for_body_chunk
|
52
|
+
@waiting_for_body_chunk = nil
|
53
|
+
@stream_fiber.schedule data
|
54
|
+
else
|
55
|
+
@request.buffer_body_chunk(data)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def on_half_close
|
60
|
+
if @waiting_for_body_chunk
|
61
|
+
@waiting_for_body_chunk = nil
|
62
|
+
@stream_fiber.schedule
|
63
|
+
elsif @waiting_for_half_close
|
64
|
+
@waiting_for_half_close = nil
|
65
|
+
@stream_fiber.schedule
|
66
|
+
else
|
67
|
+
@request.complete!
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def protocol
|
72
|
+
'h2'
|
73
|
+
end
|
74
|
+
|
75
|
+
def get_body_chunk
|
76
|
+
# called in the context of the stream fiber
|
77
|
+
return nil if @request.complete?
|
78
|
+
|
79
|
+
@waiting_for_body_chunk = true
|
80
|
+
# the chunk (or an exception) will be returned once the stream fiber is
|
81
|
+
# resumed
|
82
|
+
suspend
|
83
|
+
ensure
|
84
|
+
@waiting_for_body_chunk = nil
|
85
|
+
end
|
86
|
+
|
87
|
+
# Wait for request to finish
|
88
|
+
def consume_request
|
89
|
+
return if @request.complete?
|
90
|
+
|
91
|
+
@waiting_for_half_close = true
|
92
|
+
suspend
|
93
|
+
ensure
|
94
|
+
@waiting_for_half_close = nil
|
95
|
+
end
|
96
|
+
|
97
|
+
# response API
|
98
|
+
def respond(chunk, headers)
|
99
|
+
headers[':status'] ||= '200'
|
100
|
+
@stream.headers(headers, end_stream: false)
|
101
|
+
@stream.data(chunk, end_stream: true)
|
102
|
+
@headers_sent = true
|
103
|
+
end
|
104
|
+
|
105
|
+
def send_headers(headers, empty_response = false)
|
106
|
+
return if @headers_sent
|
107
|
+
|
108
|
+
headers[':status'] ||= (empty_response ? 204 : 200).to_s
|
109
|
+
@stream.headers(headers, end_stream: false)
|
110
|
+
@headers_sent = true
|
111
|
+
end
|
112
|
+
|
113
|
+
def send_chunk(chunk, done: false)
|
114
|
+
send_headers({}, false) unless @headers_sent
|
115
|
+
@stream.data(chunk, end_stream: done)
|
116
|
+
end
|
117
|
+
|
118
|
+
def finish
|
119
|
+
if @headers_sent
|
120
|
+
@stream.close
|
121
|
+
else
|
122
|
+
headers[':status'] ||= '204'
|
123
|
+
@stream.headers(headers, end_stream: true)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def stop
|
128
|
+
return if @done
|
129
|
+
|
130
|
+
@stream.close
|
131
|
+
@stream_fiber.schedule(Polyphony::MoveOn.new)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack'
|
4
|
+
|
5
|
+
module Tipi
|
6
|
+
module RackAdapter
|
7
|
+
# Implements a rack input stream:
|
8
|
+
# https://www.rubydoc.info/github/rack/rack/master/file/SPEC#label-The+Input+Stream
|
9
|
+
class InputStream
|
10
|
+
def initialize(request)
|
11
|
+
@request = request
|
12
|
+
end
|
13
|
+
|
14
|
+
def gets; end
|
15
|
+
|
16
|
+
def read(length = nil, outbuf = nil); end
|
17
|
+
|
18
|
+
def each(&block)
|
19
|
+
@request.each_chunk(&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def rewind; end
|
23
|
+
end
|
24
|
+
|
25
|
+
class << self
|
26
|
+
def run(app)
|
27
|
+
->(req) { respond(req, app.(env(req))) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def load(path)
|
31
|
+
src = IO.read(path)
|
32
|
+
instance_eval(src, path, 1)
|
33
|
+
end
|
34
|
+
|
35
|
+
def env(request)
|
36
|
+
{
|
37
|
+
'REQUEST_METHOD' => request.method,
|
38
|
+
'SCRIPT_NAME' => '',
|
39
|
+
'PATH_INFO' => request.path,
|
40
|
+
'QUERY_STRING' => request.query_string || '',
|
41
|
+
'SERVER_NAME' => request.headers['Host'], # ?
|
42
|
+
'SERVER_PORT' => '80', # ?
|
43
|
+
'rack.version' => Rack::VERSION,
|
44
|
+
'rack.url_scheme' => 'https', # ?
|
45
|
+
'rack.input' => InputStream.new(request),
|
46
|
+
'rack.errors' => STDERR, # ?
|
47
|
+
'rack.multithread' => false,
|
48
|
+
'rack.run_once' => false,
|
49
|
+
'rack.hijack?' => false,
|
50
|
+
'rack.hijack' => nil,
|
51
|
+
'rack.hijack_io' => nil,
|
52
|
+
'rack.session' => nil,
|
53
|
+
'rack.logger' => nil,
|
54
|
+
'rack.multipart.buffer_size' => nil,
|
55
|
+
'rack.multipar.tempfile_factory' => nil
|
56
|
+
}.tap do |env|
|
57
|
+
request.headers.each { |k, v| env["HTTP_#{k.upcase}"] = v }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def respond(request, (status_code, headers, body))
|
62
|
+
headers[':status'] = status_code.to_s
|
63
|
+
request.respond(body.first, headers)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/tipi/request.rb
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module Tipi
|
6
|
+
# HTTP request
|
7
|
+
class Request
|
8
|
+
attr_reader :headers, :adapter
|
9
|
+
attr_accessor :__next__
|
10
|
+
|
11
|
+
def initialize(headers, adapter)
|
12
|
+
@headers = headers
|
13
|
+
@adapter = adapter
|
14
|
+
end
|
15
|
+
|
16
|
+
def protocol
|
17
|
+
@protocol = @adapter.protocol
|
18
|
+
end
|
19
|
+
|
20
|
+
def method
|
21
|
+
@method ||= @headers[':method']
|
22
|
+
end
|
23
|
+
|
24
|
+
def scheme
|
25
|
+
@scheme ||= @headers[':scheme']
|
26
|
+
end
|
27
|
+
|
28
|
+
def uri
|
29
|
+
@uri ||= URI.parse(@headers[':path'] || '')
|
30
|
+
end
|
31
|
+
|
32
|
+
def path
|
33
|
+
@path ||= uri.path
|
34
|
+
end
|
35
|
+
|
36
|
+
def query_string
|
37
|
+
@query_string ||= uri.query
|
38
|
+
end
|
39
|
+
|
40
|
+
def query
|
41
|
+
return @query if @query
|
42
|
+
|
43
|
+
@query = (q = uri.query) ? split_query_string(q) : {}
|
44
|
+
end
|
45
|
+
|
46
|
+
def split_query_string(query)
|
47
|
+
query.split('&').each_with_object({}) do |kv, h|
|
48
|
+
k, v = kv.split('=')
|
49
|
+
h[k.to_sym] = URI.decode_www_form_component(v)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def buffer_body_chunk(chunk)
|
54
|
+
@buffered_body_chunks ||= []
|
55
|
+
@buffered_body_chunks << chunk
|
56
|
+
end
|
57
|
+
|
58
|
+
def each_chunk(&block)
|
59
|
+
if @buffered_body_chunks
|
60
|
+
@buffered_body_chunks.each(&block)
|
61
|
+
@buffered_body_chunks = nil
|
62
|
+
end
|
63
|
+
while !@message_complete && (chunk = @adapter.get_body_chunk)
|
64
|
+
yield chunk
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def complete!(keep_alive = nil)
|
69
|
+
@message_complete = true
|
70
|
+
@keep_alive = keep_alive
|
71
|
+
end
|
72
|
+
|
73
|
+
def complete?
|
74
|
+
@message_complete
|
75
|
+
end
|
76
|
+
|
77
|
+
def consume
|
78
|
+
@adapter.consume_request
|
79
|
+
end
|
80
|
+
|
81
|
+
def keep_alive?
|
82
|
+
@keep_alive
|
83
|
+
end
|
84
|
+
|
85
|
+
def read
|
86
|
+
buf = @buffered_body_chunks ? @buffered_body_chunks.join : +''
|
87
|
+
while (chunk = @adapter.get_body_chunk)
|
88
|
+
buf << chunk
|
89
|
+
end
|
90
|
+
buf
|
91
|
+
end
|
92
|
+
|
93
|
+
def respond(body, headers = {})
|
94
|
+
@adapter.respond(body, headers)
|
95
|
+
@headers_sent = true
|
96
|
+
end
|
97
|
+
|
98
|
+
def send_headers(headers = {}, empty_response = false)
|
99
|
+
return if @headers_sent
|
100
|
+
|
101
|
+
@headers_sent = true
|
102
|
+
@adapter.send_headers(headers, empty_response: empty_response)
|
103
|
+
end
|
104
|
+
|
105
|
+
def send_chunk(body, done: false)
|
106
|
+
send_headers({}) unless @headers_sent
|
107
|
+
|
108
|
+
@adapter.send_chunk(body, done: done)
|
109
|
+
end
|
110
|
+
alias_method :<<, :send_chunk
|
111
|
+
|
112
|
+
def finish
|
113
|
+
send_headers({}) unless @headers_sent
|
114
|
+
|
115
|
+
@adapter.finish
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
data/lib/tipi/version.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
export :handler
|
4
|
+
|
5
|
+
require 'digest/sha1'
|
6
|
+
require 'websocket'
|
7
|
+
|
8
|
+
module Tipi
|
9
|
+
# Websocket connection
|
10
|
+
class Websocket
|
11
|
+
def self.handler(&block)
|
12
|
+
proc { |client, header|
|
13
|
+
block.(new(client, header))
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(client, headers)
|
18
|
+
@client = client
|
19
|
+
@headers = headers
|
20
|
+
setup(headers)
|
21
|
+
end
|
22
|
+
|
23
|
+
S_WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
24
|
+
UPGRADE_RESPONSE = <<~HTTP.gsub("\n", "\r\n")
|
25
|
+
HTTP/1.1 101 Switching Protocols
|
26
|
+
Upgrade: websocket
|
27
|
+
Connection: Upgrade
|
28
|
+
Sec-WebSocket-Accept: %<accept>s
|
29
|
+
|
30
|
+
HTTP
|
31
|
+
|
32
|
+
def setup(headers)
|
33
|
+
key = headers['Sec-WebSocket-Key']
|
34
|
+
@version = headers['Sec-WebSocket-Version'].to_i
|
35
|
+
accept = Digest::SHA1.base64digest([key, S_WS_GUID].join)
|
36
|
+
@client << format(UPGRADE_RESPONSE, accept: accept)
|
37
|
+
|
38
|
+
@reader = ::WebSocket::Frame::Incoming::Server.new(version: @version)
|
39
|
+
end
|
40
|
+
|
41
|
+
def recv
|
42
|
+
loop do
|
43
|
+
data = @client.readpartial(8192)
|
44
|
+
break nil unless data
|
45
|
+
|
46
|
+
@reader << data
|
47
|
+
if (msg = @reader.next)
|
48
|
+
break msg.to_s
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def send(data)
|
54
|
+
frame = ::WebSocket::Frame::Outgoing::Server.new(
|
55
|
+
version: @version, data: data, type: :text
|
56
|
+
)
|
57
|
+
@client << frame.to_s
|
58
|
+
end
|
59
|
+
alias_method :<<, :send
|
60
|
+
end
|
61
|
+
end
|
data/test/coverage.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'coverage'
|
4
|
+
require 'simplecov'
|
5
|
+
|
6
|
+
class << SimpleCov::LinesClassifier
|
7
|
+
alias_method :orig_whitespace_line?, :whitespace_line?
|
8
|
+
def whitespace_line?(line)
|
9
|
+
line.strip =~ /^(begin|end|ensure|else|\})|(\s*rescue\s.+)$/ || orig_whitespace_line?(line)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module Coverage
|
14
|
+
EXCLUDE = %w{coverage eg helper run
|
15
|
+
}.map { |n| File.expand_path("test/#{n}.rb") }
|
16
|
+
|
17
|
+
LIB_FILES = Dir["#{File.join(FileUtils.pwd, 'lib')}/polyphony/**/*.rb"]
|
18
|
+
|
19
|
+
class << self
|
20
|
+
def relevant_lines_for_filename(filename)
|
21
|
+
@classifier ||= SimpleCov::LinesClassifier.new
|
22
|
+
@classifier.classify(IO.read(filename).lines)
|
23
|
+
end
|
24
|
+
|
25
|
+
def start
|
26
|
+
@result = {}
|
27
|
+
trace = TracePoint.new(:line) do |tp|
|
28
|
+
next if tp.path =~ /\(/
|
29
|
+
|
30
|
+
absolute = File.expand_path(tp.path)
|
31
|
+
next unless LIB_FILES.include?(absolute)# =~ /^#{LIB_DIR}/
|
32
|
+
|
33
|
+
@result[absolute] ||= relevant_lines_for_filename(absolute)
|
34
|
+
@result[absolute][tp.lineno - 1] = 1
|
35
|
+
end
|
36
|
+
trace.enable
|
37
|
+
end
|
38
|
+
|
39
|
+
def result
|
40
|
+
@result
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
SimpleCov.start
|
data/test/eg.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kernel
|
4
|
+
RE_CONST = /^[A-Z]/.freeze
|
5
|
+
RE_ATTR = /^@(.+)$/.freeze
|
6
|
+
|
7
|
+
def eg(hash)
|
8
|
+
Module.new.tap do |m|
|
9
|
+
s = m.singleton_class
|
10
|
+
hash.each do |k, v|
|
11
|
+
case k
|
12
|
+
when RE_CONST
|
13
|
+
m.const_set(k, v)
|
14
|
+
when RE_ATTR
|
15
|
+
m.instance_variable_set(k, v)
|
16
|
+
else
|
17
|
+
block = if v.respond_to?(:to_proc)
|
18
|
+
proc { |*args, &block| instance_exec { v.(*args, &block) } }
|
19
|
+
else
|
20
|
+
proc { v }
|
21
|
+
end
|
22
|
+
s.define_method(k, &block)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|