low_loop 0.3.2 → 0.5.0
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 +4 -4
- data/lib/events/file_event.rb +1 -1
- data/lib/factories/response_factory.rb +2 -2
- data/lib/low_loop.rb +79 -12
- data/lib/requests/request_parser.rb +29 -18
- data/lib/responses/response_builder.rb +36 -9
- data/lib/support/config_loader.rb +7 -0
- data/lib/support/low_frame.rb +51 -0
- data/lib/version.rb +1 -1
- metadata +3 -3
- data/lib/support/config_support.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db3872b7a63b4b1fdb9036a98dce3f48ec8d85a0facd7fe42f8dd806a82cab17
|
|
4
|
+
data.tar.gz: 1dced9eafbbbc44921900cff33d734b8540ecd92a59a122f8654c7689f2d021a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d80a7cf83f40792f60f2652c13583b130e770ca31272f2ab3e5796b7ec232e581d1a48b41030247f18a6fdfa685457a6790b78b29211a167f7e5df14da2328fb
|
|
7
|
+
data.tar.gz: aab2f0b8a6a4450ddf9f5c2b1a9226abb0cc168aee50ed4c081e1ac2c4a54c83fcab57d80102c505b3dba8bcd35651a8544e72fa5eac9c80357d393b654cc3a4
|
data/lib/events/file_event.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Low
|
|
|
11
11
|
headers = Protocol::HTTP::Headers.new(['content-type', 'text/html'])
|
|
12
12
|
body = Protocol::HTTP::Body::Buffered.wrap(body)
|
|
13
13
|
|
|
14
|
-
Protocol::HTTP::Response.new('
|
|
14
|
+
Protocol::HTTP::Response.new('HTTP/1.1', 200, headers, body)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def file(path:, content_type:)
|
|
@@ -19,7 +19,7 @@ module Low
|
|
|
19
19
|
file = File.open(path, 'rb')
|
|
20
20
|
body = Protocol::HTTP::Body::File.new(file)
|
|
21
21
|
|
|
22
|
-
Protocol::HTTP::Response.new('
|
|
22
|
+
Protocol::HTTP::Response.new('HTTP/1.1', 200, headers, body)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
data/lib/low_loop.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'async'
|
|
4
|
+
require 'io/wait'
|
|
4
5
|
require 'socket'
|
|
5
6
|
require 'low_type'
|
|
6
7
|
require 'low_event'
|
|
@@ -10,36 +11,44 @@ require_relative 'factories/response_factory'
|
|
|
10
11
|
require_relative 'requests/request_parser'
|
|
11
12
|
require_relative 'responses/response_builder'
|
|
12
13
|
require_relative 'servers/file_server'
|
|
14
|
+
require_relative 'support/low_frame'
|
|
13
15
|
|
|
14
16
|
class LowLoop
|
|
15
17
|
include Observers
|
|
16
18
|
|
|
19
|
+
DEFAULT_KEEP_ALIVE_TIMEOUT = 30
|
|
20
|
+
DEFAULT_REQUEST_TIMEOUT = 10
|
|
21
|
+
|
|
17
22
|
attr_reader :config
|
|
18
23
|
|
|
19
|
-
def initialize(config:, router: nil)
|
|
24
|
+
def initialize(config:, router: nil, renderer: nil, show_output: true)
|
|
20
25
|
@config = config
|
|
26
|
+
@frame = LowFrame.new(renderer:, fps: 10, show_output:)
|
|
21
27
|
|
|
22
|
-
observers << Low::FileServer.new(web_root: config.web_root, content_types: config.content_types)
|
|
23
|
-
observers << router if router
|
|
28
|
+
observers(Low::Events::RequestEvent) << Low::FileServer.new(web_root: config.web_root, content_types: config.content_types)
|
|
29
|
+
observers(Low::Events::RequestEvent) << router if router
|
|
24
30
|
|
|
25
|
-
observers.push(
|
|
31
|
+
observers.push(Low::Events::RequestEvent, action: :mirror) if config.mirror_mode
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
def start
|
|
29
35
|
server = start_server
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
Async do |task|
|
|
38
|
+
# Background task.
|
|
39
|
+
task.async do
|
|
40
|
+
loop do
|
|
41
|
+
@frame.render if @frame.renderer
|
|
42
|
+
sleep 0.1 # 10fps
|
|
43
|
+
end
|
|
44
|
+
end
|
|
32
45
|
|
|
33
|
-
|
|
46
|
+
# Request handler.
|
|
34
47
|
loop do
|
|
35
48
|
socket = server.accept
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
response_event = take(event: Low::Events::RequestEvent.new(request:))
|
|
40
|
-
response = response_event.response
|
|
41
|
-
|
|
42
|
-
Low::ResponseBuilder.respond(config:, socket:, response:)
|
|
50
|
+
task.async do
|
|
51
|
+
handle_connection(socket)
|
|
43
52
|
rescue StandardError => e
|
|
44
53
|
puts e.message
|
|
45
54
|
ensure
|
|
@@ -57,6 +66,14 @@ class LowLoop
|
|
|
57
66
|
server
|
|
58
67
|
end
|
|
59
68
|
|
|
69
|
+
def render
|
|
70
|
+
Async do
|
|
71
|
+
loop do
|
|
72
|
+
@frame.render
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
60
77
|
# Fallback mode for when there's no dependencies and you want to know that the server is still working.
|
|
61
78
|
def mirror(event:)
|
|
62
79
|
request = event.request
|
|
@@ -68,4 +85,54 @@ class LowLoop
|
|
|
68
85
|
def ==(other) = other.class == self.class
|
|
69
86
|
def eql?(other) = self == other
|
|
70
87
|
def hash = [self.class].hash
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def handle_connection(socket)
|
|
92
|
+
stream = Low::RequestParser.create_stream(socket:)
|
|
93
|
+
keep_alive = true
|
|
94
|
+
version = nil
|
|
95
|
+
|
|
96
|
+
while keep_alive
|
|
97
|
+
break unless socket.wait_readable(keep_alive_timeout)
|
|
98
|
+
|
|
99
|
+
socket.timeout = request_timeout
|
|
100
|
+
begin
|
|
101
|
+
request = Low::RequestParser.parse(stream:, host: config.host, port: config.port, version:)
|
|
102
|
+
rescue IO::TimeoutError
|
|
103
|
+
break
|
|
104
|
+
ensure
|
|
105
|
+
socket.timeout = nil
|
|
106
|
+
end
|
|
107
|
+
break if request.nil?
|
|
108
|
+
|
|
109
|
+
version ||= request.version
|
|
110
|
+
keep_alive = keep_alive?(request)
|
|
111
|
+
|
|
112
|
+
response_event = Low::Events::RequestEvent.take(request:)
|
|
113
|
+
response = response_event.response
|
|
114
|
+
|
|
115
|
+
Low::ResponseBuilder.respond(config:, socket:, response:, keep_alive:)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def keep_alive?(request)
|
|
120
|
+
tokens = (request.headers['connection'] || []).flat_map do |value|
|
|
121
|
+
value.split(',').map { |token| token.strip.downcase }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if request.version.to_s.downcase.include?('1.0')
|
|
125
|
+
tokens.include?('keep-alive')
|
|
126
|
+
else
|
|
127
|
+
!tokens.include?('close')
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def keep_alive_timeout
|
|
132
|
+
config.keep_alive_timeout || DEFAULT_KEEP_ALIVE_TIMEOUT
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def request_timeout
|
|
136
|
+
config.request_timeout || DEFAULT_REQUEST_TIMEOUT
|
|
137
|
+
end
|
|
71
138
|
end
|
|
@@ -9,15 +9,21 @@ module Low
|
|
|
9
9
|
include LowType
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
def create_stream(socket: TCPSocket) -> { IO::Stream::Generic }
|
|
13
|
+
IO::Stream(socket)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse(stream: IO::Stream::Generic, host: String, port: Integer, version: nil) -> { ::Protocol::HTTP::Request | nil }
|
|
17
|
+
version ||= Async::HTTP::Protocol::HTTP.default.protocol_for(stream)::VERSION
|
|
18
|
+
|
|
19
|
+
result = parse_request(stream:)
|
|
20
|
+
return nil unless result
|
|
15
21
|
|
|
16
|
-
method,
|
|
22
|
+
method, full_path, = result
|
|
17
23
|
headers = parse_headers(stream:)
|
|
18
|
-
body = parse_body(stream:, method:)
|
|
24
|
+
body = parse_body(stream:, method:, headers:)
|
|
19
25
|
|
|
20
|
-
::Protocol::HTTP::Request.new('http', "#{host}:#{port}", method,
|
|
26
|
+
::Protocol::HTTP::Request.new('http', "#{host}:#{port}", method, full_path, version, headers, body)
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
private
|
|
@@ -30,23 +36,25 @@ module Low
|
|
|
30
36
|
# :header_3\r\n
|
|
31
37
|
# \r\n
|
|
32
38
|
# :body
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
request_line =
|
|
39
|
+
def parse_request(stream: IO::Stream::Generic)
|
|
40
|
+
request_line = stream.gets || return
|
|
41
|
+
|
|
42
|
+
request_line = request_line.strip
|
|
43
|
+
return nil if request_line.empty?
|
|
37
44
|
|
|
38
|
-
method, full_path, _http_version = request_line.
|
|
45
|
+
method, full_path, _http_version = request_line.split(' ', 3)
|
|
39
46
|
path, query = full_path.split('?', 2)
|
|
40
47
|
|
|
41
48
|
[method, full_path, path, query]
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
def parse_headers(stream:) -> { ::Protocol::HTTP::Headers }
|
|
51
|
+
def parse_headers(stream: IO::Stream::Generic) -> { ::Protocol::HTTP::Headers }
|
|
46
52
|
fields = []
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
# Sometimes returns nil which represents a client disconnect mid-request.
|
|
55
|
+
while (line = stream.gets)
|
|
56
|
+
line = line.strip
|
|
57
|
+
break if line.empty?
|
|
50
58
|
|
|
51
59
|
key, value = line.split(/:\s/, 2)
|
|
52
60
|
fields << [key, value]
|
|
@@ -55,10 +63,13 @@ module Low
|
|
|
55
63
|
::Protocol::HTTP::Headers.new(fields)
|
|
56
64
|
end
|
|
57
65
|
|
|
58
|
-
def parse_body(stream
|
|
59
|
-
return nil unless %w[POST PUT].include?(method)
|
|
66
|
+
def parse_body(stream: IO::Stream::Generic, method: String, headers: ::Protocol::HTTP::Headers)
|
|
67
|
+
return nil unless %w[POST PUT PATCH].include?(method)
|
|
68
|
+
|
|
69
|
+
content_length = headers['content-length']&.first&.to_i
|
|
70
|
+
return nil unless content_length&.positive?
|
|
60
71
|
|
|
61
|
-
stream.read
|
|
72
|
+
stream.read(content_length)
|
|
62
73
|
end
|
|
63
74
|
end
|
|
64
75
|
end
|
|
@@ -4,23 +4,50 @@ module Low
|
|
|
4
4
|
class ResponseBuilder
|
|
5
5
|
class << self
|
|
6
6
|
# TODO: Use Async wherever we can where it doesn't have "Task" requirement.
|
|
7
|
-
def respond(config:, socket:, response:)
|
|
8
|
-
|
|
9
|
-
socket.puts config.port.nil? ? "Host: #{config.host}\r\n" : "Host: #{config.host}:#{config.port}\r\n"
|
|
7
|
+
def respond(config:, socket:, response:, keep_alive: true)
|
|
8
|
+
file_body = response.body.respond_to?(:file)
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
if file_body
|
|
11
|
+
content_length = response.body.file.size
|
|
12
|
+
else
|
|
13
|
+
body_data = response.body.read || ''
|
|
14
|
+
content_length = body_data.bytesize
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
socket
|
|
17
|
+
write_status_line(socket, response)
|
|
18
|
+
write_host_header(socket, config)
|
|
19
|
+
write_response_headers(socket, response)
|
|
20
|
+
write_final_headers(socket, content_length, keep_alive)
|
|
16
21
|
|
|
17
|
-
if
|
|
22
|
+
if file_body
|
|
18
23
|
IO.copy_stream(response.body.file, socket)
|
|
19
24
|
else
|
|
20
|
-
socket.
|
|
25
|
+
socket.write(body_data)
|
|
21
26
|
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def write_status_line(socket, response)
|
|
32
|
+
socket.puts "#{response.version} #{response.status}\r\n"
|
|
33
|
+
end
|
|
22
34
|
|
|
23
|
-
|
|
35
|
+
def write_host_header(socket, config)
|
|
36
|
+
socket.puts config.port.nil? ? "Host: #{config.host}\r\n" : "Host: #{config.host}:#{config.port}\r\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write_response_headers(socket, response)
|
|
40
|
+
response.headers.fields.each_slice(2) do |key, value|
|
|
41
|
+
next if %w[content-length connection].include?(key.to_s.downcase)
|
|
42
|
+
|
|
43
|
+
socket.puts "#{key}: #{value}\r\n"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def write_final_headers(socket, content_length, keep_alive)
|
|
48
|
+
socket.puts "Content-Length: #{content_length}\r\n"
|
|
49
|
+
socket.puts "Connection: #{keep_alive ? 'keep-alive' : 'close'}\r\n"
|
|
50
|
+
socket.puts "\r\n"
|
|
24
51
|
end
|
|
25
52
|
end
|
|
26
53
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
class LowFrame
|
|
6
|
+
attr_reader :screen_size, :renderer
|
|
7
|
+
|
|
8
|
+
def initialize(renderer:, fps: 10, show_output: true)
|
|
9
|
+
@renderer = renderer
|
|
10
|
+
@show_output = show_output
|
|
11
|
+
|
|
12
|
+
# Millisecond duration of each frame. We lose a small amount of precision dropping the decimal.
|
|
13
|
+
@frame_time = ((1.0 / fps) * 1000).to_i
|
|
14
|
+
@last_frame = nil
|
|
15
|
+
|
|
16
|
+
setup if renderer && show_output
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render
|
|
20
|
+
return unless @last_frame.nil? || (current_timestamp - @last_frame) >= @frame_time
|
|
21
|
+
|
|
22
|
+
row_count, column_count = IO.console.winsize
|
|
23
|
+
@screen_size = { row_count:, column_count: }
|
|
24
|
+
|
|
25
|
+
@last_frame = current_timestamp
|
|
26
|
+
@renderer.render(screen_size: @screen_size)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def setup
|
|
30
|
+
print "\e[?25l" # Hide cursor.
|
|
31
|
+
system 'clear'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset
|
|
35
|
+
print "\e[?25h\e[0m" # Show cursor and reset colors.
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exit
|
|
39
|
+
trap('INT') do
|
|
40
|
+
reset
|
|
41
|
+
system 'clear'
|
|
42
|
+
exit
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def current_timestamp
|
|
49
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: low_loop
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
@@ -124,7 +124,7 @@ files:
|
|
|
124
124
|
- lib/servers/file_server.rb
|
|
125
125
|
- lib/states/file_state.rb
|
|
126
126
|
- lib/support/config_loader.rb
|
|
127
|
-
- lib/support/
|
|
127
|
+
- lib/support/low_frame.rb
|
|
128
128
|
- lib/version.rb
|
|
129
129
|
homepage: https://github.com/low-rb/low_loop
|
|
130
130
|
licenses: []
|
|
@@ -145,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
145
145
|
- !ruby/object:Gem::Version
|
|
146
146
|
version: '0'
|
|
147
147
|
requirements: []
|
|
148
|
-
rubygems_version:
|
|
148
|
+
rubygems_version: 4.0.6
|
|
149
149
|
specification_version: 4
|
|
150
150
|
summary: An event-driven event loop
|
|
151
151
|
test_files: []
|