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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13f02bd5602a0a6f93f9fdf80a937c0bc8adf20bbb2ad30fbd4860475011e02f
4
- data.tar.gz: 4023be91bfbcab20c048dd54201fa80fc3926ab83d17c4d90d52c92dd839155c
3
+ metadata.gz: db3872b7a63b4b1fdb9036a98dce3f48ec8d85a0facd7fe42f8dd806a82cab17
4
+ data.tar.gz: 1dced9eafbbbc44921900cff33d734b8540ecd92a59a122f8654c7689f2d021a
5
5
  SHA512:
6
- metadata.gz: 9b8974c79439798a63e993f9002fa08829f754a6e2d3b31083f9ee6b7613d8398cd57074d284c14be74fb70bf7bdf26f4bfba33bd5fe65feddb39d14188efca3
7
- data.tar.gz: d55c83e3bf308b5a569e48c5ffb7333df45d240ba67c220faf27795d6c297076faa95c8e53340daab1c13e07705fc4d6f67fab19b73893a9f815046ad5b2205d
6
+ metadata.gz: d80a7cf83f40792f60f2652c13583b130e770ca31272f2ab3e5796b7ec232e581d1a48b41030247f18a6fdfa685457a6790b78b29211a167f7e5df14da2328fb
7
+ data.tar.gz: aab2f0b8a6a4450ddf9f5c2b1a9226abb0cc168aee50ed4c081e1ac2c4a54c83fcab57d80102c505b3dba8bcd35651a8544e72fa5eac9c80357d393b654cc3a4
@@ -9,7 +9,7 @@ module Low
9
9
 
10
10
  # TODO: For RouteEvent/FileEvent parse and provide query params as attributes on the event.
11
11
  def initialize(file:, request: nil)
12
- super()
12
+ super(key: self.class)
13
13
 
14
14
  @file = file
15
15
  @request = request
@@ -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'
@@ -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(self, action: :mirror) if config.mirror_mode
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
- 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
32
45
 
33
- Fiber.schedule do
46
+ # Request handler.
34
47
  loop do
35
48
  socket = server.accept
36
49
 
37
- Fiber.schedule do
38
- request = Low::RequestParser.parse(socket:, host: config.host, port: config.port)
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 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
@@ -16,6 +16,13 @@ module Low
16
16
 
17
17
  OpenStruct.new(config_data)
18
18
  end
19
+
20
+ def parse_boolean(value)
21
+ return true if value == '1'
22
+ return false if value == '0'
23
+
24
+ nil
25
+ end
19
26
  end
20
27
  end
21
28
  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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Low
4
4
  module Loop
5
- VERSION = '0.3.2'
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.3.2
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/config_support.rb
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: 3.7.2
148
+ rubygems_version: 4.0.6
149
149
  specification_version: 4
150
150
  summary: An event-driven event loop
151
151
  test_files: []
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Low
4
- module ConfigSupport
5
- class << self
6
- def parse_boolean(value)
7
- return true if value == '1'
8
- return false if value == '0'
9
-
10
- nil
11
- end
12
- end
13
- end
14
- end