Skip to content

Commit a4fdb1c

Browse files
committed
Refactor routing functionality into separate Router class
1 parent ea0be27 commit a4fdb1c

7 files changed

Lines changed: 311 additions & 26 deletions

File tree

TODO.md

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1-
- Add support for site-wide _site.rb file:
1+
- Refactor routing code into a separate Router class.
2+
- The Router class is in charge of:
3+
- caching routes
4+
- loading modules
5+
- unloading modules on file change
6+
- calculating middleware for routes
7+
- middleware is defined in `_hook.rb` modules
8+
- interface: ->(req, next)
9+
- a special case for handling errors is `_error.rb`
10+
- interface: ->(req, err)
11+
- dispatching routes
12+
- error handling:
13+
- on uncaught error, if an `_error.rb` file exists in the same directory
14+
or up the file tree
15+
- middleware:
16+
- a closure is created from the composition of the different hooks
17+
defined, from the route's directory and up the file
18+
- error handlers and middleware closures are cached as part of the route's
19+
entry
20+
- on file change for any _hook.rb or _error.rb files, all route entries in
21+
the corresponding subtree are invalidated
222

3-
```Ruby
4-
# site/_site.rb
5-
# just a regular module
6-
7-
export ->(req) {
8-
...
9-
}
10-
11-
# more specifically, for the sake of running multiple domains
12-
export Syntropy.route_by_domain(
13-
'noteflakes.com' => 'noteflakes.com',
14-
'tolkora.net' => 'tolkora.net'
15-
)
16-
```
1723

1824
- Middleware
1925

lib/syntropy.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require 'syntropy/module'
1010
require 'syntropy/rpc_api'
1111
require 'syntropy/side_run'
12+
require 'syntropy/router'
1213
require 'syntropy/app'
1314
require 'syntropy/request_extensions'
1415

lib/syntropy/app.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ def start_file_watcher
8181
wf = @opts[:watch_files]
8282
period = wf.is_a?(Numeric) ? wf : 0.1
8383
@machine.spin do
84-
Syntropy.file_watch(@machine, @src_path, period: period) do
85-
@opts[:logger]&.call("Detected changed file: #{it}")
86-
invalidate_cache(it)
84+
Syntropy.file_watch(@machine, @src_path, period: period) do |_event, fn|
85+
@opts[:logger]&.call("Detected changed file: #{fn}")
86+
invalidate_cache(fn)
8787
rescue Exception => e
8888
p e
8989
p e.backtrace

lib/syntropy/file_watch.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ def self.file_watch(machine, *roots, period: 0.1, &block)
88

99
queue = Thread::Queue.new
1010
listener = Listen.to(*roots) do |modified, added, removed|
11-
fns = (modified + added + removed).uniq
12-
fns.each { queue.push(it) }
11+
modified.each { queue.push([:modified, it]) }
12+
added.each { queue.push([:added, it]) }
13+
removed.each { queue.push([:removed, it]) }
1314
end
1415
listener.start
1516

1617
loop do
1718
machine.sleep(period) while queue.empty?
18-
fn = queue.shift
19-
block.call(fn)
19+
event, fn = queue.shift
20+
block.call(event, fn)
2021
end
2122
rescue StandardError => e
2223
p e

