low_loop 0.4.0 → 0.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d9fa2d555e127ad470c1eb71573c076d3484b08b833e184aa5c052b0de5a6af
4
- data.tar.gz: 7a56cc04f846739e4dd957f66d6d558e40ab98c1fc69408fe82a6675f6101dec
3
+ metadata.gz: a937739ee77aedaaf4bce063e88be4dc25d144d23900f58741ed6b1f9b816aba
4
+ data.tar.gz: f5fa4e2e8943cf9c65811df974ed16f1acc9da21bed14601fd6fbd8b940ac4c7
5
5
  SHA512:
6
- metadata.gz: 1e6d8044eee477441056a43a895c1314cbe14e99d170244ed9d75477c50b614c5ea29a6ac2dd245172dab8870cdca6d70bfec5b9c4ca2065dce8468be5b12e8c
7
- data.tar.gz: 1923f95b047d33bccd17fe9b48b63a84dfa80ac30497d25b5989bdffdf1ff50f554898b0e2a5d7fcdc4ceda175f02f4da6dff6dca0c60d94f063a5801d45e9f2
6
+ metadata.gz: ff08f0065bf92482f735e28f80c958c456d1b55cd46e5eed62c5c65530ad91198347f33265d966b050ade553305282fd75c8b45482d4ac456ebdeb83f9af43bb
7
+ data.tar.gz: b809fe7e750c258e7758c8360b0f0164965903cf3a565821b11e90801ad77d3adf7c9727211ddb085b68ec2ba6b55b09e0829d8ada3906f169ae16d5542be978
@@ -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('http/1.1', 200, headers, body)
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('http/1.1', 200, headers, body)
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'
@@ -15,35 +16,40 @@ require_relative 'support/low_frame'
15
16
  class LowLoop
16
17
  include Observers
17
18
 
19
+ DEFAULT_KEEP_ALIVE_TIMEOUT = 30
20
+ DEFAULT_REQUEST_TIMEOUT = 10
21
+
18
22
  attr_reader :config
19
23
 
20
24
  def initialize(config:, router: nil, renderer: nil, show_output: true)
21
25
  @config = config
22
- @frame = LowFrame.new(renderer:, fps: 30, show_output:)
23
-
24
- observers(Low::Events::RequestEvent) << Low::FileServer.new(web_root: config.web_root, content_types: config.content_types)
25
- observers(Low::Events::RequestEvent) << router if router
26
+ @frame = LowFrame.new(renderer:, fps: 10, show_output:)
26
27
 
27
- observers.push(Low::Events::RequestEvent, action: :mirror) if config.mirror_mode
28
+ Low::Events::RequestEvent.define do |observers|
29
+ observers << Low::FileServer.new(web_root: config.web_root, content_types: config.content_types)
30
+ observers << router if router
31
+ observers.push(action: :mirror) if config.mirror_mode
32
+ end
28
33
  end
29
34
 
30
35
  def start
31
36
  server = start_server
32
37
 
33
- Fiber.set_scheduler(Async::Scheduler.new)
38
+ Async do |task|
39
+ # Background task.
40
+ task.async do
41
+ loop do
42
+ @frame.render if @frame.renderer
43
+ sleep 0.1 # 10fps
44
+ end
45
+ end
34
46
 
35
- Fiber.schedule do
47
+ # Request handler.
36
48
  loop do
37
49
  socket = server.accept
38
50
 
39
- @frame.render if @frame.renderer
40
-
41
- Fiber.schedule do
42
- request = Low::RequestParser.parse(socket:, host: config.host, port: config.port)
43
- response_event = Low::Events::RequestEvent.take(request:)
44
- response = response_event.response
45
-
46
- Low::ResponseBuilder.respond(config:, socket:, response:)
51
+ task.async do
52
+ handle_connection(socket)
47
53
  rescue StandardError => e
48
54
  puts e.message
49
55
  ensure
@@ -61,6 +67,14 @@ class LowLoop
61
67
  server
62
68
  end
63
69
 
70
+ def render
71
+ Async do
72
+ loop do
73
+ @frame.render
74
+ end
75
+ end
76
+ end
77
+
64
78
  # Fallback mode for when there's no dependencies and you want to know that the server is still working.
65
79
  def mirror(event:)
66
80
  request = event.request
@@ -72,4 +86,54 @@ class LowLoop
72
86
  def ==(other) = other.class == self.class
73
87
  def eql?(other) = self == other
74
88
  def hash = [self.class].hash
