lowloop 0.8.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0cb4879d732cfa8bcceccce38ec1712d3ad5abd65fd5d06243301c3c7bc7fbfc
4
+ data.tar.gz: c40fa4f9fa6c7bb068b690ae91c66d3cc3b994bf9329bad57e88c738f356e667
5
+ SHA512:
6
+ metadata.gz: 6a9ac09e3922269acf23efb9f257d69ae1835dd6775a41a0962e287524a9b1025a20da6836c7cc92fd64dbce6cd1dc98cd826b395a4477cc78ba3ee47636d7a1
7
+ data.tar.gz: d0b84d595b03f666a3b60dc7e14e011dc611a4c45dc49869fae240b6711b6af5b19a94c732f32cafbd67ed36f9dab789580c3d16c92c1166c46ea617b39c05da
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/wait'
4
+
5
+ require_relative 'request_parser'
6
+ require_relative '../responses/response_builder'
7
+
8
+ module Low
9
+ class ConnectionManager
10
+ DEFAULT_KEEP_ALIVE_TIMEOUT = 30
11
+ DEFAULT_REQUEST_TIMEOUT = 10
12
+
13
+ def initialize(config:)
14
+ @config = config
15
+ end
16
+
17
+ def handle_connection(socket:)
18
+ stream = RequestParser.create_stream(socket:)
19
+ keep_alive = true
20
+ version = nil
21
+
22
+ while keep_alive
23
+ break unless socket.wait_readable(keep_alive_timeout)
24
+
25
+ socket.timeout = request_timeout
26
+ begin
27
+ request = RequestParser.parse(stream:, host: config.host, port: config.port, version:)
28
+ rescue IO::TimeoutError
29
+ break
30
+ ensure
31
+ socket.timeout = nil
32
+ end
33
+ break if request.nil?
34
+
35
+ version ||= request.version
36
+ keep_alive = keep_alive?(request)
37
+
38
+ response_event = Events::RequestEvent.take(request:)
39
+ response = response_event.response
40
+
41
+ ResponseBuilder.respond(config:, socket:, response:, keep_alive:)
42
+ end
43
+ end
44
+
45
+ def keep_alive?(request)
46
+ tokens = (request.headers['connection'] || []).flat_map do |value|
47
+ value.split(',').map { |token| token.strip.downcase }
48
+ end
49
+
50
+ if request.version.to_s.downcase.include?('1.0')
51
+ tokens.include?('keep-alive')
52
+ else
53
+ !tokens.include?('close')
54
+ end
55
+ end
56
+
57
+ def keep_alive_timeout
58
+ config.keep_alive_timeout || DEFAULT_KEEP_ALIVE_TIMEOUT
59
+ end
60
+
61
+ def request_timeout
62
+ config.request_timeout || DEFAULT_REQUEST_TIMEOUT
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :config
68
+ end
69
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/stream'
4
+ require 'async/http'
5
+ require 'protocol/http'
6
+
7
+ module Low
8
+ class RequestParser
9
+ include LowType
10
+
11
+ class << self
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
21
+
22
+ method, full_path, = result
23
+ headers = parse_headers(stream:)
24
+ body = parse_body(stream:, method:, headers:)
25
+
26
+ ::Protocol::HTTP::Request.new('http', "#{host}:#{port}", method, full_path, version, headers, body)
27
+ end
28
+
29
+ private
30
+
31
+ # HTTP Request format:
32
+ #
33
+ # :verb :path HTTP/1.1\r\n
34
+ # :header_1\r\n
35
+ # :header_2\r\n
36
+ # :header_3\r\n
37
+ # \r\n
38
+ # :body
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?
44
+
45
+ method, full_path, _http_version = request_line.split(' ', 3)
46
+ path, query = full_path.split('?', 2)
47
+
48
+ [method, full_path, path, query]
49
+ end
50
+
51
+ def parse_headers(stream: IO::Stream::Generic) -> { ::Protocol::HTTP::Headers }
52
+ fields = []
53
+
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?
58
+
59
+ key, value = line.split(/:\s/, 2)
60
+ fields << [key, value]
61
+ end
62
+
63
+ ::Protocol::HTTP::Headers.new(fields)
64
+ end
65
+
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?
71
+
72
+ stream.read(content_length)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lowevent'
4
+
5
+ module Low
6
+ module Events
7
+ class FileEvent < LowEvent
8
+ attr_reader :file, :request
9
+
10
+ # TODO: For RouteEvent/FileEvent parse and provide query params as attributes on the event.
11
+ def initialize(file:, request: nil)
12
+ super(key: self.class, action: :request)
13
+
14
+ @file = file
15
+ @request = request
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/lowloop.rb ADDED
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'paint'
5
+ require 'socket'
6
+ require 'lowtype'
7
+ require 'lowevent'
8
+ require 'observers'
9
+
10
+ require_relative 'responses/response_factory'
11
+ require_relative 'connections/connection_manager'
12
+ require_relative 'servers/file_server'
13
+ require_relative 'support/low_frame'
14
+
15
+ class LowLoop
16
+ include Observers
17
+
18
+ attr_reader :config
19
+
20
+ def initialize(config:, router: nil, renderer: nil, show_output: true)
21
+ @config = config
22
+ @connection_manager = Low::ConnectionManager.new(config:)
23
+ @frame = LowFrame.new(renderer:, fps: 10, show_output:)
24
+
25
+ Low::Events::RequestEvent.define do |observers|
26
+ observers << Low::FileServer.new(web_root: config.web_root, content_types: config.content_types)
27
+ observers << router if router
28
+ observers.push(self, action: :mirror) if config.mirror_mode
29
+ end
30
+ end
31
+
32
+ def start
33
+ server = start_server
34
+
35
+ Async do |task|
36
+ render_frame(task)
37
+
38
+ loop do
39
+ socket = server.accept
40
+
41
+ task.async do
42
+ @connection_manager.handle_connection(socket:)
43
+ rescue StandardError => e
44
+ render_error(e)
45
+ ensure
46
+ socket&.close
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # Fallback mode for when there's no dependencies and you want to know that the server is still working.
53
+ def mirror(event:)
54
+ request = event.request
55
+ response = tories::ResponseFactory.html(body: "Thank you for visiting #{request.path} with body: '#{request.body}'")
56
+ Low::Events::ResponseEvent.new(response:)
57
+ end
58
+
59
+ # Consider LowLoop a value object in the context of Observers (there can only be one).
60
+ def ==(other) = other.class == self.class
61
+ def eql?(other) = self == other
62
+ def hash = [self.class].hash
63
+
64
+ private
65
+
66
+ def start_server
67
+ puts "Starting server @ #{config.host}:#{config.port}" unless config.matrix_mode
68
+
69
+ server = TCPServer.new(config.host, config.port)
70
+ server.listen(10)
71
+ server
72
+ end
73
+
74
+ def render_frame(task)
75
+ return unless @frame.renderer
76
+
77
+ task.async do
78
+ loop do
79
+ @frame.render
80
+ sleep 0.1 # 10fps
81
+ end
82
+ end
83
+ end
84
+
85
+ def render_error(error)
86
+ puts "\nException:"
87
+ puts Paint[error.message, :red]
88
+ puts ''
89
+
90
+ return unless @config.debug_mode
91
+
92
+ Fiber.blocking do
93
+ puts Paint[error.backtrace.join("\n"), :blue]
94
+ puts ''
95
+ puts 'Press ENTER to continue...'
96
+ gets
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../responses/response_factory'
4
+
5
+ module Low
6
+ class FileResponse
7
+ class << self
8
+ def request(event:)
9
+ file = event.file
10
+
11
+ if File.exist?(file.path)
12
+ response = ResponseFactory.file(path: file.path, content_type: file.content_type)
13
+ return Events::ResponseEvent.new(response:).tap(&:branch)
14
+ end
15
+
16
+ nil
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Low
4
+ class ResponseBuilder
5
+ class << self
6
+ # TODO: Use Async wherever we can where it doesn't have "Task" requirement.
7
+ def respond(config:, socket:, response:, keep_alive: true)
8
+ file_body = response.body.respond_to?(:file)
9
+
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
15
+ end
16
+
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)
21
+
22
+ if file_body
23
+ IO.copy_stream(response.body.file, socket)
24
+ else
25
+ socket.write(body_data)
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
34
+
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 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"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'protocol/http'
4
+ require 'protocol/http/body/file'
5
+
6
+ module Low
7
+ class ResponseFactory
8
+ class << self
9
+ def html(body:)
10
+ headers = Protocol::HTTP::Headers.new([['content-type', 'text/html']])
11
+ body = Protocol::HTTP::Body::Buffered.wrap(body.strip)
12
+
13
+ Protocol::HTTP::Response.new('HTTP/1.1', 200, headers, body)
14
+ end
15
+
16
+ def file(path:, content_type:)
17
+ headers = Protocol::HTTP::Headers.new([['content-type', content_type]])
18
+ file = File.open(path, 'rb')
19
+ body = Protocol::HTTP::Body::File.new(file)
20
+
21
+ Protocol::HTTP::Response.new('HTTP/1.1', 200, headers, body)
22
+ end
23
+
24
+ def status(code:)
25
+ headers = Protocol::HTTP::Headers.new([['content-type', 'text/html']])
26
+
27
+ Protocol::HTTP::Response.new('HTTP/1.1', code, headers, nil)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'protocol/url'
4
+
5
+ require_relative '../events/file_event'
6
+ require_relative '../responses/file_response'
7
+ require_relative '../states/file_state'
8
+
9
+ module Low
10
+ class FileServer
11
+ include LowType
12
+ include Observers
13
+
14
+ def initialize(web_root:, content_types:)
15
+ @web_root = web_root
16
+ @content_types = content_types.transform_keys(&:to_s)
17
+
18
+ observers(Events::FileEvent) << FileResponse
19
+ end
20
+
21
+ def extension(file_path:)
22
+ extension = File.extname(file_path).delete_prefix('.')
23
+
24
+ return nil if extension == ''
25
+ return nil unless @content_types.key?(extension)
26
+
27
+ extension
28
+ end
29
+
30
+ def request(event: Events::RequestEvent)
31
+ file_path = event.request.path
32
+
33
+ return nil unless file_path.include?('.')
34
+
35
+ url = Protocol::URL[file_path]
36
+ extension = extension(file_path: url.path.to_s)
37
+ return nil if extension.nil?
38
+
39
+ file_path = url.local_path(@web_root)
40
+ file = States::FileState.new(path: file_path, content_type: @content_types[extension])
41
+
42
+ Events::FileEvent.take(file:, request: event.request)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Low
4
+ module States
5
+ FileState = Data.define(:path, :content_type)
6
+ end
7
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ require 'yaml'
5
+
6
+ module Low
7
+ class ConfigLoader
8
+ class << self
9
+ def load(filepath, env = {})
10
+ config_file = YAML.safe_load_file(filepath, symbolize_names: true)
11
+
12
+ # Environment variables override config file.
13
+ config_data = config_file.merge(env) do |_key, old_value, new_value|
14
+ new_value.nil? ? old_value : new_value
15
+ end
16
+
17
+ OpenStruct.new(config_data)
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
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,53 @@
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
+ @last_frame = current_timestamp
23
+ @renderer.render(screen_size: @screen_size)
24
+ end
25
+
26
+ def setup
27
+ print "\e[?25l" # Hide cursor.
28
+ system 'clear'
29
+
30
+ resize
31
+
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.
38
+ system 'clear'
39
+ exit
40
+ end
41
+ end
42
+
43
+ def resize
44
+ row_count, column_count = IO.console.winsize
45
+ @screen_size = { row_count:, column_count: }
46
+ end
47
+
48
+ private
49
+
50
+ def current_timestamp
51
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
52
+ end
53
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Low
4
+ module Loop
5
+ VERSION = '0.8.1'
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lowloop
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.8.1
5
+ platform: ruby
6
+ authors:
7
+ - maedi
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: io-stream
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: paint
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: protocol-http
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: protocol-url
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: lowevent
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: lowtype
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '1.0'
124
+ description: An asynchronous server that creates events for your event-driven application
125
+ to use
126
+ email:
127
+ - maediprichard@gmail.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - lib/connections/connection_manager.rb
133
+ - lib/connections/request_parser.rb
134
+ - lib/events/file_event.rb
135
+ - lib/lowloop.rb
136
+ - lib/responses/file_response.rb
137
+ - lib/responses/response_builder.rb
138
+ - lib/responses/response_factory.rb
139
+ - lib/servers/file_server.rb
140
+ - lib/states/file_state.rb
141
+ - lib/support/config_loader.rb
142
+ - lib/support/low_frame.rb
143
+ - lib/version.rb
144
+ homepage: https://github.com/low-rb/lowloop
145
+ licenses: []
146
+ metadata:
147
+ homepage_uri: https://github.com/low-rb/lowloop
148
+ source_code_uri: https://github.com/low-rb/lowloop/src/branch/main
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: 3.4.0
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubygems_version: 4.0.6
164
+ specification_version: 4
165
+ summary: An event-driven event loop
166
+ test_files: []