syntropy 0.29.0 → 0.31.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +2 -2
  3. data/CHANGELOG.md +22 -0
  4. data/README.md +0 -2
  5. data/bin/syntropy +8 -86
  6. data/cmd/_banner.rb +16 -0
  7. data/cmd/help.rb +12 -0
  8. data/cmd/serve.rb +95 -0
  9. data/cmd/test.rb +40 -0
  10. data/examples/{counter.rb → basic/counter.rb} +1 -1
  11. data/examples/{templates.rb → basic/templates.rb} +1 -1
  12. data/examples/mcp-oauth/.ruby-version +1 -0
  13. data/examples/mcp-oauth/Gemfile +8 -0
  14. data/examples/mcp-oauth/README.md +128 -0
  15. data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
  16. data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
  17. data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
  18. data/examples/mcp-oauth/app/index.md +1 -0
  19. data/examples/mcp-oauth/app/mcp.rb +38 -0
  20. data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
  21. data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
  22. data/examples/mcp-oauth/app/oauth/register.rb +15 -0
  23. data/examples/mcp-oauth/app/oauth/token.rb +79 -0
  24. data/examples/mcp-oauth/app/signin.rb +85 -0
  25. data/examples/mcp-oauth/test/helper.rb +9 -0
  26. data/examples/mcp-oauth/test/test_app.rb +27 -0
  27. data/examples/mcp-oauth/test/test_oauth.rb +628 -0
  28. data/lib/syntropy/app.rb +23 -12
  29. data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
  30. data/lib/syntropy/applets/builtin/req.rb +1 -1
  31. data/lib/syntropy/dev_mode.rb +1 -1
  32. data/lib/syntropy/errors.rb +19 -12
  33. data/lib/syntropy/http/client.rb +43 -0
  34. data/lib/syntropy/http/client_connection.rb +36 -0
  35. data/lib/syntropy/http/io_extensions.rb +148 -0
  36. data/lib/syntropy/http/server.rb +174 -0
  37. data/lib/syntropy/http/server_connection.rb +367 -0
  38. data/lib/syntropy/http/status.rb +76 -0
  39. data/lib/syntropy/http.rb +7 -0
  40. data/lib/syntropy/json_api.rb +2 -5
  41. data/lib/syntropy/logger.rb +5 -1
  42. data/lib/syntropy/mime_types.rb +37 -0
  43. data/lib/syntropy/papercraft_extensions.rb +1 -1
  44. data/lib/syntropy/request/mock_adapter.rb +60 -0
  45. data/lib/syntropy/request/request_info.rb +255 -0
  46. data/lib/syntropy/request/response.rb +206 -0
  47. data/lib/syntropy/request/validation.rb +146 -0
  48. data/lib/syntropy/request.rb +99 -0
  49. data/lib/syntropy/routing_tree.rb +2 -1
  50. data/lib/syntropy/test.rb +65 -0
  51. data/lib/syntropy/utils.rb +1 -1
  52. data/lib/syntropy/version.rb +1 -1
  53. data/lib/syntropy.rb +4 -27
  54. data/syntropy.gemspec +2 -4
  55. data/test/app/.well-known/foo.rb +3 -0
  56. data/test/app/about/_error.rb +1 -1
  57. data/test/app/api+.rb +1 -1
  58. data/test/app_custom/_site.rb +1 -1
  59. data/test/bm_router_proc.rb +3 -3
  60. data/test/helper.rb +4 -27
  61. data/test/test_app.rb +83 -98
  62. data/test/test_caching.rb +2 -2
  63. data/test/test_errors.rb +6 -6
  64. data/test/test_http_client.rb +52 -0
  65. data/test/test_http_client_connection.rb +43 -0
  66. data/test/{test_connection.rb → test_http_server_connection.rb} +32 -32
  67. data/test/test_json_api.rb +14 -12
  68. data/test/test_mock_adapter.rb +59 -0
  69. data/test/{test_request_extensions.rb → test_request.rb} +150 -18
  70. data/test/test_response.rb +112 -0
  71. data/test/test_routing_tree.rb +15 -3
  72. data/test/test_server.rb +1 -1
  73. metadata +57 -35
  74. data/lib/syntropy/connection.rb +0 -402
  75. data/lib/syntropy/request_extensions.rb +0 -308
  76. data/lib/syntropy/server.rb +0 -173
  77. /data/examples/{bad.rb → basic/bad.rb} +0 -0
  78. /data/examples/{card.rb → basic/card.rb} +0 -0
  79. /data/examples/{counter.js → basic/counter.js} +0 -0
  80. /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
  81. /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
  82. /data/examples/{index.md → basic/index.md} +0 -0
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
@@ -35,6 +35,7 @@ module Syntropy
35
35
  end
