syntropy 0.38.1 → 0.40.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +2 -0
  4. data/TODO.md +55 -144
  5. data/cmd/console.rb +2 -4
  6. data/cmd/new/template/config/production.rb +2 -1
  7. data/cmd/new.rb +1 -1
  8. data/cmd/serve.rb +29 -4
  9. data/cmd/test.rb +12 -4
  10. data/examples/agent/Dockerfile +25 -0
  11. data/examples/agent/Gemfile +3 -0
  12. data/examples/agent/README.md +40 -0
  13. data/examples/agent/TODO.md +5 -0
  14. data/examples/agent/app/_layout/default.rb +15 -0
  15. data/examples/agent/app/_lib/storage.rb +13 -0
  16. data/examples/agent/app/_schema/2026-01-01-initial.rb +9 -0
  17. data/examples/agent/app/assets/agent.js +63 -0
  18. data/examples/agent/app/assets/minigfm.js +207 -0
  19. data/examples/agent/app/assets/model.js +45 -0
  20. data/examples/agent/app/assets/style.css +25 -0
  21. data/examples/agent/app/assets/tools.js +74 -0
  22. data/examples/agent/app/assets/ui.js +50 -0
  23. data/examples/agent/app/index.rb +19 -0
  24. data/examples/agent/app/test.rb +7 -0
  25. data/examples/agent/config/Caddyfile +5 -0
  26. data/examples/agent/config/development.rb +5 -0
  27. data/examples/agent/config/production.rb +5 -0
  28. data/examples/agent/config/test.rb +5 -0
  29. data/examples/agent/docker-compose.yml +71 -0
  30. data/examples/agent/response_example.json +108 -0
  31. data/examples/agent/test/test_app.rb +14 -0
  32. data/examples/blog/app/foo.rb +1 -0
  33. data/examples/blog/app/posts/[id]/edit.rb +3 -3
  34. data/examples/blog/app/posts/[id]/index.rb +8 -10
  35. data/examples/blog/app/posts/index.rb +6 -8
  36. data/examples/blog/app/posts/new.rb +3 -3
  37. data/examples/blog/config/production.rb +2 -1
  38. data/lib/syntropy/app.rb +12 -60
  39. data/lib/syntropy/applets/builtin/auto_refresh/watch.js +1 -1
  40. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +7 -0
  41. data/lib/syntropy/controller_extensions.rb +1 -1
  42. data/lib/syntropy/dev_mode.rb +8 -3
  43. data/lib/syntropy/http/client.rb +17 -1
  44. data/lib/syntropy/http/client_connection.rb +16 -0
  45. data/lib/syntropy/http/io_extensions.rb +32 -4
  46. data/lib/syntropy/http/server.rb +55 -24
  47. data/lib/syntropy/http/server_connection.rb +72 -2
  48. data/lib/syntropy/logger.rb +8 -0
  49. data/lib/syntropy/markdown.rb +139 -11
  50. data/lib/syntropy/module_loader.rb +110 -68
  51. data/lib/syntropy/request/request_info.rb +8 -0
  52. data/lib/syntropy/request/response.rb +76 -6
  53. data/lib/syntropy/request/validation.rb +6 -0
  54. data/lib/syntropy/storage/connection_pool.rb +34 -0
  55. data/lib/syntropy/test.rb +14 -5
  56. data/lib/syntropy/version.rb +1 -1
  57. data/lib/syntropy.rb +9 -2
  58. data/syntropy.gemspec +4 -4
  59. data/test/fixtures/app/_layout/default.rb +1 -1
  60. data/test/fixtures/app/_layout/kuku.rb +8 -0
  61. data/test/fixtures/app/_lib/circular/a.rb +2 -0
  62. data/test/fixtures/app/_lib/circular/b.rb +2 -0
  63. data/test/fixtures/app/_lib/circular/c.rb +2 -0
  64. data/test/fixtures/app/mod/concurrent.rb +9 -0
  65. data/test/test_app.rb +1 -1
  66. data/test/test_caching.rb +3 -3
  67. data/test/test_markdown.rb +268 -0
  68. data/test/test_module_loader.rb +32 -4
  69. data/test/test_request.rb +7 -0
  70. data/test/test_response.rb +30 -0
  71. data/test/test_schema.rb +1 -0
  72. metadata +40 -11
