syntropy 0.29.0 → 0.30.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: b63b4a2ba22db6a8edcb7fc01dbbc67ba36e7a6392bcb2d5cec18fbf03e961c9
4
- data.tar.gz: 06b6347df280046f31da969514d9a844c3b6197f6e0a2cc73ddc804e0861a320
3
+ metadata.gz: 70dae64fe246aa0851a582ff914d647bcfc650755a237ef476a37de8fd0cc052
4
+ data.tar.gz: 9fde1a73c1136b316f4a5a42d7d0e359df76f4a7015146a5e4c0583eb22c9f76
5
5
  SHA512:
6
- metadata.gz: 7c66c3ab9254e638dd0e0705680fb9d395ff87c539b2e55cc6005f5e70e7bb11d64b534c85e1ef06052ce194a70423b118872715c13e90b2e0c7082ecd2566fb
7
- data.tar.gz: 9bb15db34ee78a8a6c8d2e4d87459368e85525d617ded497b78d1d693473f580877a9b177ee5181249a1e16d1c0352eb8d3afc670e3a211454ebaa828f86f107
6
+ metadata.gz: 2a5561fd30d062766fbb5e5df5ab80b1a6840b5a0bb29c2b1bc0e9a3a7872462face8a9e908d72f1a5426f5ad781b91adc079ca2412efbe95071b68f76c030d6
7
+ data.tar.gz: 22708dd2c98a250068613cbc1d54157ec5673100d2d4df2df216fa353085cbc13c696ae3903a527926c03c17ba746a145c1ff70f0aefddf7fd8996d60527d263
@@ -29,5 +29,5 @@ jobs:
29
29
  ruby-version: ${{matrix.ruby}}
30
30
  bundler-cache: true # 'bundle install' and cache
31
31
  - name: Run tests
32
- # run: bundle exec ruby test/test_um.rb --name test_read_each_raising_2
33
- run: bundle exec rake test
32
+ run: bundle exec ruby test/run.rb --verbose
33
+ # run: bundle exec rake test
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ # 0.30.0 2026-05-10
2
+
3
+ - Refactor HTTP modules
4
+ - Integrate Qeweney::Request
5
+
1
6
  # 0.29.0 2026-05-07
2
7
 
3
8
  - Use UM#file_watch for dev mode
data/README.md CHANGED
@@ -48,8 +48,6 @@ Syntropy is based on:
48
48
 