36
36
 
37
37
  attr_reader :module_loader, :routing_tree, :root_dir, :mount_path, :env
38
+ attr_accessor :test_mode
38
39
 
39
40
  def initialize(**env)
40
41
  @machine = env[:machine]
@@ -59,14 +60,14 @@ module Syntropy
59
60
  # error message, and with the appropriate HTTP status code, according to the
60
61
  # type of error.
61
62
  #
62
- # @param req [Qeweney::Request] HTTP request
63
+ # @param req [Syntropy::Request] HTTP request
63
64
  # @return [void]
64
65
  def call(req)
65
66
  path = req.path
66
67
  route = @router_proc.(path, req.route_params)
67
68
  if !route
68
69
  if (m = path.match(/^(.+)\/$/))
69
- return req.redirect(m[1], Qeweney::Status::MOVED_PERMANENTLY)
70
+ return req.redirect(m[1], HTTP::MOVED_PERMANENTLY)
70
71
  else
71
72
  return handle_not_found(req)
72
73
  end
@@ -106,7 +107,7 @@ module Syntropy
106
107
  # Handles a not found error, taking into account hooks up the tree from the
107
108
  # request path.
108
109
  #
109
- # @param req [Qeweney::Reqest] request
110
+ # @param req [Syntropy::Reqest] request
110
111
  # @return [void]
111
112
  def handle_not_found(req)
112
113
  closest_uptree_route = find_first_uptree_route(File.dirname(req.path))
@@ -188,7 +189,7 @@ module Syntropy
188
189
  # @return [Proc] route handler proc
189
190
  def static_route_proc(route)
190
191
  fn = route[:target][:fn]
191
- headers = { 'Content-Type' => Qeweney::MimeTypes[File.extname(fn)] }
192
+ headers = { 'Content-Type' => MimeTypes[File.extname(fn)] }
192
193
 
193
194
  ->(req) {
194
195
  case req.method
@@ -204,7 +205,7 @@ module Syntropy
204
205
 
205
206
  # Serves a static file from the given target hash with cache validation.
206
207
  #
207
- # @param req [Qeweney::Request] request
208
+ # @param req [Syntropy::Request] request
208
209
  # @param target [Hash] route target hash
209
210
  # @return [void]
210
211
  def serve_static_file(req, target)
@@ -253,7 +254,7 @@ module Syntropy
253
254
  target[:last_modified] = mtime
254
255
  target[:last_modified_date] = Time.at(mtime).httpdate
255
256
  target[:content] = buffer = String.new(capacity: size)
256
- target[:mime_type] = Qeweney::MimeTypes[File.extname(target[:fn])]
257
+ target[:mime_type] = MimeTypes[File.extname(target[:fn])]
257
258
  len = 0
258
259
  while len < size
259
260
  len += @machine.read(fd, buffer, size, len)
@@ -321,7 +322,7 @@ module Syntropy
321
322
  }
322
323
  body {
323
324
  markdown md
324
- auto_refresh_watch! if @env[:dev_mode]
325
+ auto_refresh! if @env[:dev_mode]
325
326
  }
326
327
  }
327
328
  }
@@ -450,18 +451,28 @@ module Syntropy
450
451
  end
451
452
 
