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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +2 -2
- data/CHANGELOG.md +22 -0
- data/README.md +0 -2
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/help.rb +12 -0
- data/cmd/serve.rb +95 -0
- data/cmd/test.rb +40 -0
- data/examples/{counter.rb → basic/counter.rb} +1 -1
- data/examples/{templates.rb → basic/templates.rb} +1 -1
- data/examples/mcp-oauth/.ruby-version +1 -0
- data/examples/mcp-oauth/Gemfile +8 -0
- data/examples/mcp-oauth/README.md +128 -0
- data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
- data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
- data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
- data/examples/mcp-oauth/app/index.md +1 -0
- data/examples/mcp-oauth/app/mcp.rb +38 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +15 -0
- data/examples/mcp-oauth/app/oauth/token.rb +79 -0
- data/examples/mcp-oauth/app/signin.rb +85 -0
- data/examples/mcp-oauth/test/helper.rb +9 -0
- data/examples/mcp-oauth/test/test_app.rb +27 -0
- data/examples/mcp-oauth/test/test_oauth.rb +628 -0
- data/lib/syntropy/app.rb +23 -12
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/dev_mode.rb +1 -1
- data/lib/syntropy/errors.rb +19 -12
- data/lib/syntropy/http/client.rb +43 -0
- data/lib/syntropy/http/client_connection.rb +36 -0
- data/lib/syntropy/http/io_extensions.rb +148 -0
- data/lib/syntropy/http/server.rb +174 -0
- data/lib/syntropy/http/server_connection.rb +367 -0
- data/lib/syntropy/http/status.rb +76 -0
- data/lib/syntropy/http.rb +7 -0
- data/lib/syntropy/json_api.rb +2 -5
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/mime_types.rb +37 -0
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +60 -0
- data/lib/syntropy/request/request_info.rb +255 -0
- data/lib/syntropy/request/response.rb +206 -0
- data/lib/syntropy/request/validation.rb +146 -0
- data/lib/syntropy/request.rb +99 -0
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +65 -0
- data/lib/syntropy/utils.rb +1 -1
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +4 -27
- data/syntropy.gemspec +2 -4
- data/test/app/.well-known/foo.rb +3 -0
- data/test/app/about/_error.rb +1 -1
- data/test/app/api+.rb +1 -1
- data/test/app_custom/_site.rb +1 -1
- data/test/bm_router_proc.rb +3 -3
- data/test/helper.rb +4 -27
- data/test/test_app.rb +83 -98
- data/test/test_caching.rb +2 -2
- data/test/test_errors.rb +6 -6
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +32 -32
- data/test/test_json_api.rb +14 -12
- data/test/test_mock_adapter.rb +59 -0
- data/test/{test_request_extensions.rb → test_request.rb} +150 -18
- data/test/test_response.rb +112 -0
- data/test/test_routing_tree.rb +15 -3
- data/test/test_server.rb +1 -1
- metadata +57 -35
- data/lib/syntropy/connection.rb +0 -402
- data/lib/syntropy/request_extensions.rb +0 -308
- data/lib/syntropy/server.rb +0 -173
- /data/examples/{bad.rb → basic/bad.rb} +0 -0
- /data/examples/{card.rb → basic/card.rb} +0 -0
- /data/examples/{counter.js → basic/counter.js} +0 -0
- /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
- /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
- /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 [
|
|
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],
|
|
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 [
|
|
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' =>
|
|
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 [
|
|
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] =
|
|
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
|
-
|
|
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' =>
|
|
458
|
+
req.respond(msg, ':status' => status) rescue nil
|
|
456
459
|
}
|
|
457
460
|
|
|
458
|
-
|
|
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
|
-
|
|
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: "
|
|
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.
|
|
46
|
+
req.respond_html(html, ':status' => status)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def error_response_raw(req, error)
|
data/lib/syntropy/dev_mode.rb
CHANGED
data/lib/syntropy/errors.rb
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
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 =
|
|
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) !=
|
|
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,
|
|
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,
|
|
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,
|
|
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 ||
|
|
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,
|
|
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
|
-
|
|
75
|
+
HTTP::BAD_REQUEST
|
|
78
76
|
end
|
|
79
77
|
end
|
|
80
78
|
|
|
81
79
|
class UnsupportedHTTPVersionError < ProtocolError
|
|
82
80
|
def http_status
|
|
83
|
-
|
|
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
|