49
49
  - [UringMachine](https://github.com/digital-fabric/uringmachine) - a lean mean
50
50
  [io_uring](https://unixism.net/loti/what_is_io_uring.html) machine for Ruby.
51
- - [Qeweney](https://github.com/digital-fabric/qeweney) a uniform interface for
52
- working with HTTP requests and responses.
53
51
  - [Papercraft](https://github.com/digital-fabric/papercraft) HTML templating with plain Ruby.
54
52
  - [Extralite](https://github.com/digital-fabric/extralite) a fast and innovative
55
53
  SQLite wrapper for Ruby.
data/lib/syntropy/app.rb CHANGED
@@ -3,12 +3,12 @@
3
3
  require 'json'
4
4
  require 'yaml'
5
5
 
6
- require 'qeweney'
7
6
  require 'papercraft'
8
7
 
9
8
  require 'syntropy/errors'
10
9
  require 'syntropy/module'
11
10
  require 'syntropy/routing_tree'
11
+ require 'syntropy/mime_types'
12
12
 
13
13
  module Syntropy
14
14
  class App
@@ -59,14 +59,14 @@ module Syntropy
59
59
  # error message, and with the appropriate HTTP status code, according to the
60
60
  # type of error.
61
61
  #
62
- # @param req [Qeweney::Request] HTTP request
62
+ # @param req [Syntropy::Request] HTTP request
63
63
  # @return [void]
64
64
  def call(req)
65
65
  path = req.path
66
66
  route = @router_proc.(path, req.route_params)
67
67
  if !route
68
68
  if (m = path.match(/^(.+)\/$/))
69
- return req.redirect(m[1], Qeweney::Status::MOVED_PERMANENTLY)
69
+ return req.redirect(m[1], HTTP::MOVED_PERMANENTLY)
70
70
  else
71
71
  return handle_not_found(req)
72
72
  end
@@ -106,7 +106,7 @@ module Syntropy
106
106
  # Handles a not found error, taking into account hooks up the tree from the
107
107
  # request path.
108
108
  #
109
- # @param req [Qeweney::Reqest] request
109
+ # @param req [Syntropy::Reqest] request
110
110
  # @return [void]
111
111
  def handle_not_found(req)
112
112
  closest_uptree_route = find_first_uptree_route(File.dirname(req.path))
@@ -188,7 +188,7 @@ module Syntropy
188
188
  # @return [Proc] route handler proc
189
189
  def static_route_proc(route)
190
190
  fn = route[:target][:fn]
191
- headers = { 'Content-Type' => Qeweney::MimeTypes[File.extname(fn)] }
191
+ headers = { 'Content-Type' => MimeTypes[File.extname(fn)] }
192
192
 
193
193
  ->(req) {
194
194
  case req.method
@@ -204,7 +204,7 @@ module Syntropy
204
204
 
205
205
  # Serves a static file from the given target hash with cache validation.
206
206
  #
207
- # @param req [Qeweney::Request] request
207
+ # @param req [Syntropy::Request] request
208
208
  # @param target [Hash] route target hash
209
209
  # @return [void]
210
210
  def serve_static_file(req, target)
@@ -253,7 +253,7 @@ module Syntropy
253
253
  target[:last_modified] = mtime
254
254
  target[:last_modified_date] = Time.at(mtime).httpdate
255
255
  target[:content] = buffer = String.new(capacity: size)
256
- target[:mime_type] = Qeweney::MimeTypes[File.extname(target[:fn])]
256
+ target[:mime_type] = MimeTypes[File.extname(target[:fn])]
257
257
  len = 0
258
258
  while len < size
259
259
  len += @machine.read(fd, buffer, size, len)
@@ -498,7 +498,7 @@ module Syntropy
498
498
  @module_loader.invalidate_fn(fn)
499
499
  debounce_file_change
500
500
  }
501
-
501
+
502
502
 
503
503
 
504
504
  # Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'qeweney'
3
+ require 'syntropy/http/status'
4
4
 
5
5
  module Syntropy
6
6
  # The base Syntropy error class
7
7
  class Error < StandardError
8
- Status = Qeweney::Status
9
-
10
8
  # By default, the HTTP status for errors is 500 Internal Server Error.
11
- DEFAULT_STATUS = Status::INTERNAL_SERVER_ERROR
9
+ DEFAULT_STATUS = HTTP::INTERNAL_SERVER_ERROR
12
10
 
13
11
  # Returns the HTTP status for the given exception.
14
12
  #
@@ -24,26 +22,26 @@ module Syntropy
24
22
  # @param err [Exception] error
25
23
  # @return [bool]
26
24
  def self.log_error?(err)
27
- http_status(err) != Status::NOT_FOUND
25
+ http_status(err) != HTTP::NOT_FOUND
28
26
  end
29
27
 
30
28
  # Creates an error with status 404 Not Found.
31
29
  #
32
30
  # @param msg [String] error message
33
31
  # @return [Syntropy::Error]
34
- def self.not_found(msg = 'Not found') = new(msg, Status::NOT_FOUND)
32
+ def self.not_found(msg = 'Not found') = new(msg, HTTP::NOT_FOUND)
35
33
 
36
34
  # Creates an error with status 405 Method Not Allowed.
37
35
  #
38
36
  # @param msg [String] error message
39
37
  # @return [Syntropy::Error]
40
- def self.method_not_allowed(msg = 'Method not allowed') = new(msg, Status::METHOD_NOT_ALLOWED)
38
+ def self.method_not_allowed(msg = 'Method not allowed') = new(msg, HTTP::METHOD_NOT_ALLOWED)
41
39
 
42
40
  # Creates an error with status 418 I'm a teapot.
43
41
  #
44
42
  # @param msg [String] error message
45
43
  # @return [Syntropy::Error]
46
- def self.teapot(msg = 'I\'m a teapot') = new(msg, Status::TEAPOT)
44
+ def self.teapot(msg = 'I\'m a teapot') = new(msg, HTTP::TEAPOT)
47
45
 
48
46
  attr_reader :http_status
49
47
 
@@ -61,26 +59,29 @@ module Syntropy
61
59
  #
62
60
  # @return [Integer, String] HTTP status
63
61
  def http_status
64
- @http_status || Status::INTERNAL_SERVER_ERROR
62
+ @http_status || HTTP::INTERNAL_SERVER_ERROR
65
63
  end
66
64
  end
67
65
 
68
66
  # ValidationError is raised when a validation has failed.
69
67
  class ValidationError < Error
70
68
  def initialize(msg)
71
- super(msg, Status::BAD_REQUEST)
69
+ super(msg, HTTP::BAD_REQUEST)
72
70
  end
73
71
  end
74
72
 
75
73
  class ProtocolError < Error
76
74
  def http_status
77
- Qeweney::Status::BAD_REQUEST
75
+ HTTP::BAD_REQUEST
78
76
  end
79
77
  end
80
78
 
81
79
  class UnsupportedHTTPVersionError < ProtocolError
82
80
  def http_status
83
- Qeweney::Status::HTTP_VERSION_NOT_SUPPORTED
81
+ HTTP::HTTP_VERSION_NOT_SUPPORTED
84
82
  end
85
83
  end
84
+
85
+ class BadRequestError < Error
86
+ end
86
87
  end
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/errors'
4
+
5
+ module Syntropy
6
+ module HTTP
7
+ # Implements an HTTP/1.1 connection received by the Syntropy server. This
8
+ # implementation rejects incoming HTTP/0.9 or HTTP/1.0 requests. The response
9
+ # body is sent exclusively using chunked transfer encoding. Request bodies are
10
+ # accepted using either fixed length (Content-Length header) or chunked
11
+ # transfer encoding.
12
+ class Connection
13
+ attr_reader :fd, :response_headers, :logger
14
+
15
+ def initialize(server, machine, fd, env, &app)
16
+ @server = server
17
+ @machine = machine
18
+ @fd = fd
19
+ @env = env
20
+ @logger = env[:logger]
21
+ @io = machine.io(fd, :socket)
22
+ @app = app
23
+
24
+ @done = nil
25
+ @response_headers = nil
26
+ end
27
+
28
+ def run
29
+ loop do
30
+ @done = nil
31
+ @response_headers = nil
32
+ persist = serve_request
33
+ break if !persist
34
+ end
35
+ rescue UM::Terminate
36
+ # server is terminated, do nothing
37
+ rescue StandardError => e
38
+ @logger&.error(
39
+ message: 'Uncaught error while running connection',
40
+ error: e
41
+ )
42
+ ensure
43
+ @machine.close_async(@fd)
44
+ end
45
+
46
+ # Processes an incoming request by parsing the headers, creating a request
47
+ # object and handing it off to the app handler. Returns true if the
48
+ # connection should be persisted.
49
+ def serve_request
50
+ @closed = nil
51
+ headers = parse_headers
52
+ return false if !headers
53
+
54
+ request = Syntropy::Request.new(headers, self)
55
+
56
+ @app.call(request)
57
+ persist_connection?(headers)
58
+ rescue StandardError => e
59
+ handle_error(request, e)
60
+ false
61
+ end
62
+
63
+ # Handles an error encountered while serving a request by logging the error
64
+ # and optionally sending an error response with the relevant HTTP status
65
+ # code. For I/O errors, no response is sent.
66
+ #
67
+ # @param request [Syntropy::Request] HTTP request
68
+ # @param err [Exception] error
69
+ # @return [void]
70
+ def handle_error(request, err)
71
+ case err
72
+ when SystemCallError
73
+ log_error(err, 'I/O error')
74
+ false
75
+ when ProtocolError
76
+ log_error(err, err.message)
77
+ respond(request, err.message, ':status' => err.http_status)
78
+ else
79
+ log_error(err, 'Internal error')
80
+ return if !request || @done
81
+
82
+ respond(request, 'Internal server error', ':status' => INTERNAL_SERVER_ERROR)
83
+ end
84
+ end
85
+
86
+ # Logs the given err and given message.
87
+ #
88
+ # @param err [Exception] error
89
+ # @param message [String] error message
90
+ # @return [void]
91
+ def log_error(err, message)
92
+ @logger&.error(message: "#{message}, closing connection", error: err)
93
+ end
94
+
95
+ def get_body(req)
96
+ headers = req.headers
97
+ return nil if headers[':body-done-reading']
98
+
99
+ content_length = headers['content-length']
100
+ if content_length
101
+
102
+ chunk = @io.read(content_length.to_i)
103
+ headers[':body-done-reading'] = true
104
+ return chunk
105
+ end
106
+
107
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
108
+ if chunked_encoding
109
+ buf = +''
110
+ while (chunk = read_chunk(headers, nil))
111
+ buf << chunk
112
+ end
113
+ headers[':body-done-reading'] = true
114
+ return buf
115
+ end
116
+
117
+ nil
118
+ end
119
+
120
+ def get_body_chunk(req)
121
+ headers = req.headers
122
+ content_length = headers['content-length']
123
+ if content_length
124
+ return nil if headers[':body-done-reading']
125
+
126
+ chunk = @io.read(content_length.to_i)
127
+ headers[':body-done-reading'] = true
128
+ return chunk
129
+ end
130
+
131
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
132
+ return read_chunk(headers, nil) if chunked_encoding
133
+
134
+ return nil if headers[':body-done-reading']
135
+
136
+ # if content-length is not specified, we read to EOF, up to max 1MB size
137
+ chunk = read(1 << 20, nil, false)
138
+ headers[':body-done-reading'] = true
139
+ chunk
140
+ end
141
+
142
+ def complete?(req)
143
+ req.headers[':body-done-reading']
144
+ end
145
+
146
+ # response API
147
+
148
+ # Sets response headers before sending any response. This method is used to
149
+ # add headers such as Set-Cookie or cache control headers to a response
150
+ # before actually responding, specifically in middleware hooks.
151
+ #
152
+ # @param headers [Hash] response headers
153
+ # @return [void]
154
+ def set_response_headers(headers)
155
+ @response_headers ? @response_headers.merge!(headers) : @response_headers = headers
156
+ end
157
+
158
+ def set_cookie(*cookies)
159
+ existing_cookies = @response_headers && @response_headers['Set-Cookie']
160
+ if existing_cookies
161
+ @response_headers['Set-Cookie'] = existing_cookies + cookies
162
+ else
163
+ set_response_headers('Set-Cookie' => cookies)
164
+ end
165
+ end
166
+
167
+ SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
168
+
169
+ EMPTY_CHUNK = "0\r\n\r\n"
170
+ EMPTY_CHUNK_LEN = EMPTY_CHUNK.bytesize
171
+
172
+ CHUNKED_ENCODING_POSTLUDE = "\r\n#{EMPTY_CHUNK}"
173
+
174
+ # Sends response including headers and body. Waits for the request to complete
175
+ # if not yet completed. The body is sent using chunked transfer encoding.
176
+ # @param request [Syntropy::Request] HTTP request
177
+ # @param body [String] response body
178
+ # @param headers
179
+ def respond(request, body, headers)
180
+ headers = @response_headers.merge(headers) if @response_headers
181
+
182
+ formatted_headers = format_headers(headers, body)
183
+ @response_headers = headers
184
+ request&.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
185
+ if body
186
+ chunk_prelude = "#{body.bytesize.to_s(16)}\r\n"
187
+ @machine.sendv(@fd, formatted_headers, chunk_prelude, body, CHUNKED_ENCODING_POSTLUDE)
188
+ else
189
+ @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
190
+ end
191
+ @logger&.info(request: request, response_headers: headers) if request
192
+ @done = true
193
+ end
194
+
195
+ # Sends response headers. If empty_response is truthy, the response status
196
+ # code will default to 204, otherwise to 200.
197
+ # @param request [Syntropy::Request] HTTP request
198
+ # @param headers [Hash] response headers
199
+ # @param empty_response [boolean] whether a response body will be sent
200
+ # @return [void]
201
+ def send_headers(request, headers, empty_response: false)
202
+ formatted_headers = format_headers(headers, !empty_response)
203
+ request.tx_incr(formatted_headers.bytesize)
204
+ @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
205
+ @response_headers = headers
206
+ end
207
+
208
+ # Sends a response body chunk. If no headers were sent, default headers are
209
+ # sent using #send_headers. if the done option is true(thy), an empty chunk
210
+ # will be sent to signal response completion to the client.
211
+ # @param request [Syntropy::Request] HTTP request
212
+ # @param chunk [String] response body chunk
213
+ # @param done [boolean] whether the response is completed
214
+ # @return [void]
215
+ def send_chunk(request, chunk, done: false)
216
+ data = +''
217
+ data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk
218
+ data << EMPTY_CHUNK if done
219
+ return if data.empty?
220
+
221
+ request.tx_incr(data.bytesize)
222
+ @machine.send(@fd, data, data.bytesize, SEND_FLAGS)
223
+ return if @done || !done
224
+
225
+ @logger&.info(request: request, response_headers: @response_headers)
226
+ @done = true
227
+ end
228
+
229
+ # Finishes the response to the current request. If no headers were sent,
230
+ # default headers are sent using #send_headers.
231
+ # @return [void]
232
+ def finish(request)
233
+ request.tx_incr(EMPTY_CHUNK_LEN)
234
+ @machine.send(@fd, EMPTY_CHUNK, EMPTY_CHUNK_LEN, SEND_FLAGS)
235
+ return if @done
236
+
237
+ @logger&.info(request, request, response_headers: @response_headers)
238
+ @done = true
239
+ end
240
+
241
+ def respond_with_static_file(req, path, env, cache_headers)
242
+ fd = @machine.open(path, UM::O_RDONLY)
243
+ env ||= {}
244
+ if env[:headers]
245
+ env[:headers].merge!(cache_headers)
246
+ else
247
+ env[:headers] = cache_headers
248
+ end
249
+
250
+ maxlen = env[:max_len] || 65_536
251
+ buf = String.new(capacity: maxlen)
252
+ headers_sent = nil
253
+ loop do
254
+ res = @machine.read(fd, buf, maxlen, 0)
255
+ if res < maxlen && !headers_sent
256
+ return respond(req, buf, env[:headers])
257
+ elsif res == 0
258
+ return finish(req)
259
+ end
260
+
261
+ if !headers_sent
262
+ send_headers(req, env[:headers])
263
+ headers_sent = true
264
+ end
265
+ done = res < maxlen
266
+ send_chunk(req, buf, done: done)
267
+ return if done
268
+ end
269
+ end
270
+
271
+ def close
272
+ return if @closed
273
+
274
+ @closed = true
275
+ @machine.shutdown(@fd, UM::SHUT_WR)
276
+ @machine.close_async(@fd)
277
+ end
278
+
279
+ def with_stream
280
+ yield @io, @fd
281
+ end
282
+
283
+ private
284
+
285
+ RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+http\/([019\.]{1,3})/i
286
+ RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
287
+ MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
288
+ MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
289
+ MAX_CHUNK_SIZE_LEN = 16
290
+
291
+ def persist_connection?(headers)
292
+ connection = headers['connection']&.downcase
293
+ return connection != 'close'
294
+ end
295
+
296
+ def parse_headers
297
+ headers = get_request_line(MAX_REQUEST_LINE_LEN)
298
+ return nil if !headers
299
+
300
+ loop do
301
+ line = @io.read_line(MAX_HEADER_LINE_LEN)
302
+ break if line.nil? || line.empty?
303
+
304
+ m = line.match(RE_HEADER_LINE)
305
+ raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
306
+
307
+ headers[m[1].downcase] = m[2]
308
+ end
309
+
310
+ headers
311
+ end
312
+
313
+ def get_request_line(buf)
314
+ line = @io.read_line(MAX_REQUEST_LINE_LEN)
315
+ return nil if !line
316
+
317
+ m = line.match(RE_REQUEST_LINE)
318
+ raise ProtocolError, 'Invalid request line' if !m
319
+
320
+ http_version = m[3]
321
+ raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
322
+
323
+ {
324
+ ':method' => m[1].downcase,
325
+ ':path' => m[2],
326
+ ':protocol' => 'http/1.1'
327
+ }
328
+ end
329
+
330
+ def read_chunk(headers, buffer)
331
+ chunk_size_str = @io.read_line(MAX_CHUNK_SIZE_LEN)
332
+ return nil if !chunk_size_str
333
+
334
+ chunk_size = chunk_size_str.to_i(16)
335
+ if chunk_size == 0
336
+ headers[':body-done-reading'] = true
337
+ @io.read_line(0)
338
+ return nil
339
+ end
340
+
341
+ chunk = @io.read(chunk_size)
342
+ @io.read_line(0)
343
+
344
+ buffer ? (buffer << chunk) : chunk
345
+ end
346
+
347
+ INTERNAL_HEADER_REGEXP = /^:/
348
+
349
+ # Formats response headers into an array. If empty_response is true(thy),
350
+ # the response status code will default to 204, otherwise to 200.
351
+ # @param headers [Hash] response headers
352
+ # @param body [boolean] whether a response body will be sent
353
+ # @return [String] formatted response headers
354
+ def format_headers(headers, body)
355
+ status = headers[':status'] || (body ? OK : NO_CONTENT)
356
+ lines = format_status_line(body, status)
357
+ lines << @env[:server_headers] if @env[:server_headers]
358
+ headers.each do |k, v|
359
+ next if k =~ INTERNAL_HEADER_REGEXP
360
+
361
+ collect_header_lines(lines, k, v)
362
+ end
363
+ lines << "\r\n"
364
+ lines
365
+ end
366
+
367
+ def format_status_line(body, status)
368
+ if !body
369
+ empty_status_line(status)
370
+ else
371
+ with_body_status_line(status, body)
372
+ end
373
+ end
374
+
375
+ def empty_status_line(status)
376
+ if status == 204
377
+ +"HTTP/1.1 #{status}\r\n"
378
+ else
379
+ +"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n"
380
+ end
381
+ end
382
+
383
+ def with_body_status_line(status, body)
384
+ +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
385
+ end
386
+
387
+ def collect_header_lines(lines, key, value)
388
+ if value.is_a?(Array)
389
+ value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" }
390
+ else
391
+ lines << "#{key}: #{value}\r\n"
392
+ end
393
+ end
394
+ end
395
+ end
396
+ end