89
+
90
+ private
91
+
92
+ def handle_connection(socket)
93
+ stream = Low::RequestParser.create_stream(socket:)
94
+ keep_alive = true
95
+ version = nil
96
+
97
+ while keep_alive
98
+ break unless socket.wait_readable(keep_alive_timeout)
99
+
100
+ socket.timeout = request_timeout
101
+ begin
102
+ request = Low::RequestParser.parse(stream:, host: config.host, port: config.port, version:)
103
+ rescue IO::TimeoutError
104
+ break
105
+ ensure
106
+ socket.timeout = nil
107
+ end
108
+ break if request.nil?
109
+
110
+ version ||= request.version
111
+ keep_alive = keep_alive?(request)
112
+
113
+ response_event = Low::Events::RequestEvent.take(request:)
114
+ response = response_event.response
115
+
116
+ Low::ResponseBuilder.respond(config:, socket:, response:, keep_alive:)
117
+ end
118
+ end
119
+
120
+ def keep_alive?(request)
121
+ tokens = (request.headers['connection'] || []).flat_map do |value|
122
+ value.split(',').map { |token| token.strip.downcase }
123
+ end
124
+
125
+ if request.version.to_s.downcase.include?('1.0')
126
+ tokens.include?('keep-alive')
127
+ else
128
+ !tokens.include?('close')
129
+ end
130
+ end
131
+
132
+ def keep_alive_timeout
133
+ config.keep_alive_timeout || DEFAULT_KEEP_ALIVE_TIMEOUT
134
+ end
135
+
136
+ def request_timeout
137
+ config.request_timeout || DEFAULT_REQUEST_TIMEOUT
138
+ end
75
139
  end
@@ -9,15 +9,21 @@ module Low
9
9
  include LowType
10
10
 
11
11
  class << self
12
- def parse(socket: TCPSocket, host: String, port: Integer) -> { ::Protocol::HTTP::Request }
13
- stream = IO::Stream(socket)
14
- protocol = Async::HTTP::Protocol::HTTP.default.protocol_for(stream)
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, path, = parse_request(stream:)
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, path, protocol::VERSION, headers, body)
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
- # TODO: Handle type for namespaced "IO:Stream".
35
- def parse_request(stream:)
36
- request_line = stream.gets || raise(StandardError, 'EOF')
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.strip.split(' ', 3)
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
- # TODO: Handle namespaced stream type "IO:Stream".
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
- while (line = stream.gets.strip)
49
- break if line.strip.empty?
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:, method:)
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
- socket.puts "#{response.version} #{response.status}\r\n"
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
- response.headers.fields.each_slice(2) do |key, value|
12
- socket.puts "#{key}: #{value}\r\n"
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.puts "\r\n"
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 response.body.respond_to?(:file)
22
+ if file_body
18
23
  IO.copy_stream(response.body.file, socket)
19
24
  else
20
- socket.puts(response.body.read)
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
- socket.close
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
@@ -5,45 +5,44 @@ require 'io/console'
5
5
  class LowFrame
6
6
  attr_reader :screen_size, :renderer
7
7
 
8
- def initialize(renderer:, fps: 30, show_output: true)
8
+ def initialize(renderer:, fps: 10, show_output: true)
9
9
  @renderer = renderer
10
10
  @show_output = show_output
11
11
 
12
12
  # Millisecond duration of each frame. We lose a small amount of precision dropping the decimal.
13
13
  @frame_time = ((1.0 / fps) * 1000).to_i
14
-
15
- row_count, column_count = IO.console.winsize
16
- @screen_size = { row_count:, column_count: }
17
-
18
14
  @last_frame = nil
19
15
 
20
16
  setup if renderer && show_output
21
17
  end
22
18
 
23
19
  def render
24
- if @last_frame.nil? || (current_timestamp - @last_frame) >= @frame_time
25
- system 'clear' if @show_output
20
+ return unless @last_frame.nil? || (current_timestamp - @last_frame) >= @frame_time
26
21
 
27
- @last_frame = current_timestamp
28
- @renderer.render(screen_size: @screen_size)
29
- end
22
+ @last_frame = current_timestamp
23
+ @renderer.render(screen_size: @screen_size)
30
24
  end
31
25
 
32
26
  def setup
33
27
  print "\e[?25l" # Hide cursor.
34
28
  system 'clear'
35
- end
36
29
 
37
- def reset
38
- print "\e[?25h\e[0m" # Show cursor and reset colors.
39
- end
30
+ resize
40
31
 
41
- def exit
42
- trap('INT') {
43
- reset
32
+ Signal.trap('WINCH') do
33
+ resize
34
+ end
35
+
36
+ Signal.trap('INT') do
37
+ print "\e[?25h\e[0m" # Show cursor and reset colors.
44
38
  system 'clear'
45
39
  exit
46
- }
40
+ end
41
+ end
42
+
43
+ def resize
44
+ row_count, column_count = IO.console.winsize
45
+ @screen_size = { row_count:, column_count: }
47
46
  end
48
47
 
49
48
  private
data/lib/version.rb CHANGED
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Low
4
4
  module Loop
5
- VERSION = '0.4.0'
5
+ VERSION = '0.5.1'
6
6
  end
7
7
  end
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.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi