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,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