452
453
  RAW_DEFAULT_ERROR_HANDLER = ->(req, err) {
454
+ status = Syntropy::Error.http_status(err)
455
+
453
456
  msg = err.message
454
457
  msg = nil if msg.empty? || (req.method == 'head')
455
- req.respond(msg, ':status' => Syntropy::Error.http_status(err)) rescue nil
458
+ req.respond(msg, ':status' => status) rescue nil
456
459
  }
457
460
 
458
- def default_error_handler
461
+ TEST_MODE_DEFAULT_ERROR_HANDLER = ->(req, err) {
462
+ status = Syntropy::Error.http_status(err)
463
+ raise if status == HTTP::INTERNAL_SERVER_ERROR
464
+
465
+ msg = err.message
466
+ msg = nil if msg.empty? || (req.method == 'head')
467
+ req.respond(msg, ':status' => status) rescue nil
468
+ }
459
469
 
470
+ def default_error_handler
460
471
  @default_error_handler ||= begin
461
472
  if @builtin_applet
462
473
  @builtin_applet.module_loader.load('/default_error_handler')
463
474
  else
464
- RAW_DEFAULT_ERROR_HANDLER
475
+ @test_mode ? TEST_MODE_DEFAULT_ERROR_HANDLER : RAW_DEFAULT_ERROR_HANDLER
465
476
  end
466
477
  end
467
478
  end
@@ -498,7 +509,7 @@ module Syntropy
498
509
  @module_loader.invalidate_fn(fn)
499
510
  debounce_file_change
500
511
  }
501
-
512
+
502
513
 
503
514
 
504
515
  # Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
@@ -23,7 +23,7 @@ ErrorPage = ->(error:, status:, backtrace:) {
23
23
  }
24
24
  end
25
25
  }
26
- auto_refresh_watch!
26
+ auto_refresh!
27
27
  }
28
28
  }
29
29
  }
@@ -32,7 +32,7 @@ def transform_backtrace(backtrace)
32
32
  backtrace.map do
33
33
  if (m = it.match(/^(.+:\d+):/))
34
34
  location = m[1]
35
- { entry: it, url: "vscode://file/#{location}" }
35
+ { entry: it, url: "zed://file/#{location}" }
36
36
  else
37
37
  { entry: it, url: nil }
38
38
  end
@@ -43,7 +43,7 @@ def error_response_html(req, error)
43
43
  status = Syntropy::Error.http_status(error)
44
44
  backtrace = transform_backtrace(error.backtrace)
45
45
  html = Papercraft.html(ErrorPage, error:, status:, backtrace:)
46
- req.html_response(html, ':status' => status)
46
+ req.respond_html(html, ':status' => status)
47
47
  end
48
48
 