@@ -8,9 +8,7 @@ def get(req)
8
8
  post = @posts.get(id)
9
9
  raise Syntropy::Error.not_found if !post
10
10
 
11
- req.respond_html(
12
- @template.render(post:, req:)
13
- )
11
+ req.respond_html(@template.render(post:, req:))
14
12
  end
15
13
 
16
14
  def post(req)
@@ -32,15 +30,15 @@ def delete(req)
32
30
  id = req.route_params['id'].to_i
33
31
 
34
32
  deleted = @posts.delete(id)
35
- raise BadRequestError, "Failed to delete post" if deleted != 1
33
+ raise BadRequestError, 'Failed to delete post' if deleted != 1
36
34
 
37
35
  req.flash[:notice] = 'Post was successfully destroyed.'
38
- req.redirect "/posts", Syntropy::HTTP::SEE_OTHER
36
+ req.redirect '..', Syntropy::HTTP::SEE_OTHER
39
37
  end
40
38
 
41
- @template = @layout.apply { |post:, **props|
42
- h1 "My blog"
43
- p props[:req]&.flash[:notice], style: 'color: green'
39
+ @template = @layout.apply { |post:, req:, **props|
40
+ h1 'My blog'
41
+ p req.flash[:notice], style: 'color: green'
44
42
  div {
45
43
  h2 {
46
44
  a post[:title]
@@ -48,9 +46,9 @@ end
48
46
  p post[:body]
49
47
  }
50
48
  p {
51
- a "Edit", href: "/posts/#{post[:id]}/edit"
49
+ a 'Edit', href: req.rel('./edit')
52
50
  span '|'
53
- a "Back to posts", href: '/posts'
51
+ a 'Back to posts', href: req.rel('..')
54
52
  }
55
53
  div {
56
54
  form(method: 'post') {
@@ -5,9 +5,7 @@ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  posts = @posts.get_all
8
- req.respond_html(
9
- @template.render(posts:, req:)
10
- )
8
+ req.respond_html(@template.render(posts:, req:))
11
9
  end
12
10
 
13
11
  def post(req)
@@ -17,16 +15,16 @@ def post(req)
17
15
  id = @posts.create(title, body)
18
16
 
19
17
  req.flash[:notice] = 'Post was successfully created.'
20
- req.redirect("posts/#{id}")
18
+ req.redirect("./#{id}")
21
19
  end
22
20
 
23
- @template = @layout.apply { |**props|
21
+ @template = @layout.apply { |req:, **props|
24
22
  h1 "My awesome blog"
25
- p props[:req]&.flash[:notice], style: 'color: green'
23
+ p req.flash[:notice], style: 'color: green'
26
24
  props[:posts].each { |post|
27
25
  div {
28
26
  h2 {
29
- a post[:title], href: "/posts/#{post[:id]}"
27
+ a post[:title], href: req.rel("./#{post[:id]}")
30
28
  }
31
29
  p post[:body]
32
30
  }
@@ -34,7 +32,7 @@ end
34
32
 
35
33
  div {
36
34
  p {
37
- a "New post", href: '/posts/new'
35
+ a "New post", href: req.rel('./new')
38
36
  }
39
37
  }
40
38
  }
@@ -5,14 +5,14 @@ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  req.respond_html(
8
- @template.render
8
+ @template.render(req:)
9
9
  )
10
10
  end
11
11
 
12
- @template = @layout.apply { |**props|
12
+ @template = @layout.apply { |req:, **props|
13
13
  h1 "Create blog post"
14
14
  div {
15
- form(action: "/posts", method: 'post') {
15
+ form(action: req.rel(".."), method: 'post') {
16
16
  div {
17
17
  label 'Title', for: 'title'
18
18
  input name: 'title', type: 'text'
@@ -1,4 +1,5 @@
1
1
  export(
2
- storage:
2
+ storage: {
3
3
  path: ENV['DATABASE_PATH'] || 'storage/production.db'
4
+ }
4
5
  )
data/lib/syntropy/app.rb CHANGED
@@ -26,7 +26,7 @@ module Syntropy
26
26
  end
27
27
 
28
28
  BUILTIN_APPLET_app_root = File.expand_path(File.join(__dir__, 'applets/builtin'))
29
-
29
+
30
30
  # Creates a builtin applet with the given environment hash. By default the
31
31
  # builtin applet is mounted at /.syntropy.
32
32
  #
@@ -102,6 +102,7 @@ module Syntropy
102
102
  # @param req [Syntropy::Request] HTTP request
103
103
  # @return [void]
104
104
  def call(req)
105
+ # before = GC.stat(:total_allocated_objects)
105
106
  path = req.path
106
107
  route = @router_proc.(path, req.route_params)
107
108
  if !route
@@ -126,6 +127,9 @@ module Syntropy
126
127
  end
127
128
  error_handler = get_error_handler(route)
128
129
  error_handler.(req, e)
130
+ # ensure
131
+ # after = GC.stat(:total_allocated_objects)
132
+ # @logger&.debug(message: "Allocated: #{after - before}")
129
133
  end
130
134
 
131
135
  # Returns the route entry for the given path. If compute_proc is true,
@@ -251,6 +255,8 @@ module Syntropy
251
255
  }
252
256
  end
253
257
 
258
+ DEFAULT_CACHE_CONTROL = 'max-age=604800' # one week
259
+
254
260
  # Serves a static file from the given target hash with cache validation.
255
261
  #
256
262
  # @param req [Syntropy::Request] request
@@ -259,7 +265,7 @@ module Syntropy
259
265
  def serve_static_file(req, target)
260
266
  validate_static_file_info(target)
261
267
  cache_opts = {
262
- cache_control: 'max-age=3600',
268
+ cache_control: DEFAULT_CACHE_CONTROL,
263
269
  last_modified: target[:last_modified_date],
264
270
  etag: target[:etag]
265
271
  }
@@ -317,63 +323,9 @@ module Syntropy
317
323
  # @param route [Hash] route entry
318
324
  # @return [Proc] route proc
319
325
  def markdown_route_proc(route)
320
- headers = { 'Content-Type' => 'text/html' }
321
-
322
- ->(req) {
323
- req.respond_by_http_method(
324
- 'head' => [nil, headers],
325
- 'get' => -> { [render_markdown(route), headers] }
326
- )
327
- }
328
- end
329
-
330
- # Renders and returns the given markdown route as HTML.
331
- #
332
- # @param route [Hash] route entry
333
- # @return [String] rendered HTML
334
- def render_markdown(route)
335
- atts, md = Syntropy::Markdown.parse(route[:target][:fn], @env)
336
-
337
- layout = compute_markdown_layout(route, atts)
338
- Papercraft.html(layout, md:, **atts)
339
- end
340
-
341
- def compute_markdown_layout(route, atts)
342
- if (layout = atts[:layout])
343
- route[:applied_layouts] ||= {}
344
- route[:applied_layouts][layout] ||= markdown_layout_template(layout)
345
- else
346
- default_markdown_layout_template
347
- end
348
- end
349
-
350
- # Returns a markdown template based on the given layout.
351
- #
352
- # @param layout [String] layout name
353
- # @return [Proc] layout template
354
- def markdown_layout_template(layout)
355
- @layouts ||= {}
356
- template = @module_loader.load("_layout/#{layout}")
357
- @layouts[layout] = Papercraft.apply(template) { |md:, **| markdown(md) }
358
- end
359
-
360
- # Returns the default markdown layout, which renders to HTML and includes a
361
- # title, the markdown content, and emits code for auto refreshing the page
362
- # on file change.
363
- #
364
- # @return [Proc] default Markdown layout template
365
- def default_markdown_layout_template
366
- @default_markdown_layout ||= ->(md:, **atts) {
367
- html5 {
368
- head {
369
- title atts[:title]
370
- }
371
- body {
372
- markdown md
373
- auto_refresh! if Syntropy.dev_mode
374
- }
375
- }
376
- }
326
+ env = @env.merge(module_loader: @module_loader)
327
+ atts, md = Syntropy::Markdown.parse_file(route[:target][:fn], env)
328
+ Syntropy::Markdown.make_controller(env, atts, md)
377
329
  end
378
330
 
379
331
  # Returns the route proc for a module route.
@@ -535,7 +487,7 @@ module Syntropy
535
487
  #
536
488
  # @return [void]
537
489
  def start
538
- @module_loader.load('_setup', raise_on_missing: false)
490
+ @module_loader.load('_setup', raise_on_missing_export: false)
539
491
 
540
492
  @machine.spin do
541
493
  # we do startup stuff asynchronously, in order to first let Syntropy do
@@ -7,6 +7,6 @@
7
7
  if (msg.data != '') window.location.reload();
8
8
  })
9
9
  eventSource.addEventListener('error', () => {
10
- console.log(`Failed to connect to auto refresh watcher (${sseURL})`);
10
+ // console.log(`Failed to connect to auto refresh watcher (${sseURL})`);
11
11
  })
12
12
  })()
@@ -9,11 +9,15 @@
9
9
  # residing by default at `/.syntropy/auto_refresh/watch.js`.
10
10
 
11
11
  # Returns a hash holding references to queues for ongoing `watch.sse` requests.
12
+ #
13
+ # @return [Hash] hash of watchers
12
14
  def watchers
13
15
  @watchers ||= {}
14
16
  end
15
17
 
16
18
  # Signals a file change by pushing to all watcher queues.
19
+ #
20
+ # @return [void]
17
21
  def signal!
18
22
  watchers.each_key { @machine.push(it, true) }
19
23
  end
@@ -21,6 +25,9 @@ end
21
25
  # Handles incoming requests to the `watch.sse` route. Adds a queue to the list
22
26
  # of watchers, and waits for the queue to be signalled. In the absence of file
23
27
  # change, a timeout occurs after one minute, and the request is terminated.
28
+ #
29
+ # @param req [Syntropy::Request] request
30
+ # @return [void]
24
31
  def call(req)
25
32
  queue = UM::Queue.new
26
33
  watchers[queue] = true
@@ -53,7 +53,7 @@ module Syntropy
53
53
  raise 'Not a directory' if !File.directory?(full_path)
54
54
 
55
55
  Dir[File.join(full_path, '*.md')].sort.map {
56
- atts, markdown = Syntropy::Markdown.parse(it, @env)
56
+ atts, markdown = Syntropy::Markdown.parse_file(it, @env)
57
57
  { atts:, markdown: }
58
58
  }
59
59
  end
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- TAG_DEBUG_PROC = ->(level, fn, line, col) {
3
+ # Injects debug information into Papercraft emitted HTML tags.
4
+ #
5
+ # @param level [Integer] nesting level
6
+ # @param fn [String] source filename
7
+ # @param line [Integer] source line
8
+ # @param col [Integer] source column
9
+ # @return [Hash] HTML data attributes
10
+ Papercraft::Compiler.html_debug_attribute_injector = ->(level, fn, line, col) {
4
11
  {
5
12
  'data-syntropy-level' => level,
6
13
  'data-syntropy-fn' => fn,
7
14
  'data-syntropy-loc' => "zed://file/#{fn}:#{line}:#{col}"
8
15
  }
9
16
  }
10
-
11
- Papercraft::Compiler.html_debug_attribute_injector = TAG_DEBUG_PROC
@@ -7,10 +7,16 @@ module Syntropy
7
7
  module HTTP
8
8
  # HTTP Client class.
9
9
  class Client
10
+ # Initializes an HTTP client.
10
11
  def initialize(machine)
11
12
  @machine = machine
12
13
  end
13
14
 
15
+ # Performs a GET request.
16
+ #
17
+ # @param url [String] URL
18
+ # @param headers [Hash] request headers
19
+ # @return [Array] array containing response headers and body
14
20
  def get(url, **headers, &)
15
21
  uri = URI.parse(url)
16
22
  headers = headers.merge(
@@ -22,7 +28,11 @@ module Syntropy
22
28
 
23
29
  private
24
30
 
25
- # @param uri [URI]
31
+ # Performs an HTTP request, returning the response headers and body.
32
+ #
33
+ # @param uri [URI] request URI
34
+ # @param headers [Hash] request headers
35
+ # @return [Array] array containing response headers and body
26
36
  def req(uri, **headers)
27
37
  connection = make_connection(uri.scheme, uri.host, uri.port)
28
38
  response_headers = connection.req(**headers)
@@ -33,6 +43,12 @@ module Syntropy
33
43
  end
34
44
  end
35
45
 
46
+ # Creates an HTTP connection.
47
+ #
48
+ # @param _scheme [String] connection scheme
49
+ # @param host [String] host
50
+ # @param port [Integer] port
51
+ # @return [Syntropy::HTTP::ClientConnection]
36
52
  def make_connection(_scheme, host, port)
37
53
  ip = (host =~ /^\d+\.\d+\.\d+\.\d+$/) ? host : @machine.resolve(host)[0]
38
54
 
@@ -5,15 +5,27 @@ require 'syntropy/http/io_extensions'
5
5
 
6
6
  module Syntropy
7
7
  module HTTP
8
+ # ClientConnection represents an HTTP client connection.
8
9
  class ClientConnection
9
10
  attr_reader :fd, :response_headers, :logger
10
11
 
12
+ # Initializes a ClientConnection.
13
+ #
14
+ # @param machine [UringMachine] machine instance
15
+ # @param fd [Integer] file descriptor
16
+ # @param io_mode [Symbol] IO mode
17
+ # @return [void]
11
18
  def initialize(machine, fd, io_mode: :socket)
12
19
  @machine = machine
13
20
  @fd = fd
14
21
  @io = machine.io(fd, io_mode)
15
22
  end
16
23
 
24
+ # Performs a request with the given body and headers.
25
+ #
26
+ # @param body [String, nil] request body
27
+ # @param headers [Hash] request headers
28
+ # @return [Hash] response headers
17
29
  def req(body: nil, **headers)
18
30
  if body
19
31
  headers = headers.merge(
@@ -28,6 +40,10 @@ module Syntropy
28
40
  @io.http_read_response_headers
29
41
  end
30
42
 
43
+ # Returns the response body
44
+ #
45
+ # @param headers [Hash] response headers
46
+ # @return [String, nil] response body
31
47
  def get_response_body(headers)
32
48
  @io.http_read_body(headers)
33
49
  end
@@ -4,6 +4,7 @@ require 'syntropy/errors'
4
4
 
5
5
  module Syntropy
6
6
  module HTTP
7
+ # HTTP protocol extensions for UringMachine::IO
7
8
  module ProtocolMethods
8
9
  RE_REQUEST_LINE = /^(get|head|options|trace|put|delete|post|patch|connect)\s+([^\s]+)\s+HTTP\/([019\.]{1,3})/i
9
10
  RE_RESPONSE_LINE = /^HTTP\/1\.1\s+(\d{3})(\s+.+)?$/i
@@ -14,7 +15,9 @@ module Syntropy
14
15
  MAX_HEADER_LINE_LEN = 1 << 13 # 8KB
15
16
  MAX_CHUNK_SIZE_LEN = 16
16
17
 
17
- # @return [Hash] headers
18
+ # Reads HTTP request headers.
19
+ #
20
+ # @return [Hash] request headers
18
21
  def http_read_request_headers
19
22
  line = read_line(MAX_REQUEST_LINE_LEN)
20
23
  return nil if !line
@@ -43,6 +46,9 @@ module Syntropy
43
46
  headers
44
47
  end
45
48
 
49
+ # Reads HTTP response headers.
50
+ #
51
+ # @return [Hash] response headers
46
52
  def http_read_response_headers
47
53
  line = read_line(MAX_RESPONSE_LINE_LEN)
48
54
  return nil if !line
@@ -73,6 +79,10 @@ module Syntropy
73
79
  headers
74
80
  end
75
81
 
82
+ # Reads an HTTP request/response body.
83
+ #
84
+ # @param headers [Hash] request/response headers
85
+ # @return [String] body
76
86
  def http_read_body(headers)
77
87
  content_length = headers['content-length']
78
88
  if content_length
@@ -95,6 +105,10 @@ module Syntropy
95
105
  nil
96
106
  end
97
107
 
108
+ # Skips an HTTP reuqest/response body.
109
+ #
110
+ # @param headers [Hash] request/response headers
111
+ # @return [void]
98
112
  def http_skip_body(headers)
99
113
  content_length = headers['content-length']
100
114
  if content_length
@@ -109,10 +123,12 @@ module Syntropy
109
123
  while http_skip_cte_chunk
110
124
  end
111
125
  end
112
-
113
- nil
114
126
  end
115
127
 
128
+ # Reads an HTTP body chunk. If no chunks remain to be read, returns nil.
129
+ #
130
+ # @param headers [Hash] request/response headers
131
+ # @return [String, nil] body chunk
116
132
  def http_read_body_chunk(headers)
117
133
  content_length = headers['content-length']
118
134
  if content_length
@@ -129,6 +145,10 @@ module Syntropy
129
145
  nil
130
146
  end
131
147
 
148
+ # Writes HTTP request headers.
149
+ #
150
+ # @param headers [Hash] request headers
151
+ # @return [void]
132
152
  def http_write_request_headers(headers)
133
153
  method = headers[':method'] || (raise BadRequestError)
134
154
  path = headers[':path'] || (raise BadRequestError)
@@ -149,6 +169,11 @@ module Syntropy
149
169
 
150
170
  private
151
171
 
172
+ # Read a chunk (in chunked transfer encoding) into the given buffer. If no
173
+ # buffer is given, returns the chunk, otherwise returns the buffer.
174
+ #
175
+ # @param buffer [String, nil] buffer
176
+ # @return [String] buffer or chunk
152
177
  def http_read_cte_chunk(buffer)
153
178
  chunk_size_str = read_line(MAX_CHUNK_SIZE_LEN)
154
179
  return nil if !chunk_size_str
@@ -165,6 +190,9 @@ module Syntropy
165
190
  buffer ? (buffer << chunk) : chunk
166
191
  end
167
192
 
193
+ # Skips a chunk (in chunked transfer encoding).
194
+ #
195
+ # @return [void]
168
196
  def http_skip_cte_chunk
169
197
  chunk_size_str = read_line(MAX_CHUNK_SIZE_LEN)
170
198
  return if !chunk_size_str
@@ -172,7 +200,7 @@ module Syntropy
172
200
  chunk_size = chunk_size_str.to_i(16)
173
201
  if chunk_size == 0
174
202
  read_line(0)
175
- return nil
203
+ return
176
204
  end
177
205
 
178
206
  chunk = skip(chunk_size)
@@ -4,41 +4,28 @@ require 'syntropy/http/server_connection'
4
4
 
5
5
  module Syntropy
6
6
  module HTTP
7
+ # HTTP::Server implements an HTTP server.
7
8
  class Server
8
9
  PENDING_REQUESTS_GRACE_PERIOD = 0.1
9
10
  PENDING_REQUESTS_TIMEOUT_PERIOD = 5
10
11
 
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
-
12
+ # Initializes a server instance.
13
+ #
14
+ # @param machine [UringMachine] machine instance
15
+ # @param env [Hash] app environment
16
+ # @param app [Proc, Syntropy::App] app instance
17
+ # @return [void]
23
18
  def initialize(machine, env, &app)
24
19
  @machine = machine
25
20
  @env = env
26
- @app = app || app_from_env
21
+ @app = app
27
22
  @server_fds = []
28
23
  @accept_fibers = []
29
24
  end
30
25
 
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
-
26
+ # Runs the server.
27
+ #
28
+ # @return [void]
42
29
  def run
43
30
  setup
44
31
  @machine.await(@accept_fibers)
@@ -46,12 +33,18 @@ module Syntropy
46
33
  graceful_shutdown
47
34
  end
48
35
 
36
+ # Stops the server with graceful shutdown.
37
+ #
38
+ # @return [void]
49
39
  def stop!
50
40
  graceful_shutdown
51
41
  end
52
42
 
53
43
  private
54
44
 
45
+ # Sets up the server.
46
+ #
47
+ # @return [void]
55
48
  def setup
56
49
  bind_info = get_bind_entries
57
50
  bind_info.each do |(host, port)|
@@ -67,6 +60,9 @@ module Syntropy
67
60
  @connection_fibers = Set.new
68
61
  end
69
62
 
63
+ # Returns bind entries from the app environment.
64
+ #
65
+ # @return [Array<Array>] array containing host/port tuples
70
66
  def get_bind_entries
71
67
  bind = @env[:bind]
72
68
  case bind
@@ -80,11 +76,20 @@ module Syntropy
80
76
  end
81
77
  end
82
78
 
79
+ # Parses a bind string into a host/port tuple.
80
+ #
81
+ # @param bind_string [String] bind string
82
+ # @return [Array<String, Integer>] array containing host and port
83
83
  def bind_info(bind_string)
84
84
  parts = bind_string.split(':')
85
85
  [parts[0], parts[1].to_i]
86
86
  end
87
87
 
88
+ # Sets up a TCP socket listening on the given host and port.
89
+ #
90
+ # @param host [String] host
91
+ # @param port [Integer] port
92
+ # @return [Integer] socket fd
88
93
  def setup_server_socket(host, port)
89
94
  fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
90
95
  @machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
@@ -94,6 +99,10 @@ module Syntropy
94
99
  fd
95
100
  end
96
101
 
102
+ # Setup server extensions. This is used for adding additional headers to
103
+ # the response, namely the Server the Date headers.
104
+ #
105
+ # @return [void]
97
106
  def setup_server_extensions
98
107
  extensions = @env[:server_extensions]
99
108
  return if !extensions
@@ -109,6 +118,10 @@ module Syntropy
109
118
  end
110
119
  end
111
120
 
121
+ # Updates server headers.
122
+ #
123
+ # @param server_name [String] server name
124
+ # @return [void]
112
125
  def update_server_headers(server_name)
113
126
  @env[:server_date] = Time.now
114
127
  if server_name
@@ -118,12 +131,20 @@ module Syntropy
118
131
  end
119
132
  end
120
133
 
134
+ # Accepts incoming connections, spinning up a fiber for connection.
135
+ #
136
+ # @param listen_fd [Integer] listening socket fd
137
+ # @return [void]
121
138
  def accept_incoming(listen_fd)
122
139
  @machine.accept_each(listen_fd) { start_connection(it) }
123
140
  rescue UM::Terminate
124
141
  @machine.shutdown(listen_fd, UM::SHUT_RD)
125
142
  end
126
143
 
144
+ # Starts handling a connection on a separate fiber.
145
+ #
146
+ # @param fd [Integer] socket fd
147
+ # @return [void]
127
148
  def start_connection(fd)
128
149
  conn = ServerConnection.new(@machine, fd, @env, &@app)
129
150
  f = @machine.spin(conn) do
@@ -134,17 +155,27 @@ module Syntropy
134
155
  @connection_fibers << f
135
156
  end
136
157
 
158
+ # Closes all listening socket fd's.
159
+ #
160
+ # @return [void]
137
161
  def close_all_server_fds
138
162
  @server_fds.each { @machine.close_async(it) }
139
163
  end
140
164
 
141
165
  STOP = UM::Terminate.new
142
166
 
167
+ # Stops all fibers listening for incoming connections.
168
+ #
169
+ # @return [void]
143
170
  def stop_accept_fibers
144
171
  @accept_fibers.each { @machine.schedule(it, STOP) if !it.done? }
145
172
  @machine.await(@accept_fibers)
146
173
  end
147
174
 
175
+ # Performs a graceful shutdown by stopping listening, then waiting for
176
+ # connection fibers to stop with a timeout of 5 seconds.
177
+ #
178
+ # @return [void]
148
179
  def graceful_shutdown
149
180
  @env[:logger]&.info(message: 'Shutting down gracefully...')
150
181