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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +27 -0
  3. data/.gitignore +56 -0
  4. data/CHANGELOG.md +33 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +50 -0
  7. data/LICENSE +21 -0
  8. data/README.md +23 -0
  9. data/Rakefile +12 -0
  10. data/TODO.md +66 -0
  11. data/bin/tipi +12 -0
  12. data/docs/README.md +62 -0
  13. data/docs/summary.md +60 -0
  14. data/examples/cuba.ru +23 -0
  15. data/examples/hanami-api.ru +23 -0
  16. data/examples/http_server.js +24 -0
  17. data/examples/http_server.rb +21 -0
  18. data/examples/http_server_forked.rb +29 -0
  19. data/examples/http_server_graceful.rb +27 -0
  20. data/examples/http_server_simple.rb +11 -0
  21. data/examples/http_server_throttled.rb +15 -0
  22. data/examples/http_server_timeout.rb +35 -0
  23. data/examples/http_ws_server.rb +37 -0
  24. data/examples/https_server.rb +24 -0
  25. data/examples/https_server_forked.rb +32 -0
  26. data/examples/https_wss_server.rb +39 -0
  27. data/examples/rack_server.rb +12 -0
  28. data/examples/rack_server_https.rb +19 -0
  29. data/examples/rack_server_https_forked.rb +27 -0
  30. data/examples/websocket_secure_server.rb +27 -0
  31. data/examples/websocket_server.rb +24 -0
  32. data/examples/ws_page.html +34 -0
  33. data/examples/wss_page.html +34 -0
  34. data/lib/tipi.rb +54 -0
  35. data/lib/tipi/http1_adapter.rb +268 -0
  36. data/lib/tipi/http2_adapter.rb +74 -0
  37. data/lib/tipi/http2_stream.rb +134 -0
  38. data/lib/tipi/rack_adapter.rb +67 -0
  39. data/lib/tipi/request.rb +118 -0
  40. data/lib/tipi/version.rb +5 -0
  41. data/lib/tipi/websocket.rb +61 -0
  42. data/test/coverage.rb +45 -0
  43. data/test/eg.rb +27 -0
  44. data/test/helper.rb +51 -0
  45. data/test/run.rb +5 -0
  46. data/test/test_http_server.rb +321 -0
  47. data/tipi.gemspec +34 -0
  48. 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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tipi
4
+ VERSION = '0.30'
5
+ end
@@ -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
@@ -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
@@ -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