tipi 0.30

Sign up to get free protection for your applications and to get access to all the features.
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