lib/syntropy/router.rb

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# frozen_string_literal: true
2+
3+
module Syntropy
4+
class Router
5+
attr_reader :cache
6+
7+
def initialize(opts, module_loader = nil)
8+
raise 'Invalid location given' if !File.directory?(opts[:location])
9+
10+
@opts = opts
11+
@machine = opts[:machine]
12+
@root = File.expand_path(opts[:location])
13+
@mount_path = opts[:mount_path] || '/'
14+
@rel_path_re ||= /^#{@root}/
15+
@module_loader = module_loader
16+
17+
@cache = {} # maps url path to route entry
18+
@routes = {} # maps canonical path to route entry (actual routes)
19+
@files = {} # maps filename to entry
20+
@deps = {} # maps filenames to array of dependent entries
21+
@x = {} # maps directories to hook chains
22+
23+
scan_routes
24+
start_file_watcher if opts[:watch_files]
25+
end
26+
27+
def [](path)
28+
get_route_entry(path)
29+
end
30+
31+
private
32+
33+
HIDDEN_RE = /^_/
34+
35+
def scan_routes(dir = nil)
36+
dir ||= @root
37+
38+
Dir[File.join(dir, '*')].each do
39+
basename = File.basename(it)
40+
next if (basename =~ HIDDEN_RE)
41+
42+
File.directory?(it) ? scan_routes(it) : add_route(it)
43+
end
44+
end
45+
46+
def add_route(fn)
47+
kind = route_kind(fn)
48+
rel_path = path_rel(fn)
49+
canonical_path = path_canonical(rel_path, kind)
50+
entry = { kind:, fn:, canonical_path: }
51+
entry[:handle_subtree] = true if (kind == :module) && !!(fn =~ /\+\.rb$/)
52+
53+
@routes[canonical_path] = entry
54+
@files[fn] = entry
55+
end
56+
57+
def route_kind(fn)
58+
case File.extname(fn)
59+
when '.md'
60+
:markdown
61+
when '.rb'
62+
:module
63+
else
64+
:static
65+
end
66+
end
67+
68+
def path_rel(path)
69+
path.gsub(@rel_path_re, '')
70+
end
71+
72+
def path_abs(path, base)
73+
File.join(base, path)
74+
end
75+
76+
PATH_PARENT_RE = /^(.+)?\/([^\/]+)$/
77+
78+
def path_parent(path)
79+
return nil if path == '/'
80+
81+
path.match(PATH_PARENT_RE)[1] || '/'
82+
end
83+
84+
MD_EXT_RE = /\.md$/
85+
RB_EXT_RE = /[+]?\.rb$/
86+
INDEX_RE = /^(.*)\/index[+]?\.(?:rb|md|html)$/
87+
88+
def path_canonical(rel_path, kind)
89+
clean = path_clean(rel_path, kind)
90+
clean.empty? ? @mount_path : File.join(@mount_path, clean)
91+
end
92+
93+
def path_clean(rel_path, kind)
94+
if (m = rel_path.match(INDEX_RE))
95+
return m[1]
96+
end
97+
98+
case kind
99+
when :static
100+
rel_path
101+
when :markdown
102+
rel_path.gsub(MD_EXT_RE, '')
103+
when :module
104+
rel_path.gsub(RB_EXT_RE, '')
105+
end
106+
end
107+
108+
def get_route_entry(path, use_cache: true)
109+
if use_cache
110+
cached = @cache[path]
111+
return cached if cached
112+
end
113+
114+
entry = find_route_entry(path)
115+
set_cache(path, entry) if use_cache && entry[:kind] != :not_found
116+
entry
117+
end
118+
119+
def set_cache(path, entry)
120+
@cache[path] = entry
121+
(entry[:cache_keys] ||= {})[path] = true
122+
end
123+
124+
# We don't allow access to path with /.., or entries that start with _
125+
FORBIDDEN_RE = %r{(/_)|((/\.\.)/?)}
126+
NOT_FOUND = { kind: :not_found }.freeze
127+
128+
def find_route_entry(path)
129+
return NOT_FOUND if path =~ FORBIDDEN_RE
130+
131+
@routes[path] || find_up_tree_module(path) || NOT_FOUND
132+
end
133+
134+
def find_up_tree_module(path)
135+
parent_path = path_parent(path)
136+
return nil if !parent_path
137+
138+
entry = @routes[parent_path]
139+
return entry if entry && entry[:handle_subtree]
140+
141+
find_up_tree_module(parent_path)
142+
end
143+
144+
def start_file_watcher
145+
@opts[:logger]&.call('Watching for file changes...', nil)
146+
@machine.spin { file_watcher_loop }
147+
end
148+
149+
def file_watcher_loop
150+
wf = @opts[:watch_files]
151+
period = wf.is_a?(Numeric) ? wf : 0.1
152+
Syntropy.file_watch(@machine, @root, period: period) do |event, fn|
153+
handle_changed_file(event, fn)
154+
rescue Exception => e
155+
p e
156+
p e.backtrace
157+
exit!
158+
end
159+
end
160+
161+
def handle_changed_file(event, fn)
162+
@opts[:logger]&.call("Detected changed file: #{event} #{fn}")
163+
@module_loader&.invalidate(fn)
164+
case event
165+
when :added
166+
add_route(fn)
167+
@cache.clear # TODO: remove only relevant cache entries
168+
when :removed
169+
entry = @files[fn]
170+
if entry
171+
remove_entry_cache_keys(entry)
172+
@routes.delete(entry[:canonical_path])
173+
@files.delete(fn)
174+
end
175+
when :modified
176+
entry = @files[fn]
177+
if entry && entry[:kind] == :module
178+
# invalidate the entry proc, so it will be recalculated
179+
entry[:proc] = nil
180+
end
181+
end
182+
end
183+
184+
def remove_entry_cache_keys(entry)
185+
entry[:cache_keys]&.each_key { @cache.delete(it) }.clear
186+
end
187+
end
188+
end

