low_loop 0.4.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d9fa2d555e127ad470c1eb71573c076d3484b08b833e184aa5c052b0de5a6af
4
- data.tar.gz: 7a56cc04f846739e4dd957f66d6d558e40ab98c1fc69408fe82a6675f6101dec
3
+ metadata.gz: db3872b7a63b4b1fdb9036a98dce3f48ec8d85a0facd7fe42f8dd806a82cab17
4
+ data.tar.gz: 1dced9eafbbbc44921900cff33d734b8540ecd92a59a122f8654c7689f2d021a
5
5
  SHA512:
6
- metadata.gz: 1e6d8044eee477441056a43a895c1314cbe14e99d170244ed9d75477c50b614c5ea29a6ac2dd245172dab8870cdca6d70bfec5b9c4ca2065dce8468be5b12e8c
7
- data.tar.gz: 1923f95b047d33bccd17fe9b48b63a84dfa80ac30497d25b5989bdffdf1ff50f554898b0e2a5d7fcdc4ceda175f02f4da6dff6dca0c60d94f063a5801d45e9f2
6
+ metadata.gz: d80a7cf83f40792f60f2652c13583b130e770ca31272f2ab3e5796b7ec232e581d1a48b41030247f18a6fdfa685457a6790b78b29211a167f7e5df14da2328fb
7
+ data.tar.gz: aab2f0b8a6a4450ddf9f5c2b1a9226abb0cc168aee50ed4c081e1ac2c4a54c83fcab57d80102c505b3dba8bcd35651a8544e72fa5eac9c80357d393b654cc3a4
@@ -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,11 +16,14 @@ 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:)
26
+ @frame = LowFrame.new(renderer:, fps: 10, show_output:)
23
27
 
24
28
  observers(Low::Events::RequestEvent) << Low::FileServer.new(web_root: config.web_root, content_types: config.content_types)
25
29
  observers(Low::Events::RequestEvent) << router if router
@@ -30,20 +34,21 @@ class LowLoop
30
34
  def start
31
35
  server = start_server
32
36
 
33
- Fiber.set_scheduler(Async::Scheduler.new)
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
34
45
 
35
- Fiber.schedule do
46
+ # Request handler.
36
47
  loop do
37
48
  socket = server.accept
38
49
 
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:)
50
+ task.async do
51
+ handle_connection(socket)
47
52
  rescue StandardError => e
48
53
  puts e.message
49
54
  ensure
@@ -61,6 +66,14 @@ class LowLoop
61
66
  server
62
67
  end
63
68
 
69
+ def render
70
+ Async do
71
+ loop do
72
+ @frame.render
73
+ end
74
+ end
75
+ end
76
+
64
77
  # Fallback mode for when there's no dependencies and you want to know that the server is still working.
65
78
  def mirror(event:)
66
79
  request = event.request
@@ -72,4 +85,54 @@ class LowLoop
72
85
  def ==(other) = other.class == self.class
73
86
  def eql?(other) = self == other
74
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
75
138
  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,28 +5,25 @@ 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
+ 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)
30
27
  end
31
28
 
32
29
  def setup
@@ -39,11 +36,11 @@ class LowFrame
39
36
  end
40
37
 
41
38
  def exit
42
- trap('INT') {
39
+ trap('INT') do
43
40
  reset
44
41
  system 'clear'
45
42
  exit
46
- }
43
+ end
47
44
  end
48
45
 
49
46
  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.0'
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi