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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'tipi'
5
+
6
+ def ws_handler(conn)
7
+ while (msg = conn.recv)
8
+ conn << "you said: #{msg}"
9
+ end
10
+ end
11
+
12
+ opts = {
13
+ reuse_addr: true,
14
+ dont_linger: true,
15
+ upgrade: {
16
+ websocket: Polyphony::Websocket.handler(&method(:ws_handler))
17
+ }
18
+ }
19
+
20
+ puts "pid: #{Process.pid}"
21
+ puts 'Listening on port 1234...'
22
+ Tipi.serve('0.0.0.0', 1234, opts) do |req|
23
+ req.respond("Hello world!\n")
24
+ end
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>Websocket Client</title>
5
+ </head>
6
+ <body>
7
+ <script>
8
+ var connect = function () {
9
+ var exampleSocket = new WebSocket("ws://localhost:1234");
10
+
11
+ exampleSocket.onopen = function (event) {
12
+ document.querySelector('#status').innerText = 'connected';
13
+ exampleSocket.send("Can you hear me?");
14
+ };
15
+ exampleSocket.onclose = function (event) {
16
+ console.log('onclose');
17
+ document.querySelector('#status').innerText = 'disconnected';
18
+ setTimeout(function () {
19
+ // exampleSocket.removeAllListeners();
20
+ connect();
21
+ }, 1000);
22
+ }
23
+ exampleSocket.onmessage = function (event) {
24
+ document.querySelector('#msg').innerText = event.data;
25
+ console.log(event.data);
26
+ }
27
+ };
28
+
29
+ connect();
30
+ </script>
31
+ <h1 id="status">disconnected</h1>
32
+ <h1 id="msg"></h1>
33
+ </body>
34
+ </html>
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>Websocket Client</title>
5
+ </head>
6
+ <body>
7
+ <script>
8
+ var connect = function () {
9
+ var exampleSocket = new WebSocket("/");
10
+
11
+ exampleSocket.onopen = function (event) {
12
+ document.querySelector('#status').innerText = 'connected';
13
+ exampleSocket.send("Can you hear me?");
14
+ };
15
+ exampleSocket.onclose = function (event) {
16
+ console.log('onclose');
17
+ document.querySelector('#status').innerText = 'disconnected';
18
+ setTimeout(function () {
19
+ // exampleSocket.removeAllListeners();
20
+ connect();
21
+ }, 1000);
22
+ }
23
+ exampleSocket.onmessage = function (event) {
24
+ document.querySelector('#msg').innerText = event.data;
25
+ console.log(event.data);
26
+ }
27
+ };
28
+
29
+ connect();
30
+ </script>
31
+ <h1 id="status">disconnected</h1>
32
+ <h1 id="msg"></h1>
33
+ </body>
34
+ </html>
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'polyphony'
4
+ require_relative './tipi/http1_adapter'
5
+ require_relative './tipi/http2_adapter'
6
+
7
+ module Tipi
8
+ ALPN_PROTOCOLS = %w[h2 http/1.1].freeze
9
+ H2_PROTOCOL = 'h2'
10
+
11
+ class << self
12
+ def serve(host, port, opts = {}, &handler)
13
+ opts[:alpn_protocols] = ALPN_PROTOCOLS
14
+ server = Polyphony::Net.tcp_listen(host, port, opts)
15
+ accept_loop(server, opts, &handler)
16
+ ensure
17
+ server&.close
18
+ end
19
+
20
+ def listen(host, port, opts = {})
21
+ opts[:alpn_protocols] = ALPN_PROTOCOLS
22
+ Polyphony::Net.tcp_listen(host, port, opts).tap do |socket|
23
+ socket.define_singleton_method(:each) do |&block|
24
+ ::Tipi.accept_loop(socket, opts, &block)
25
+ end
26
+ end
27
+ end
28
+
29
+ def accept_loop(server, opts, &handler)
30
+ loop do
31
+ client = server.accept
32
+ spin { client_loop(client, opts, &handler) }
33
+ snooze
34
+ rescue OpenSSL::SSL::SSLError
35
+ # disregard
36
+ end
37
+ end
38
+
39
+ def client_loop(client, opts, &handler)
40
+ client.no_delay if client.respond_to?(:no_delay)
41
+ adapter = protocol_adapter(client, opts)
42
+ adapter.each(&handler)
43
+ ensure
44
+ client.close
45
+ end
46
+
47
+ def protocol_adapter(socket, opts)
48
+ use_http2 = socket.respond_to?(:alpn_protocol) &&
49
+ socket.alpn_protocol == H2_PROTOCOL
50
+ klass = use_http2 ? HTTP2Adapter : HTTP1Adapter
51
+ klass.new(socket, opts)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http/parser'
4
+ require_relative './request'
5
+ require_relative './http2_adapter'
6
+
7
+ module Tipi
8
+ # HTTP1 protocol implementation
9
+ class HTTP1Adapter
10
+ # Initializes a protocol adapter instance
11
+ def initialize(conn, opts)
12
+ @conn = conn
13
+ @opts = opts
14
+ @parser = ::HTTP::Parser.new(self)
15
+ end
16
+
17
+ def each(&block)
18
+ @conn.read_loop do |data|
19
+ return if handle_incoming_data(data, &block)
20
+ end
21
+ rescue SystemCallError, IOError
22
+ # ignore
23
+ ensure
24
+ finalize_client_loop
25
+ end
26
+
27
+ # return [Boolean] true if client loop should stop
28
+ def handle_incoming_data(data, &block)
29
+ @parser << data
30
+ while (request = @requests_head)
31
+ return true if upgrade_connection(request.headers, &block)
32
+
33
+ @requests_head = request.__next__
34
+ block.call(request)
35
+ return true unless request.keep_alive?
36
+ end
37
+ nil
38
+ end
39
+
40
+ def finalize_client_loop
41
+ # release references to various objects
42
+ @requests_head = @requests_tail = nil
43
+ @parser = nil
44
+ @conn.close
45
+ end
46
+
47
+ # Reads a body chunk for the current request. Transfers control to the parse
48
+ # loop, and resumes once the parse_loop has fired the on_body callback
49
+ def get_body_chunk
50
+ @waiting_for_body_chunk = true
51
+ @next_chunk = nil
52
+ while !@requests_tail.complete? && (data = @conn.readpartial(8192))
53
+ @parser << data
54
+ return @next_chunk if @next_chunk
55
+
56
+ snooze
57
+ end
58
+ nil
59
+ ensure
60
+ @waiting_for_body_chunk = nil
61
+ end
62
+
63
+ # Waits for the current request to complete. Transfers control to the parse
64
+ # loop, and resumes once the parse_loop has fired the on_message_complete
65
+ # callback
66
+ def consume_request
67
+ request = @requests_head
68
+ loop do
69
+ data = @conn.readpartial(8192)
70
+ @parser << data
71
+ return if request.complete?
72
+
73
+ snooze
74
+ rescue EOFError
75
+ break
76
+ end
77
+ end
78
+
79
+ def protocol
80
+ version = @parser.http_version
81
+ "HTTP #{version.join('.')}"
82
+ end
83
+
84
+ def on_headers_complete(headers)
85
+ headers[':path'] = @parser.request_url
86
+ headers[':method'] = @parser.http_method
87
+ queue_request(Request.new(headers, self))
88
+ end
89
+
90
+ def queue_request(request)
91
+ if @requests_head
92
+ @requests_tail.__next__ = request
93
+ @requests_tail = request
94
+ else
95
+ @requests_head = @requests_tail = request
96
+ end
97
+ end
98
+
99
+ def on_body(chunk)
100
+ if @waiting_for_body_chunk
101
+ @next_chunk = chunk
102
+ @waiting_for_body_chunk = nil
103
+ else
104
+ @requests_tail.buffer_body_chunk(chunk)
105
+ end
106
+ end
107
+
108
+ def on_message_complete
109
+ @waiting_for_body_chunk = nil
110
+ @requests_tail.complete!(@parser.keep_alive?)
111
+ end
112
+
113
+ # Upgrades the connection to a different protocol, if the 'Upgrade' header is
114
+ # given. By default the only supported upgrade protocol is HTTP2. Additional
115
+ # protocols, notably WebSocket, can be specified by passing a hash to the
116
+ # :upgrade option when starting a server:
117
+ #
118
+ # opts = {
119
+ # upgrade: {
120
+ # websocket: Tipi::Websocket.handler(&method(:ws_handler))
121
+ # }
122
+ # }
123
+ # Tipi.serve('0.0.0.0', 1234, opts) { |req| ... }
124
+ #
125
+ # @param headers [Hash] request headers
126
+ # @return [boolean] truthy if the connection has been upgraded
127
+ def upgrade_connection(headers, &block)
128
+ upgrade_protocol = headers['Upgrade']
129
+ return nil unless upgrade_protocol
130
+
131
+ upgrade_protocol = upgrade_protocol.downcase.to_sym
132
+ upgrade_handler = @opts[:upgrade] && @opts[:upgrade][upgrade_protocol]
133
+ return upgrade_with_handler(upgrade_handler, headers) if upgrade_handler
134
+ return upgrade_to_http2(headers, &block) if upgrade_protocol == :h2c
135
+
136
+ nil
137
+ end
138
+
139
+ def upgrade_with_handler(handler, headers)
140
+ @parser = @requests_head = @requests_tail = nil
141
+ handler.(@conn, headers)
142
+ true
143
+ end
144
+
145
+ def upgrade_to_http2(headers, &block)
146
+ @parser = @requests_head = @requests_tail = nil
147
+ HTTP2Adapter.upgrade_each(@conn, @opts, http2_upgraded_headers(headers), &block)
148
+ true
149
+ end
150
+
151
+ # Returns headers for HTTP2 upgrade
152
+ # @param headers [Hash] request headers
153
+ # @return [Hash] headers for HTTP2 upgrade
154
+ def http2_upgraded_headers(headers)
155
+ headers.merge(
156
+ ':scheme' => 'http',
157
+ ':authority' => headers['Host']
158
+ )
159
+ end
160
+
161
+ # response API
162
+
163
+ # Sends response including headers and body. Waits for the request to complete
164
+ # if not yet completed. The body is sent using chunked transfer encoding.
165
+ # @param body [String] response body
166
+ # @param headers
167
+ def respond(body, headers)
168
+ consume_request if @parsing
169
+ data = format_headers(headers, body)
170
+ if body
171
+ data << if @parser.http_minor == 0
172
+ body
173
+ else
174
+ "#{body.bytesize.to_s(16)}\r\n#{body}\r\n0\r\n\r\n"
175
+ end
176
+ end
177
+ @conn << data
178
+ end
179
+
180
+ DEFAULT_HEADERS_OPTS = {
181
+ empty_response: false,
182
+ consume_request: true
183
+ }.freeze
184
+
185
+ # Sends response headers. If empty_response is truthy, the response status
186
+ # code will default to 204, otherwise to 200.
187
+ # @param headers [Hash] response headers
188
+ # @param empty_response [boolean] whether a response body will be sent
189
+ # @return [void]
190
+ def send_headers(headers, opts = DEFAULT_HEADERS_OPTS)
191
+ @conn << format_headers(headers, !opts[:empty_response])
192
+ end
193
+
194
+ # Sends a response body chunk. If no headers were sent, default headers are
195
+ # sent using #send_headers. if the done option is true(thy), an empty chunk
196
+ # will be sent to signal response completion to the client.
197
+ # @param chunk [String] response body chunk
198
+ # @param done [boolean] whether the response is completed
199
+ # @return [void]
200
+ def send_chunk(chunk, done: false)
201
+ data = +"#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
202
+ data << "0\r\n\r\n" if done
203
+ @conn << data
204
+ end
205
+
206
+ # Finishes the response to the current request. If no headers were sent,
207
+ # default headers are sent using #send_headers.
208
+ # @return [void]
209
+ def finish
210
+ @conn << "0\r\n\r\n"
211
+ end
212
+
213
+ def close
214
+ @conn.close
215
+ end
216
+
217
+ private
218
+
219
+ # Formats response headers. If empty_response is true(thy), the response
220
+ # status code will default to 204, otherwise to 200.
221
+ # @param headers [Hash] response headers
222
+ # @param empty_response [boolean] whether a response body will be sent
223
+ # @return [String] formatted response headers
224
+ def format_headers(headers, body)
225
+ status = headers[':status'] || (body ? 200 : 204)
226
+ data = format_status_line(body, status)
227
+
228
+ headers.each do |k, v|
229
+ next if k =~ /^:/
230
+
231
+ data << format_header_lines(k, v)
232
+ end
233
+ data << "\r\n"
234
+ end
235
+
236
+ def format_header_lines(key, value)
237
+ if value.is_a?(Array)
238
+ value.inject(+'') { |data, item| data << "#{key}: #{item}\r\n" }
239
+ else
240
+ "#{key}: #{value}\r\n"
241
+ end
242
+ end
243
+
244
+ def format_status_line(body, status)
245
+ if !body
246
+ empty_status_line(status)
247
+ else
248
+ with_body_status_line(status, body)
249
+ end
250
+ end
251
+
252
+ def empty_status_line(status)
253
+ if status == 204
254
+ +"HTTP/1.1 #{status}\r\n"
255
+ else
256
+ +"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n"
257
+ end
258
+ end
259
+
260
+ def with_body_status_line(status, body)
261
+ if @parser.http_minor == 0
262
+ +"HTTP/1.0 #{status}\r\nContent-Length: #{body.bytesize}\r\n"
263
+ else
264
+ +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http/2'
4
+ require_relative './http2_stream'
5
+
6
+ module Tipi
7
+ # HTTP2 server adapter
8
+ class HTTP2Adapter
9
+ def self.upgrade_each(socket, opts, headers, &block)
10
+ adapter = new(socket, opts, headers)
11
+ adapter.each(&block)
12
+ end
13
+
14
+ def initialize(conn, opts, upgrade_headers = nil)
15
+ @conn = conn
16
+ @opts = opts
17
+ @upgrade_headers = upgrade_headers
18
+
19
+ @interface = ::HTTP2::Server.new
20
+ @connection_fiber = Fiber.current
21
+ @interface.on(:frame, &method(:send_frame))
22
+ @streams = {}
23
+ end
24
+
25
+ def send_frame(data)
26
+ @conn << data
27
+ rescue Exception => e
28
+ @connection_fiber.transfer e
29
+ end
30
+
31
+ UPGRADE_MESSAGE = <<~HTTP.gsub("\n", "\r\n")
32
+ HTTP/1.1 101 Switching Protocols
33
+ Connection: Upgrade
34
+ Upgrade: h2c
35
+
36
+ HTTP
37
+
38
+ def upgrade
39
+ @conn << UPGRADE_MESSAGE
40
+ settings = @upgrade_headers['HTTP2-Settings']
41
+ Fiber.current.schedule(nil)
42
+ @interface.upgrade(settings, @upgrade_headers, '')
43
+ ensure
44
+ @upgrade_headers = nil
45
+ end
46
+
47
+ # Iterates over incoming requests
48
+ def each(&block)
49
+ @interface.on(:stream) { |stream| start_stream(stream, &block) }
50
+ upgrade if @upgrade_headers
51
+
52
+ @conn.read_loop(&@interface.method(:<<))
53
+ rescue SystemCallError, IOError
54
+ # ignore
55
+ ensure
56
+ finalize_client_loop
57
+ end
58
+
59
+ def start_stream(stream, &block)
60
+ stream = HTTP2StreamHandler.new(stream, &block)
61
+ @streams[stream] = true
62
+ end
63
+
64
+ def finalize_client_loop
65
+ @interface = nil
66
+ @streams.each_key(&:stop)
67
+ @conn.close
68
+ end
69
+
70
+ def close
71
+ @conn.close
72
+ end
73
+ end
74
+ end