test/test_file_watch.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,21 @@ def test_file_watch
1414
queue = UM::Queue.new
1515

1616
f = @machine.spin do
17-
Syntropy.file_watch(@machine, @root, period: 0.01) { @machine.push(queue, it) }
17+
Syntropy.file_watch(@machine, @root, period: 0.01) { |event, fn| @machine.push(queue, [event, fn]) }
1818
end
1919
@machine.sleep(0.05)
2020
assert_equal 0, queue.count
2121

2222
fn = File.join(@root, 'foo.bar')
2323
IO.write(fn, 'abc')
24-
assert_equal fn, @machine.shift(queue)
24+
assert_equal [:added, fn], @machine.shift(queue)
2525

2626
fn = File.join(@root, 'foo.bar')
2727
IO.write(fn, 'def')
28-
assert_equal fn, @machine.shift(queue)
28+
assert_equal [:modified, fn], @machine.shift(queue)
2929

3030
FileUtils.rm(fn)
31-
assert_equal fn, @machine.shift(queue)
31+
assert_equal [:removed, fn], @machine.shift(queue)
3232
ensure
3333
@machine.schedule(f, UM::Terminate)
3434
# @machine.join(f)

test/test_router.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'helper'
4+
5+
class RouterTest < Minitest::Test
6+
APP_ROOT = File.join(__dir__, 'app')
7+
8+
def setup
9+
@machine = UM.new
10+
11+
@tmp_path = '/test/tmp'
12+
@tmp_fn = File.join(APP_ROOT, 'tmp.rb')
13+
14+
@router = Syntropy::Router.new(
15+
machine: @machine,
16+
location: APP_ROOT,
17+
mount_path: '/test',
18+
watch_files: 0.05
19+
)
20+
end
21+
22+
def full_path(fn)
23+
File.join(APP_ROOT, fn)
24+
end
25+
26+
def test_find_route
27+
# entry = @router['/']
28+
# assert_equal :not_found, entry[:kind]
29+
30+
entry = @router['/test']
31+
assert_equal :static, entry[:kind]
32+
assert_equal full_path('index.html'), entry[:fn]
33+
34+
entry = @router['/test/about']
35+
assert_equal :module, entry[:kind]
36+
assert_equal full_path('about/index.rb'), entry[:fn]
37+
38+
entry = @router['/test/../test_app.rb']
39+
assert_equal :not_found, entry[:kind]
40+
41+
entry = @router['/test/_layout/default']
42+
assert_equal :not_found, entry[:kind]
43+
44+
entry = @router['/test/api']
45+
assert_equal :module, entry[:kind]
46+
assert_equal full_path('api+.rb'), entry[:fn]
47+
48+
entry = @router['/test/api/foo/bar']
49+
assert_equal :module, entry[:kind]
50+
assert_equal full_path('api+.rb'), entry[:fn]
51+
52+
entry = @router['/test/api/foo/../bar']
53+
assert_equal :not_found, entry[:kind]
54+
55+
entry = @router['/test/api_1']
56+
assert_equal :not_found, entry[:kind]
57+
58+
entry = @router['/test/about/foo']
59+
assert_equal :markdown, entry[:kind]
60+
assert_equal full_path('about/foo.md'), entry[:fn]
61+
end
62+
63+
def test_router_file_watching
64+
@machine.sleep 0.2
65+
66+
entry = @router[@tmp_path]
67+
assert_equal :module, entry[:kind]
68+
69+
# remove file
70+
orig_body = IO.read(@tmp_fn)
71+
FileUtils.rm(@tmp_fn)
72+
@machine.sleep(0.3)
73+
74+
entry = @router[@tmp_path]
75+
assert_equal :not_found, entry[:kind]
76+
77+
IO.write(@tmp_fn, 'foobar')
78+
@machine.sleep(0.3)
79+
entry = @router[@tmp_path]
80+
assert_equal :module, entry[:kind]
81+
82+
entry[:proc] = ->(x) { x }
83+
IO.write(@tmp_fn, 'barbaz')
84+
@machine.sleep(0.3)
85+
assert_nil entry[:proc]
86+
ensure
87+
IO.write(@tmp_fn, orig_body) if orig_body
88+
end
89+
end

0 commit comments

Comments
 (0)