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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +2 -0
- data/TODO.md +55 -144
- data/cmd/console.rb +2 -4
- data/cmd/new/template/config/production.rb +2 -1
- data/cmd/new.rb +1 -1
- data/cmd/serve.rb +29 -4
- data/cmd/test.rb +12 -4
- data/examples/agent/Dockerfile +25 -0
- data/examples/agent/Gemfile +3 -0
- data/examples/agent/README.md +40 -0
- data/examples/agent/TODO.md +5 -0
- data/examples/agent/app/_layout/default.rb +15 -0
- data/examples/agent/app/_lib/storage.rb +13 -0
- data/examples/agent/app/_schema/2026-01-01-initial.rb +9 -0
- data/examples/agent/app/assets/agent.js +63 -0
- data/examples/agent/app/assets/minigfm.js +207 -0
- data/examples/agent/app/assets/model.js +45 -0
- data/examples/agent/app/assets/style.css +25 -0
- data/examples/agent/app/assets/tools.js +74 -0
- data/examples/agent/app/assets/ui.js +50 -0
- data/examples/agent/app/index.rb +19 -0
- data/examples/agent/app/test.rb +7 -0
- data/examples/agent/config/Caddyfile +5 -0
- data/examples/agent/config/development.rb +5 -0
- data/examples/agent/config/production.rb +5 -0
- data/examples/agent/config/test.rb +5 -0
- data/examples/agent/docker-compose.yml +71 -0
- data/examples/agent/response_example.json +108 -0
- data/examples/agent/test/test_app.rb +14 -0
- data/examples/blog/app/foo.rb +1 -0
- data/examples/blog/app/posts/[id]/edit.rb +3 -3
- data/examples/blog/app/posts/[id]/index.rb +8 -10
- data/examples/blog/app/posts/index.rb +6 -8
- data/examples/blog/app/posts/new.rb +3 -3
- data/examples/blog/config/production.rb +2 -1
- data/lib/syntropy/app.rb +12 -60
- data/lib/syntropy/applets/builtin/auto_refresh/watch.js +1 -1
- data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +7 -0
- data/lib/syntropy/controller_extensions.rb +1 -1
- data/lib/syntropy/dev_mode.rb +8 -3
- data/lib/syntropy/http/client.rb +17 -1
- data/lib/syntropy/http/client_connection.rb +16 -0
- data/lib/syntropy/http/io_extensions.rb +32 -4
- data/lib/syntropy/http/server.rb +55 -24
- data/lib/syntropy/http/server_connection.rb +72 -2
- data/lib/syntropy/logger.rb +8 -0
- data/lib/syntropy/markdown.rb +139 -11
- data/lib/syntropy/module_loader.rb +110 -68
- data/lib/syntropy/request/request_info.rb +8 -0
- data/lib/syntropy/request/response.rb +76 -6
- data/lib/syntropy/request/validation.rb +6 -0
- data/lib/syntropy/storage/connection_pool.rb +34 -0
- data/lib/syntropy/test.rb +14 -5
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +9 -2
- data/syntropy.gemspec +4 -4
- data/test/fixtures/app/_layout/default.rb +1 -1
- data/test/fixtures/app/_layout/kuku.rb +8 -0
- data/test/fixtures/app/_lib/circular/a.rb +2 -0
- data/test/fixtures/app/_lib/circular/b.rb +2 -0
- data/test/fixtures/app/_lib/circular/c.rb +2 -0
- data/test/fixtures/app/mod/concurrent.rb +9 -0
- data/test/test_app.rb +1 -1
- data/test/test_caching.rb +3 -3
- data/test/test_markdown.rb +268 -0
- data/test/test_module_loader.rb +32 -4
- data/test/test_request.rb +7 -0
- data/test/test_response.rb +30 -0
- data/test/test_schema.rb +1 -0
- metadata +40 -11
|
@@ -13,6 +13,14 @@ module Syntropy
|
|
|
13
13
|
class ServerConnection
|
|
14
14
|
attr_reader :fd, :response_headers, :logger
|
|
15
15
|
|
|
16
|
+
# Initializes a server connection.
|
|
17
|
+
#
|
|
18
|
+
# @param machine [UringMachine] machine instance
|
|
19
|
+
# @param fd [Integer] file descriptor
|
|
20
|
+
# @param env [Hash] app environment
|
|
21
|
+
# @param io_mode [Symbol] IO mode
|
|
22
|
+
# @param app [Proc, Syntropy::App] server app
|
|
23
|
+
# @return [void]
|
|
16
24
|
def initialize(machine, fd, env, io_mode: :socket, &app)
|
|
17
25
|
@machine = machine
|
|
18
26
|
@fd = fd
|
|
@@ -26,6 +34,9 @@ module Syntropy
|
|
|
26
34
|
@response_cookies = nil
|
|
27
35
|
end
|
|
28
36
|
|
|
37
|
+
# Runs the connection.
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
29
40
|
def run
|
|
30
41
|
loop do
|
|
31
42
|
persist = serve_request
|
|
@@ -33,6 +44,10 @@ module Syntropy
|
|
|
33
44
|
end
|
|
34
45
|
rescue UM::Terminate
|
|
35
46
|
# server is terminated, do nothing
|
|
47
|
+
rescue SystemCallError
|
|
48
|
+
@logger&.error(
|
|
49
|
+
message: 'IO Error, closing...'
|
|
50
|
+
)
|
|
36
51
|
rescue StandardError => e
|
|
37
52
|
@logger&.error(
|
|
38
53
|
message: 'Uncaught error while running connection',
|
|
@@ -99,6 +114,10 @@ module Syntropy
|
|
|
99
114
|
@logger&.error(message: "#{message}, closing connection", error: err)
|
|
100
115
|
end
|
|
101
116
|
|
|
117
|
+
# Reads the request body.
|
|
118
|
+
#
|
|
119
|
+
# @param req [Syntropy::Request] request
|
|
120
|
+
# @return [String, nil] request body
|
|
102
121
|
def get_body(req)
|
|
103
122
|
headers = req.headers
|
|
104
123
|
return nil if headers[':body-done-reading']
|
|
@@ -108,6 +127,10 @@ module Syntropy
|
|
|
108
127
|
body
|
|
109
128
|
end
|
|
110
129
|
|
|
130
|
+
# Reads a request body chunk.
|
|
131
|
+
#
|
|
132
|
+
# @param req [Syntropy::Request] request
|
|
133
|
+
# @return [String, nil] request body chunk
|
|
111
134
|
def get_body_chunk(req)
|
|
112
135
|
headers = req.headers
|
|
113
136
|
return nil if headers[':body-done-reading']
|
|
@@ -117,6 +140,9 @@ module Syntropy
|
|
|
117
140
|
chunk
|
|
118
141
|
end
|
|
119
142
|
|
|
143
|
+
# Returns true if the request is done
|
|
144
|
+
#
|
|
145
|
+
# @return [bool] whether the request is complete
|
|
120
146
|
def complete?(req)
|
|
121
147
|
req.headers[':body-done-reading']
|
|
122
148
|
end
|
|
@@ -135,6 +161,11 @@ module Syntropy
|
|
|
135
161
|
|
|
136
162
|
DELETE_COOKIE = "; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Max-Age=0; HttpOnly"
|
|
137
163
|
|
|
164
|
+
# Adds a Set-Cookie header to the response headers.
|
|
165
|
+
#
|
|
166
|
+
# @param key [String] cookie name
|
|
167
|
+
# @param value [String] cookie value
|
|
168
|
+
# @return [void]
|
|
138
169
|
def set_cookie(key, value)
|
|
139
170
|
(@response_cookies ||= {})[key] = value || DELETE_COOKIE
|
|
140
171
|
end
|
|
@@ -210,6 +241,13 @@ module Syntropy
|
|
|
210
241
|
@done = true
|
|
211
242
|
end
|
|
212
243
|
|
|
244
|
+
# Responds by rendering a static file.
|
|
245
|
+
#
|
|
246
|
+
# @param req [Syntropy::Request] request
|
|
247
|
+
# @param path [String] file path
|
|
248
|
+
# @param env [Hash] app environment
|
|
249
|
+
# @param cache_headers [Hash] cache headers
|
|
250
|
+
# @return [void]
|
|
213
251
|
def respond_with_static_file(req, path, env, cache_headers)
|
|
214
252
|
fd = @machine.open(path, UM::O_RDONLY)
|
|
215
253
|
env ||= {}
|
|
@@ -240,6 +278,9 @@ module Syntropy
|
|
|
240
278
|
end
|
|
241
279
|
end
|
|
242
280
|
|
|
281
|
+
# Closes the connection.
|
|
282
|
+
#
|
|
283
|
+
# @return [void]
|
|
243
284
|
def close
|
|
244
285
|
return if @closed
|
|
245
286
|
|
|
@@ -248,6 +289,9 @@ module Syntropy
|
|
|
248
289
|
@machine.close_async(@fd)
|
|
249
290
|
end
|
|
250
291
|
|
|
292
|
+
# Yields the uhnderlying connection IO and fd to the given block.
|
|
293
|
+
#
|
|
294
|
+
# @return [void]
|
|
251
295
|
def with_stream
|
|
252
296
|
yield @io, @fd
|
|
253
297
|
end
|
|
@@ -260,6 +304,9 @@ module Syntropy
|
|
|
260
304
|
MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
|
|
261
305
|
MAX_CHUNK_SIZE_LEN = 16
|
|
262
306
|
|
|
307
|
+
# Returns true if the connection should be persisted.
|
|
308
|
+
#
|
|
309
|
+
# @param headers [Hash] request headers
|
|
263
310
|
def persist_connection?(headers)
|
|
264
311
|
connection = headers['connection']&.downcase
|
|
265
312
|
return connection != 'close'
|
|
@@ -285,14 +332,23 @@ module Syntropy
|
|
|
285
332
|
lines
|
|
286
333
|
end
|
|
287
334
|
|
|
335
|
+
# Formats a response status line.
|
|
336
|
+
#
|
|
337
|
+
# @param body [String] response body
|
|
338
|
+
# @param status [Integer] HTTP status code
|
|
339
|
+
# @return [String] rendered status line
|
|
288
340
|
def format_status_line(body, status)
|
|
289
341
|
if !body
|
|
290
342
|
empty_status_line(status)
|
|
291
343
|
else
|
|
292
|
-
with_body_status_line(
|
|
344
|
+
with_body_status_line(body, status)
|
|
293
345
|
end
|
|
294
346
|
end
|
|
295
347
|
|
|
348
|
+
# Returns the status line for an empty response.
|
|
349
|
+
#
|
|
350
|
+
# @param status [Integer] HTTP status code
|
|
351
|
+
# @return [String] rendered status line
|
|
296
352
|
def empty_status_line(status)
|
|
297
353
|
if status == 204
|
|
298
354
|
+"HTTP/1.1 #{status}\r\n"
|
|
@@ -301,10 +357,21 @@ module Syntropy
|
|
|
301
357
|
end
|
|
302
358
|
end
|
|
303
359
|
|
|
304
|
-
|
|
360
|
+
# Returns a status line for responses with body.
|
|
361
|
+
#
|
|
362
|
+
# @param body [String] response body
|
|
363
|
+
# @param status [Integer] HTTP status code
|
|
364
|
+
# @return [String] rendered status line
|
|
365
|
+
def with_body_status_line(body, status)
|
|
305
366
|
+"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
|
|
306
367
|
end
|
|
307
368
|
|
|
369
|
+
# Renders headers into a lines array.
|
|
370
|
+
#
|
|
371
|
+
# @param lines [Array<String>] array of header lines
|
|
372
|
+
# @param key [String] header name
|
|
373
|
+
# @param value [String, Array<String>] header value(s)
|
|
374
|
+
# @return [void]
|
|
308
375
|
def collect_header_lines(lines, key, value)
|
|
309
376
|
if value.is_a?(Array)
|
|
310
377
|
value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" }
|
|
@@ -313,6 +380,9 @@ module Syntropy
|
|
|
313
380
|
end
|
|
314
381
|
end
|
|
315
382
|
|
|
383
|
+
# Adds Set-Cookie headers to the response headers.
|
|
384
|
+
#
|
|
385
|
+
# @return [void]
|
|
316
386
|
def add_set_cookie_headers
|
|
317
387
|
@response_headers ||= {}
|
|
318
388
|
sc = (@response_headers['Set-Cookie'] ||= [])
|
data/lib/syntropy/logger.rb
CHANGED
data/lib/syntropy/markdown.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require 'securerandom'
|
|
4
5
|
|
|
5
6
|
module Syntropy
|
|
6
7
|
# Markdown parsing.
|
|
@@ -11,30 +12,153 @@ module Syntropy
|
|
|
11
12
|
symbolize_names: true
|
|
12
13
|
}.freeze
|
|
13
14
|
|
|
15
|
+
# Implements a Markdown file controller.
|
|
16
|
+
class Controller
|
|
17
|
+
def initialize(env, atts, md)
|
|
18
|
+
@env = env
|
|
19
|
+
@atts = atts
|
|
20
|
+
@md = md
|
|
21
|
+
@module_loader = env[:module_loader]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the controller proc.
|
|
25
|
+
#
|
|
26
|
+
# @return [Proc] controller proc
|
|
27
|
+
def to_proc
|
|
28
|
+
->(req) {
|
|
29
|
+
case req.method
|
|
30
|
+
when 'head'
|
|
31
|
+
req.respond_html(nil)
|
|
32
|
+
when 'get'
|
|
33
|
+
md = process_md_embeds
|
|
34
|
+
html = render(md)
|
|
35
|
+
req.respond_html(html)
|
|
36
|
+
else
|
|
37
|
+
req.respond(nil, ':status' => HTTP::METHOD_NOT_ALLOWED)
|
|
38
|
+
end
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Process any Papercraft embeds in the markdown file by rendering them
|
|
45
|
+
# into the markdown code.
|
|
46
|
+
#
|
|
47
|
+
# @return [String] Markdown with rendered embeds
|
|
48
|
+
def process_md_embeds
|
|
49
|
+
return @md if @embedded_templates&.empty?
|
|
50
|
+
|
|
51
|
+
@embedded_templates = {}
|
|
52
|
+
@md.gsub(/^```ruby\n# render: true\n(.*?)\r?\n```\n/m) {
|
|
53
|
+
snippet = Regexp.last_match[1]
|
|
54
|
+
templ = @embedded_templates[snippet] ||= prepare_snippet_template(snippet)
|
|
55
|
+
Papercraft.html(templ)
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Prepares a snippet template by writing it to a temporary file and
|
|
60
|
+
# instance_eval'ing the code, returning a Papercraft template.
|
|
61
|
+
#
|
|
62
|
+
# @param snippet [String] Papercraft snippet
|
|
63
|
+
# @param _location [String] currently not used
|
|
64
|
+
# @return [Proc] Papercraft template
|
|
65
|
+
def prepare_snippet_template(snippet, _location = nil)
|
|
66
|
+
fn = "/tmp/snippet-#{SecureRandom.hex(8)}.rb"
|
|
67
|
+
src = "->() do\n#{snippet}\nend"
|
|
68
|
+
IO.write(fn, src)
|
|
69
|
+
instance_eval src, fn
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Renders the given markdown to HTML.
|
|
73
|
+
#
|
|
74
|
+
# @param md [String] markdown
|
|
75
|
+
# @return [String] HTML
|
|
76
|
+
def render(md)
|
|
77
|
+
@template ||= make_template
|
|
78
|
+
Papercraft.html(@template, md: md, **@atts)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Makes a Markdown rendering template based on the corresponding layout.
|
|
82
|
+
#
|
|
83
|
+
# @return [Proc] Papercraft template
|
|
84
|
+
def make_template
|
|
85
|
+
layout = make_layout
|
|
86
|
+
Papercraft.apply(layout) { |md:, **| markdown(md) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Makes a layout template by loading the layout module. If no layout
|
|
90
|
+
# module was specified, returns the default layout.
|
|
91
|
+
#
|
|
92
|
+
# @return [Proc] Layout template
|
|
93
|
+
def make_layout
|
|
94
|
+
return default_layout if !@atts[:layout]
|
|
95
|
+
raise Error, 'Missing module loader' if !@module_loader
|
|
96
|
+
|
|
97
|
+
@module_loader.load("_layout/#{@atts[:layout]}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Creates a default layout template.
|
|
101
|
+
#
|
|
102
|
+
# @return [Proc] Default layout template
|
|
103
|
+
def default_layout
|
|
104
|
+
->(**atts) {
|
|
105
|
+
html5 {
|
|
106
|
+
head {
|
|
107
|
+
title atts[:title] if atts[:title]
|
|
108
|
+
}
|
|
109
|
+
body {
|
|
110
|
+
render_children(**atts)
|
|
111
|
+
auto_refresh!
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
14
118
|
class << self
|
|
15
119
|
# Parses the markdown file at the given path.
|
|
16
120
|
#
|
|
17
121
|
# @param path [String] file path
|
|
18
122
|
# @return [Array] an tuple containing properties<Hash>, contents<String>
|
|
19
|
-
def
|
|
20
|
-
|
|
123
|
+
def parse_file(path, env)
|
|
124
|
+
md = IO.read(path) || ''
|
|
21
125
|
atts = {}
|
|
22
|
-
|
|
23
|
-
parse_date(path, atts)
|
|
24
|
-
content = parse_content(content, atts)
|
|
25
126
|
atts[:url] = path_to_url(path, env[:app_root]) if env[:app_root]
|
|
127
|
+
parse_date(atts, path)
|
|
128
|
+
|
|
129
|
+
parse_md(atts, md)
|
|
130
|
+
end
|
|
26
131
|
|
|
27
|
-
|
|
132
|
+
# Parses the markdown content, returning the attributes and cleaned up
|
|
133
|
+
# markdown.
|
|
134
|
+
#
|
|
135
|
+
# @param atts [Hash] attributes hash
|
|
136
|
+
# @param md [String] markdown file
|
|
137
|
+
# @return [Array<atts, md>] Array containing attributes and clean markdown
|
|
138
|
+
def parse_md(atts, md)
|
|
139
|
+
md = parse_content(atts, md)
|
|
140
|
+
[atts, md]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Creates a Markdown file contoroller for the given environment,
|
|
144
|
+
# attributes and markdown.
|
|
145
|
+
#
|
|
146
|
+
# @param env [Hash] app environment
|
|
147
|
+
# @param atts [Hash] markdown file attributes
|
|
148
|
+
# @param md [String] markdown
|
|
149
|
+
# @return [Markdown::Controller] controller instance
|
|
150
|
+
def make_controller(env, atts, md)
|
|
151
|
+
Controller.new(env, atts, md).to_proc
|
|
28
152
|
end
|
|
29
153
|
|
|
30
154
|
private
|
|
31
155
|
|
|
32
156
|
# Parses date information from the given path.
|
|
33
157
|
#
|
|
34
|
-
# @param path [String] file path
|
|
35
158
|
# @param atts [Hash] file attributes
|
|
159
|
+
# @param path [String] file path
|
|
36
160
|
# @return [void]
|
|
37
|
-
def parse_date(
|
|
161
|
+
def parse_date(atts, path)
|
|
38
162
|
# Parse date from file name
|
|
39
163
|
if (m = path.match(/(\d{4}-\d{2}-\d{2})/))
|
|
40
164
|
atts[:date] ||= Date.parse(m[1])
|
|
@@ -43,10 +167,10 @@ module Syntropy
|
|
|
43
167
|
|
|
44
168
|
# Parses the markdown content and front matter attributes from the given content.
|
|
45
169
|
#
|
|
46
|
-
# @param content [String] file content
|
|
47
170
|
# @param atts [Hash] file attributes
|
|
171
|
+
# @param content [String] file content
|
|
48
172
|
# @return [String] parsed markdown content
|
|
49
|
-
def parse_content(
|
|
173
|
+
def parse_content(atts, content)
|
|
50
174
|
if (m = content.match(FRONT_MATTER_REGEXP))
|
|
51
175
|
front_matter = m[1]
|
|
52
176
|
content = m.post_match
|
|
@@ -63,7 +187,11 @@ module Syntropy
|
|
|
63
187
|
# @param app_root [String] app root directory
|
|
64
188
|
# @return [String] url
|
|
65
189
|
def path_to_url(path, app_root)
|
|
66
|
-
|
|
190
|
+
if app_root == '/'
|
|
191
|
+
path.gsub(/\.md$/, '')
|
|
192
|
+
else
|
|
193
|
+
path.gsub(/#{app_root}/, '').gsub(/\.md$/, '')
|
|
194
|
+
end
|
|
67
195
|
end
|
|
68
196
|
end
|
|
69
197
|
end
|
|
@@ -21,7 +21,7 @@ module Syntropy
|
|
|
21
21
|
# reference `_lib/foo` will point to a module residing in
|
|
22
22
|
# `/my_site/_lib/foo.rb`.
|
|
23
23
|
class ModuleLoader
|
|
24
|
-
attr_reader :modules
|
|
24
|
+
attr_reader :modules, :app_root
|
|
25
25
|
|
|
26
26
|
# Instantiates a module loader
|
|
27
27
|
#
|
|
@@ -30,25 +30,31 @@ module Syntropy
|
|
|
30
30
|
# @return [void]
|
|
31
31
|
def initialize(env, extensions: nil)
|
|
32
32
|
@env = env
|
|
33
|
+
@machine = env[:machine]
|
|
33
34
|
@app_root = env[:app_root]
|
|
34
35
|
@modules = {} # maps ref to module entry
|
|
35
36
|
@fn_map = {} # maps filename to ref
|
|
36
37
|
@extensions = extensions
|
|
38
|
+
@loading = Set.new
|
|
39
|
+
@lock = UM::Mutex.new
|
|
40
|
+
@lock_holder = nil
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# Loads a module (if not already loaded) and returns its export value.
|
|
40
44
|
#
|
|
41
45
|
# @param ref [String] module reference
|
|
42
46
|
# @return [any] export value
|
|
43
|
-
def load(ref,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
entry =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
def load(ref, raise_on_missing_export: true)
|
|
48
|
+
lock do
|
|
49
|
+
ref = "/#{ref}" if ref !~ /^\//
|
|
50
|
+
if !(entry = @modules[ref])
|
|
51
|
+
entry = load_module(ref, raise_on_missing_export:)
|
|
52
|
+
return if !entry
|
|
53
|
+
|
|
54
|
+
@modules[ref] = entry
|
|
55
|
+
end
|
|
56
|
+
entry[:export_value]
|
|
50
57
|
end
|
|
51
|
-
entry[:export_value]
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
# Returns a list of modules found in the given relative path. The module
|
|
@@ -70,13 +76,29 @@ module Syntropy
|
|
|
70
76
|
# @param fn [String] module filename
|
|
71
77
|
# @return [void]
|
|
72
78
|
def invalidate_fn(fn)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
lock do
|
|
80
|
+
ref = @fn_map[fn]
|
|
81
|
+
invalidate_ref(ref) if ref
|
|
82
|
+
invalidate_collection_modules
|
|
83
|
+
end
|
|
76
84
|
end
|
|
77
85
|
|
|
78
86
|
private
|
|
79
87
|
|
|
88
|
+
# Synchronizes access to the module loader state
|
|
89
|
+
#
|
|
90
|
+
# @return [any] block return value
|
|
91
|
+
def lock
|
|
92
|
+
return yield if @lock_holder == Fiber.current
|
|
93
|
+
|
|
94
|
+
@machine.synchronize(@lock) do
|
|
95
|
+
@lock_holder = Fiber.current
|
|
96
|
+
yield
|
|
97
|
+
ensure
|
|
98
|
+
@lock_holder = nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
80
102
|
# Invalidates a module by its reference, normally following a change to the
|
|
81
103
|
# underlying file (in order to cause reloading of the module). The module
|
|
82
104
|
# will be removed from the modules map, as well as modules dependending on
|
|
@@ -122,23 +144,35 @@ module Syntropy
|
|
|
122
144
|
#
|
|
123
145
|
# @param ref [String] module reference
|
|
124
146
|
# @return [Hash] module entry
|
|
125
|
-
def load_module(ref,
|
|
147
|
+
def load_module(ref, raise_on_missing_export: true)
|
|
126
148
|
ref = "/#{ref}" if ref !~ /^\//
|
|
127
149
|
fn = File.expand_path(File.join(@app_root, "#{ref}.rb"))
|
|
128
150
|
if !File.file?(fn)
|
|
129
|
-
raise Syntropy::Error, "File not found #{fn}" if
|
|
151
|
+
raise Syntropy::Error, "File not found #{fn}" if raise_on_missing_export
|
|
130
152
|
|
|
131
153
|
return
|
|
132
154
|
end
|
|
133
155
|
|
|
156
|
+
raise Syntropy::Error, "Circular dependency detected" if @loading.include?(ref)
|
|
157
|
+
do_load_module(ref, fn, raise_on_missing_export:)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Loads a module.
|
|
161
|
+
#
|
|
162
|
+
# @param ref [String] module reference
|
|
163
|
+
# @param fn [String] module filename
|
|
164
|
+
# @param raise_on_missing_export [bool] whether to raise on missing export
|
|
165
|
+
def do_load_module(ref, fn, raise_on_missing_export:)
|
|
166
|
+
@loading << ref
|
|
134
167
|
@fn_map[fn] = ref
|
|
135
|
-
code =
|
|
168
|
+
code = read_file(fn)
|
|
136
169
|
env = @env.merge(module_loader: self, ref: clean_ref(ref))
|
|
137
|
-
mod = Syntropy::ModuleContext.
|
|
170
|
+
mod = Syntropy::ModuleContext.new(env, code, fn, @extensions)
|
|
138
171
|
add_dependencies(ref, mod.__dependencies__)
|
|
139
172
|
export_value = transform_module_export_value(
|
|
140
|
-
mod
|
|
173
|
+
mod, fn, raise_on_missing_export:
|
|
141
174
|
)
|
|
175
|
+
@env[:logger]&.info(message: "Loaded module at #{fn}")
|
|
142
176
|
|
|
143
177
|
{
|
|
144
178
|
fn: fn,
|
|
@@ -146,6 +180,23 @@ module Syntropy
|
|
|
146
180
|
export_value: export_value,
|
|
147
181
|
reverse_deps: []
|
|
148
182
|
}
|
|
183
|
+
rescue StandardError, SyntaxError => e
|
|
184
|
+
env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
|
|
185
|
+
e.is_a?(SyntaxError) ? handle_syntax_error(fn, e) : (raise e)
|
|
186
|
+
ensure
|
|
187
|
+
@loading.delete(ref)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Returns a file's content.
|
|
191
|
+
#
|
|
192
|
+
# @param fn [String] filename
|
|
193
|
+
# @return [String] file content
|
|
194
|
+
def read_file(fn)
|
|
195
|
+
@machine.open(fn, UM::O_RDONLY) { |fd|
|
|
196
|
+
buf = +''
|
|
197
|
+
res = @machine.read(fd, buf, 1 << 20)
|
|
198
|
+
buf
|
|
199
|
+
}
|
|
149
200
|
end
|
|
150
201
|
|
|
151
202
|
# Cleans up a module reference specifier, turning /index into /
|
|
@@ -159,21 +210,34 @@ module Syntropy
|
|
|
159
210
|
(clean == '') ? '/' : clean
|
|
160
211
|
end
|
|
161
212
|
|
|
162
|
-
# Transforms the given export value. If the value is nil, an
|
|
163
|
-
# raised.
|
|
213
|
+
# Transforms the given module's export value. If the value is nil, an
|
|
214
|
+
# exception is raised.
|
|
164
215
|
#
|
|
165
|
-
# @param
|
|
216
|
+
# @param mod [Syntropy::ModuleContext] module context
|
|
166
217
|
# @return [any] transformed value
|
|
167
|
-
def transform_module_export_value(
|
|
168
|
-
case
|
|
218
|
+
def transform_module_export_value(mod, fn, raise_on_missing_export:)
|
|
219
|
+
case (value = mod.__export_value__)
|
|
169
220
|
when nil
|
|
170
|
-
|
|
221
|
+
mod
|
|
171
222
|
when String
|
|
172
|
-
->(req) { req.respond(
|
|
223
|
+
->(req) { req.respond(value) }
|
|
173
224
|
else
|
|
174
|
-
|
|
225
|
+
value
|
|
175
226
|
end
|
|
176
227
|
end
|
|
228
|
+
|
|
229
|
+
# Handles a syntax error encountered while loading a module by generating a
|
|
230
|
+
# new exception with a backtrace including the syntax error location.
|
|
231
|
+
def handle_syntax_error(fn, e)
|
|
232
|
+
$stderr.puts("\n#{e.message}") if !Syntropy.test_mode
|
|
233
|
+
m = e.message.match(/^(.+): syntax/)
|
|
234
|
+
raise e if !m
|
|
235
|
+
|
|
236
|
+
location = m[1]
|
|
237
|
+
e2 = SyntaxError.new("Syntax errors found in module at #{fn}")
|
|
238
|
+
e2.set_backtrace([location] + e.backtrace)
|
|
239
|
+
raise e2
|
|
240
|
+
end
|
|
177
241
|
end
|
|
178
242
|
|
|
179
243
|
# The Syntropy::ModuleContext class provides a context for loading a module. A
|
|
@@ -193,44 +257,14 @@ module Syntropy
|
|
|
193
257
|
# is set to `self`, and may be used to refer to various methods defined in the
|
|
194
258
|
# module.
|
|
195
259
|
class ModuleContext
|
|
196
|
-
# Loads a module, returning the module instance
|
|
197
|
-
# @param env [Hash] app environment
|
|
198
|
-
# @param code [String] module source code
|
|
199
|
-
# @param fn [String] module file name
|
|
200
|
-
# @param extensions [Module, Array<Module>] extension module(s)
|
|
201
|
-
# @return [Syntropy::ModuleContext] created module context
|
|
202
|
-
def self.load(env, code, fn, extensions)
|
|
203
|
-
mod = new(env)
|
|
204
|
-
apply_extensions(mod, extensions)
|
|
205
|
-
mod.instance_eval(code, fn)
|
|
206
|
-
env[:logger]&.info(message: "Loaded module at #{fn}")
|
|
207
|
-
mod
|
|
208
|
-
rescue StandardError, SyntaxError => e
|
|
209
|
-
env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
|
|
210
|
-
e.is_a?(SyntaxError) ? handle_syntax_error(env, e) : (raise e)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
# Applies the given extension(s) to the given module context.
|
|
214
|
-
#
|
|
215
|
-
# @param mod [Syntropy::ModuleContext] module context
|
|
216
|
-
# @param extensions [Module, Array<Module>] extension module(s)
|
|
217
|
-
def self.apply_extensions(mod, extensions)
|
|
218
|
-
case extensions
|
|
219
|
-
when Array
|
|
220
|
-
extensions.each { mod.extend(it) }
|
|
221
|
-
when Module
|
|
222
|
-
mod.extend(extensions)
|
|
223
|
-
when nil # return
|
|
224
|
-
else
|
|
225
|
-
raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
260
|
# Initializes a module with the given environment hash.
|
|
230
261
|
#
|
|
231
262
|
# @param env [Hash] environment hash
|
|
263
|
+
# @param code [String] module source code
|
|
264
|
+
# @param fn [String] module filename
|
|
265
|
+
# @param extensions [Array<Module>, Module]
|
|
232
266
|
# @return [void]
|
|
233
|
-
def initialize(env)
|
|
267
|
+
def initialize(env, code, fn, extensions)
|
|
234
268
|
@env = env
|
|
235
269
|
@machine = env[:machine]
|
|
236
270
|
@module_loader = env[:module_loader]
|
|
@@ -239,6 +273,9 @@ module Syntropy
|
|
|
239
273
|
@logger = env[:logger]
|
|
240
274
|
@__dependencies__ = []
|
|
241
275
|
singleton_class.const_set(:MODULE, self)
|
|
276
|
+
|
|
277
|
+
apply_extensions(extensions)
|
|
278
|
+
instance_eval(code, fn)
|
|
242
279
|
end
|
|
243
280
|
|
|
244
281
|
attr_reader :__export_value__, :__dependencies__
|
|
@@ -334,15 +371,20 @@ module Syntropy
|
|
|
334
371
|
Syntropy::App.new(**env)
|
|
335
372
|
end
|
|
336
373
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
374
|
+
# Applies the given extension(s) to the given module context.
|
|
375
|
+
#
|
|
376
|
+
# @param extensions [Module, Array<Module>] extension module(s)
|
|
377
|
+
# @return [void]
|
|
378
|
+
def apply_extensions(extensions)
|
|
379
|
+
case extensions
|
|
380
|
+
when Array
|
|
381
|
+
extensions.each { extend(it) }
|
|
382
|
+
when Module
|
|
383
|
+
extend(extensions)
|
|
384
|
+
when nil # return
|
|
385
|
+
else
|
|
386
|
+
raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
|
|
387
|
+
end
|
|
346
388
|
end
|
|
347
389
|
end
|
|
348
390
|
end
|
|
@@ -228,6 +228,14 @@ module Syntropy
|
|
|
228
228
|
nil
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
+
# Expands a relative path based on the request's path.
|
|
232
|
+
#
|
|
233
|
+
# param rel_path [String]
|
|
234
|
+
# @return [String]
|
|
235
|
+
def rel(rel_path)
|
|
236
|
+
File.expand_path(File.join(path, rel_path))
|
|
237
|
+
end
|
|
238
|
+
|
|
231
239
|
private
|
|
232
240
|
|
|
233
241
|
# Parses an accept string into an array of accepted MIME types.
|