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,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>
|
data/lib/tipi.rb
ADDED
@@ -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
|