49
49
  def error_response_raw(req, error)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  export ->(req) {
4
- req.json_response({
4
+ req.respond_json({
5
5
  headers: req.headers
6
6
  })
7
7
  }
@@ -4,7 +4,7 @@ TAG_DEBUG_PROC = ->(level, fn, line, col) {
4
4
  {
5
5
  'data-syntropy-level' => level,
6
6
  'data-syntropy-fn' => fn,
7
- 'data-syntropy-loc' => "vscode://file/#{fn}:#{line}:#{col}"
7
+ 'data-syntropy-loc' => "zed://file/#{fn}:#{line}:#{col}"
8
8
  }
9
9
  }
10
10
 
@@ -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,35 @@ 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
82
+ end
83
+ end
84
+
85
+ class BadRequestError < Error
86
+ end
87
+
88
+ class InvalidRequestContentTypeError < Error
89
+ def http_status
90
+ HTTP::UNSUPPORTED_MEDIA_TYPE
84
91
  end
85
92
  end
86
93
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/http/client_connection'
4
+ require 'uri'
5
+
6
+ module Syntropy
7
+ module HTTP
8
+ class Client
9
+ def initialize(machine)
10
+ @machine = machine
11
+ end
12
+
13
+ def get(url, **headers, &)
14
+ uri = URI.parse(url)
15
+ headers = headers.merge(
16
+ ':method' => 'GET',
17
+ ':path' => uri.request_uri
18
+ )
19
+ req(uri, **headers, &)
20
+ end
21
+
22
+ private
23
+
24
+ # @param uri [URI]
25
+ def req(uri, **headers)
26
+ connection = make_connection(uri.scheme, uri.host, uri.port)
27
+ response_headers = connection.req(**headers)
28
+ if block_given?
29
+ yield(response_headers, connection)
30
+ else
31
+ [response_headers, connection.get_response_body(response_headers)]
32
+ end
33
+ end
34
+
35
+ def make_connection(_scheme, host, port)
36
+ ip = (host =~ /^\d+\.\d+\.\d+\.\d+$/) ? host : @machine.resolve(host)[0]
37
+
38
+ fd = @machine.tcp_connect(ip, port)
39
+ Syntropy::HTTP::ClientConnection.new(@machine, fd)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/errors'
4
+ require 'syntropy/http/io_extensions'
5
+
6
+ module Syntropy
7
+ module HTTP
8
+ class ClientConnection
9
+ attr_reader :fd, :response_headers, :logger
10
+
11
+ def initialize(machine, fd, io_mode: :socket)
12
+ @machine = machine
13
+ @fd = fd
14
+ @io = machine.io(fd, io_mode)
15
+ end
16
+
17
+ def req(body: nil, **headers)
18
+ if body
19
+ headers = headers.merge(
20
+ 'Content-Length' => body.bytesize
21
+ )
22
+ end
23
+ @io.http_write_request_headers(**headers)
24
+ if body
25
+ @io.write(body)
26
+ end
27
+
28
+ @io.http_read_response_headers
29
+ end
30
+
31
+ def get_response_body(headers)
32
+ @io.http_read_body(headers)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/errors'
4
+
5
+ module Syntropy
6
+ module HTTP
7
+ module ProtocolMethods
8
+ RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+HTTP\/([019\.]{1,3})/i
9
+ RE_RESPONSE_LINE = /^HTTP\/1\.1\s+(\d{3})(\s+.+)?$/i
10
+ RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
11
+
12
+ MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
13
+ MAX_RESPONSE_LINE_LEN = 1 << 8 # 256
14
+ MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
15
+ MAX_CHUNK_SIZE_LEN = 16
16
+
17
+ # @return [Hash] headers
18
+ def http_read_request_headers
19
+ line = read_line(MAX_REQUEST_LINE_LEN)
20
+ return nil if !line
21
+
22
+ m = line.match(RE_REQUEST_LINE)
23
+ raise ProtocolError, 'Invalid request line' if !m
24
+
25
+ http_version = m[3]
26
+ raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
27
+
28
+ headers = {
29
+ ':method' => m[1].downcase,
30
+ ':path' => m[2],
31
+ ':protocol' => 'http/1.1'
32
+ }
33
+
34
+ loop do
35
+ line = read_line(MAX_HEADER_LINE_LEN)
36
+ break if line.nil? || line.empty?
37
+
38
+ m = line.match(RE_HEADER_LINE)
39
+ raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
40
+
41
+ headers[m[1].downcase] = m[2]
42
+ end
43
+
44
+ headers
45
+ end
46
+
47
+ def http_read_response_headers
48
+ line = read_line(MAX_RESPONSE_LINE_LEN)
49
+ return nil if !line
50
+
51
+ m = line.match(RE_RESPONSE_LINE)
52
+ raise ProtocolError, 'Invalid response line' if !m
53
+
54
+ headers = {
55
+ ':status' => m[1].to_i
56
+ }
57
+
58
+ loop do
59
+ line = read_line(MAX_HEADER_LINE_LEN)
60
+ break if line.nil? || line.empty?
61
+
62
+ m = line.match(RE_HEADER_LINE)
63
+ raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
64
+
65
+ k = m[1].downcase
66
+ if (h = headers[k])
67
+ (h = headers[k] = [h]) if !h.is_a?(Array)
68
+ h << m[2]
69
+ else
70
+ headers[k] = m[2]
71
+ end
72
+ end
73
+
74
+ headers
75
+ end
76
+
77
+ def http_read_body(headers)
78
+ content_length = headers['content-length']
79
+ if content_length
80
+ chunk = read(content_length.to_i)
81
+ return chunk
82
+ end
83
+
84
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
85
+ if chunked_encoding
86
+ buf = +''
87
+ while (chunk = http_read_cte_chunk(nil))
88
+ buf << chunk
89
+ end
90
+ return buf
91
+ end
92
+
93
+ nil
94
+ end
95
+
96
+ def http_read_body_chunk(headers)
97
+ content_length = headers['content-length']
98
+ if content_length
99
+ chunk = read(content_length.to_i)
100
+ return chunk
101
+ end
102
+
103
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
104
+ return http_read_cte_chunk(nil) if chunked_encoding
105
+
106
+ nil
107
+ end
108
+
109
+ def http_write_request_headers(headers)
110
+ method = headers[':method'] || (raise BadRequestError)
111
+ path = headers[':path'] || (raise BadRequestError)
112
+
113
+ lines = ["#{method} #{path} HTTP/1.1\r\n"]
114
+ headers.each do |k, v|
115
+ next if k =~ /^\:/
116
+
117
+ if v.is_a?(Array)
118
+ v.each { lines << "#{k}: #{it}\r\n" }
119
+ else
120
+ lines << "#{k}: #{v}\r\n"
121
+ end
122
+ end
123
+ lines << "\r\n"
124
+ write(*lines)
125
+ end
126
+
127
+ private
128
+
129
+ def http_read_cte_chunk(buffer)
130
+ chunk_size_str = read_line(MAX_CHUNK_SIZE_LEN)
131
+ return nil if !chunk_size_str
132
+
133
+ chunk_size = chunk_size_str.to_i(16)
134
+ if chunk_size == 0
135
+ read_line(0)
136
+ return nil
137
+ end
138
+
139
+ chunk = read(chunk_size)
140
+ read_line(0)
141
+
142
+ buffer ? (buffer << chunk) : chunk
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ UringMachine::IO.include(Syntropy::HTTP::ProtocolMethods)
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/http/server_connection'
4
+
5
+ module Syntropy
6
+ module HTTP
7
+ class Server
8
+ PENDING_REQUESTS_GRACE_PERIOD = 0.1
9
+ PENDING_REQUESTS_TIMEOUT_PERIOD = 5
10
+
11
+ def self.syntropy_app(_machine, env)
12
+ if env[:app_location]
13
+ env[:logger]&.info(message: 'Loading web app', location: env[:app_location])
14
+ require env[:app_location]
15
+
16
+ env.merge!(Syntropy.config)
17
+ end
18
+ env[:app]
19
+ end
20
+
21
+ def self.static_app(env); end
22
+
23
+ def initialize(machine, env, &app)
24
+ @machine = machine
25
+ @env = env
26
+ @app = app || app_from_env
27
+ @server_fds = []
28
+ @accept_fibers = []
29
+ end
30
+
31
+ def app_from_env
32
+ case @env[:app_type]
33
+ when nil, :syntropy
34
+ Server.syntropy_app(@machine, @env)
35
+ when :static
36
+ Server.static_app(@env)
37
+ else
38
+ raise "Invalid app type #{@env[:app_type].inspect}"
39
+ end
40
+ end
41
+
42
+ def run
43
+ setup
44
+ @machine.await(@accept_fibers)
45
+ rescue UM::Terminate
46
+ graceful_shutdown
47
+ end
48
+
49
+ def stop!
50
+ graceful_shutdown
51
+ end
52
+
53
+ private
54
+
55
+ def setup
56
+ bind_info = get_bind_entries
57
+ bind_info.each do |(host, port)|
58
+ fd = setup_server_socket(host, port)
59
+ @server_fds << fd
60
+ @accept_fibers << @machine.spin { accept_incoming(fd) }
61
+ end
62
+ bind_string = bind_info.map { it.join(':') }.join(', ')
63
+ @env[:logger]&.info(message: "Listening on #{bind_string}")
64
+ setup_server_extensions
65
+
66
+ # map fibers
67
+ @connection_fibers = Set.new
68
+ end
69
+
70
+ def get_bind_entries
71
+ bind = @env[:bind]
72
+ case bind
73
+ when Array
74
+ bind.map { bind_info(it) }
75
+ when String
76
+ [bind_info(bind)]
77
+ else
78
+ # default
79
+ [['0.0.0.0', 1234]]
80
+ end
81
+ end
82
+
83
+ def bind_info(bind_string)
84
+ parts = bind_string.split(':')
85
+ [parts[0], parts[1].to_i]
86
+ end
87
+
88
+ def setup_server_socket(host, port)
89
+ fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
90
+ @machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
91
+ @machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEPORT, true)
92
+ @machine.bind(fd, host, port)
93
+ @machine.listen(fd, UM::SOMAXCONN)
94
+ fd
95
+ end
96
+
97
+ def setup_server_extensions
98
+ extensions = @env[:server_extensions]
99
+ return if !extensions
100
+
101
+ server_name = extensions[:name]
102
+ if extensions[:date]
103
+ @date_header_fiber = @machine.spin {
104
+ @machine.periodically(1) { update_server_headers(server_name) }
105
+ }
106
+ update_server_headers(server_name)
107
+ elsif server_name
108
+ @env[:server_headers] = "Server: #{server_name}\r\n"
109
+ end
110
+ end
111
+
112
+ def update_server_headers(server_name)
113
+ @env[:server_date] = Time.now
114
+ if server_name
115
+ @env[:server_headers] = "Server: #{server_name}\r\nDate: #{@env[:server_date].httpdate}\r\n"
116
+ else
117
+ @env[:server_headers] = "Date: #{Time.now.httpdate}\r\n"
118
+ end
119
+ end
120
+
121
+ def accept_incoming(listen_fd)
122
+ @machine.accept_each(listen_fd) { start_connection(it) }
123
+ rescue UM::Terminate
124
+ @machine.shutdown(listen_fd, UM::SHUT_RD)
125
+ end
126
+
127
+ def start_connection(fd)
128
+ conn = ServerConnection.new(@machine, fd, @env, &@app)
129
+ f = @machine.spin(conn) do
130
+ it.run
131
+ ensure
132
+ @connection_fibers.delete(f)
133
+ end
134
+ @connection_fibers << f
135
+ end
136
+
137
+ def close_all_server_fds
138
+ @server_fds.each { @machine.close_async(it) }
139
+ end
140
+
141
+ STOP = UM::Terminate.new
142
+
143
+ def stop_accept_fibers
144
+ @accept_fibers.each { @machine.schedule(it, STOP) if !it.done? }
145
+ @machine.await(@accept_fibers)
146
+ end
147
+
148
+ def graceful_shutdown
149
+ @env[:logger]&.info(message: 'Shutting down gracefully...')
150
+
151
+ # stop listening
152
+ close_all_server_fds
153
+ stop_accept_fibers
154
+ @machine.snooze
155
+
156
+ return if @connection_fibers.empty?
157
+
158
+ # sleep for a bit, let requests finish
159
+ @machine.sleep(PENDING_REQUESTS_GRACE_PERIOD)
160
+ return if @connection_fibers.empty?
161
+
162
+ # terminate pending fibers
163
+ pending = @connection_fibers.to_a
164
+ pending.each { @machine.schedule(it, STOP) }
165
+
166
+ @machine.timeout(PENDING_REQUESTS_TIMEOUT_PERIOD, UM::Terminate) do
167
+ @machine.await(@connection_fibers)
168
+ rescue UM::Terminate
169
+ # timeout on waiting for adapters to finish running, do nothing
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end