syntropy 0.15.1 → 0.16
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 +17 -0
- data/TODO.md +145 -0
- data/bin/syntropy +14 -5
- data/examples/card.rb +12 -0
- data/examples/counter.js +20 -0
- data/examples/counter.rb +23 -0
- data/examples/counter_api.rb +22 -0
- data/examples/favicon.ico +0 -0
- data/examples/index.md +8 -0
- data/examples/templates.rb +40 -0
- data/lib/syntropy/app.rb +58 -22
- data/lib/syntropy/applets/builtin/auto_refresh/watch.js +12 -0
- data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +49 -0
- data/lib/syntropy/applets/builtin/debug/debug.css +61 -0
- data/lib/syntropy/applets/builtin/debug/debug.js +57 -0
- data/lib/syntropy/applets/builtin/json_api.js +28 -0
- data/lib/syntropy/applets/builtin/ping.rb +3 -0
- data/lib/syntropy/dev_mode.rb +11 -0
- data/lib/syntropy/{rpc_api.rb → json_api.rb} +1 -1
- data/lib/syntropy/module.rb +48 -18
- data/lib/syntropy/p2_extensions.rb +12 -0
- data/lib/syntropy/routing_tree.rb +77 -5
- data/lib/syntropy/utils.rb +11 -0
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +2 -2
- data/syntropy.gemspec +2 -3
- data/test/app/rss.rb +3 -0
- data/test/bm_router_proc.rb +201 -0
- data/test/test_app.rb +4 -4
- data/test/test_json_api.rb +59 -0
- data/test/test_routing_tree.rb +38 -3
- metadata +25 -22
- data/test/test_rpc_api.rb +0 -41
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b90967d51ee11dd83db1237db95b35fb1a2bd9cb36248086bfe62082ddd4c0a5
|
4
|
+
data.tar.gz: 187eb2adc5a21df0f8d0400ef54609300658df9e3ba371686c1007d4744c0667
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1c0aff28315990526db9d48c1d4dba164d9944f93bdbcb671fdd6526a2f77eac28fa89cc2a99e68ce6bc5e55db85582270b3ea6032456c5e5d2fe9847150e3ae
|
7
|
+
data.tar.gz: 10d5ceb7568d22a07d89e13cca104aeeeea94e62a03c778a72c3da360b8a21ef466038d4d872b75ae00b1634c2dfffc8931e4a61695e95e99c78a51bfcd5c643
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,20 @@
|
|
1
|
+
# 0.16 2025-09-11
|
2
|
+
|
3
|
+
- `syntropy` script:
|
4
|
+
- Remove trailing slash for root dir in syntropy script
|
5
|
+
- Rename `-w/--watch` option to `-d/--dev` for development mode
|
6
|
+
- Fix `--mount` option
|
7
|
+
- Add builtin `/.syntropy` applet for builtin features:
|
8
|
+
- auto refresh for web pages
|
9
|
+
- JSON API
|
10
|
+
- Template debugging frontend tools
|
11
|
+
- ping route
|
12
|
+
- home page with links to examples
|
13
|
+
- Implement applet mounting and loading
|
14
|
+
- Remove Papercraft dependency
|
15
|
+
- Add support for P2 XML templates, using `#template_xml`
|
16
|
+
- Update P2, TP2
|
17
|
+
|
1
18
|
# 0.15 2025-08-31
|
2
19
|
|
3
20
|
- Implement invalidation of reverse dependencies on module file change
|
data/TODO.md
CHANGED
@@ -22,6 +22,8 @@
|
|
22
22
|
article.layout #=>
|
23
23
|
article.render_proc #=> (load layout, apply article)
|
24
24
|
article.render #=> (render to HTML)
|
25
|
+
|
26
|
+
# there should also be methods for creating, updating and deleting of articles/items.
|
25
27
|
...
|
26
28
|
```
|
27
29
|
|
@@ -30,6 +32,149 @@
|
|
30
32
|
- [ ] support for caching headers
|
31
33
|
- [ ] add `Request#render_static_file(route, fn)
|
32
34
|
|
35
|
+
- [ ] Serving of built-in assets (mostly JS)
|
36
|
+
- [ ] JS lib for RPC API
|
37
|
+
|
38
|
+
## Missing for a first public release
|
39
|
+
|
40
|
+
- [ ] Logo
|
41
|
+
- [ ] Website
|
42
|
+
- [ ] Frontend part of RPC API
|
43
|
+
- [v] Auto-refresh page when file changes
|
44
|
+
- [ ] Examples
|
45
|
+
- [ ] Reactive app - counter or some other simple app showing interaction with server
|
46
|
+
- [ ] ?
|
47
|
+
|
48
|
+
## Counter example
|
49
|
+
|
50
|
+
Here's a react component (from https://fresh.deno.dev/):
|
51
|
+
|
52
|
+
```jsx
|
53
|
+
// islands/Counter.tsx
|
54
|
+
import { useSignal } from "@preact/signals";
|
55
|
+
|
56
|
+
export default function Counter(props) {
|
57
|
+
const count = useSignal(props.start);
|
58
|
+
|
59
|
+
return (
|
60
|
+
<div>
|
61
|
+
<h3>Interactive island</h3>
|
62
|
+
<p>The server supplied the initial value of {props.start}.</p>
|
63
|
+
<div>
|
64
|
+
<button onClick={() => count.value -= 1}>-</button>
|
65
|
+
<div>{count}</div>
|
66
|
+
<button onClick={() => count.value += 1}>+</button>
|
67
|
+
</div>
|
68
|
+
</div>
|
69
|
+
);
|
70
|
+
}
|
71
|
+
```
|
72
|
+
|
73
|
+
How do we do this with Syntropy? Can we make a component that does the
|
74
|
+
templating and the reactivity in a single file? Can we wrap reactivity in a
|
75
|
+
component that has its own state? And where does the state live?
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
class Counter < Syntropy::Component
|
79
|
+
def initialize(start:, **props)
|
80
|
+
@count = reactive()
|
81
|
+
end
|
82
|
+
|
83
|
+
def template
|
84
|
+
div {
|
85
|
+
h3 'Interactive island'
|
86
|
+
p "The server supplied the initial value of #props[:start]}"
|
87
|
+
div {
|
88
|
+
button '-', on_click: -> { @count.value -= 1 }
|
89
|
+
div @count.value
|
90
|
+
button '+', on_click: -> { @count.value += 1 }
|
91
|
+
}
|
92
|
+
}
|
93
|
+
end
|
94
|
+
end
|
95
|
+
```
|
96
|
+
|
97
|
+
Hmm, don't know if the complexity is worth it. It's an abstraction that's very
|
98
|
+
costly - both in terms of complexity of computation, and in terms of having a
|
99
|
+
clear mental model of what's happening under the hood.
|
100
|
+
|
101
|
+
I think a more logical approach is to stay with well-defined boundaries between
|
102
|
+
computation on the frontend and computation on the backend, and a having a clear
|
103
|
+
understanding of where things happen:
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
class Counter < Syntropy::Component
|
107
|
+
def incr
|
108
|
+
update(value: @props[:value] + 1)
|
109
|
+
end
|
110
|
+
|
111
|
+
def decr
|
112
|
+
update(value: @props[:value] - 1)
|
113
|
+
end
|
114
|
+
|
115
|
+
def template
|
116
|
+
div {
|
117
|
+
h3 'Interactive island'
|
118
|
+
div {
|
119
|
+
button '-', syn_click: 'decr'
|
120
|
+
div @props[:value]
|
121
|
+
button '+', syn_click: 'incr'
|
122
|
+
}
|
123
|
+
}
|
124
|
+
end
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
Now, we can do all kinds of wrapping with scripts and ids and stuff to make this
|
129
|
+
work, but still, it would be preferable for the interactivity to be expressed in
|
130
|
+
JS. Maybe we just need a way to include a script that acts on the local HTML
|
131
|
+
code. How can we do this without writing a web component etc?
|
132
|
+
|
133
|
+
One way is to assign a random id to the template, then have a script that works
|
134
|
+
on it locally.
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
export template { |**props|
|
138
|
+
id = SecureRandom.hex(4)
|
139
|
+
div(id: id) {
|
140
|
+
h3 'Interactive island'
|
141
|
+
div {
|
142
|
+
button '-', syn_action: "decr"
|
143
|
+
div props[:value], syn_value: "value"
|
144
|
+
button '+', syn_action: "incr"
|
145
|
+
}
|
146
|
+
script <<~JS
|
147
|
+
const state = { value: #{props[:value]} }
|
148
|
+
const root = document.querySelector('##{id}')
|
149
|
+
const decr = root.querySelector('[syn-action="decr"]')
|
150
|
+
const incr = root.querySelector('[syn-action="incr"]')
|
151
|
+
const value = root.querySelector('[syn-value="value"]')
|
152
|
+
const updateValue = (v) => { state.value = v; value.innerText = String(v) }
|
153
|
+
|
154
|
+
decr.addEventListener('click', (e) => { updateValue(state.value - 1) })
|
155
|
+
incr.addEventListener('click', (e) => { updateValue(state.value + 1) })
|
156
|
+
JS
|
157
|
+
}
|
158
|
+
}
|
159
|
+
```
|
160
|
+
|
161
|
+
How can we make this less verbose, less painful, less error-prone?
|
162
|
+
|
163
|
+
One way is to say - we don't worry about this on the backend, we just write
|
164
|
+
normal JS for the frontend and forget about the whole thing. Another way is to
|
165
|
+
provide a set of tools for making this less painful:
|
166
|
+
|
167
|
+
- Add some fancier abstractions on top of the JS RPC lib
|
168
|
+
- Add some template extensions that inject JS into the generated HTML
|
169
|
+
|
170
|
+
## Testing facilities
|
171
|
+
|
172
|
+
- What do we need to test?
|
173
|
+
- Routes
|
174
|
+
- Route responses
|
175
|
+
- Changes to state / DB
|
176
|
+
-
|
177
|
+
|
33
178
|
## Support for applets
|
34
179
|
|
35
180
|
- can be implemented as separate gems
|
data/bin/syntropy
CHANGED
@@ -7,7 +7,8 @@ require 'optparse'
|
|
7
7
|
env = {
|
8
8
|
mount_path: '/',
|
9
9
|
banner: Syntropy::BANNER,
|
10
|
-
logger: true
|
10
|
+
logger: true,
|
11
|
+
builtin_applet_path: '/.syntropy'
|
11
12
|
}
|
12
13
|
|
13
14
|
parser = OptionParser.new do |o|
|
@@ -24,7 +25,8 @@ parser = OptionParser.new do |o|
|
|
24
25
|
env[:logger] = nil
|
25
26
|
end
|
26
27
|
|
27
|
-
o.on('-
|
28
|
+
o.on('-d', '--dev', 'Development mode') do
|
29
|
+
env[:dev_mode] = true
|
28
30
|
env[:watch_files] = 0.1
|
29
31
|
end
|
30
32
|
|
@@ -33,8 +35,14 @@ parser = OptionParser.new do |o|
|
|
33
35
|
exit
|
34
36
|
end
|
35
37
|
|
36
|
-
o.on('-m', '--mount', 'Set mount path (default: /)') do
|
37
|
-
|
38
|
+
o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
|
39
|
+
p mount: path
|
40
|
+
env[:mount_path] = path
|
41
|
+
env[:builtin_applet_path] = File.join(path, '.syntropy')
|
42
|
+
end
|
43
|
+
|
44
|
+
o.on('--no-builtin-applet', 'Do not mount builtin applet') do
|
45
|
+
env[:builtin_applet_path] = nil
|
38
46
|
end
|
39
47
|
|
40
48
|
o.on('-v', '--version', 'Show version') do
|
@@ -54,7 +62,7 @@ rescue StandardError => e
|
|
54
62
|
exit
|
55
63
|
end
|
56
64
|
|
57
|
-
env[:root_dir] = ARGV.shift || '.'
|
65
|
+
env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
|
58
66
|
|
59
67
|
if !File.directory?(env[:root_dir])
|
60
68
|
puts "#{File.expand_path(env[:root_dir])} Not a directory"
|
@@ -70,6 +78,7 @@ env[:machine] = Syntropy.machine = UM.new
|
|
70
78
|
env[:logger] = env[:logger] && TP2::Logger.new(env[:machine], **env)
|
71
79
|
|
72
80
|
require 'syntropy/version'
|
81
|
+
require 'syntropy/dev_mode' if env[:dev_mode]
|
73
82
|
|
74
83
|
env[:logger]&.info(message: "Running Syntropy version #{Syntropy::VERSION}")
|
75
84
|
app = Syntropy::App.load(env)
|
data/examples/card.rb
ADDED
data/examples/counter.js
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
import JSONAPI from '/.syntropy/json_api.js'
|
2
|
+
|
3
|
+
(() => {
|
4
|
+
const api = new JSONAPI('/counter_api');
|
5
|
+
|
6
|
+
const value = document.querySelector('#value');
|
7
|
+
const decr = document.querySelector('#decr');
|
8
|
+
const incr = document.querySelector('#incr');
|
9
|
+
|
10
|
+
decr.addEventListener('click', async () => {
|
11
|
+
const result = await api.post('decr')
|
12
|
+
value.innerText = String(result);
|
13
|
+
});
|
14
|
+
|
15
|
+
incr.addEventListener('click', async () => {
|
16
|
+
const result = await api.post('incr')
|
17
|
+
value.innerText = String(result);
|
18
|
+
});
|
19
|
+
|
20
|
+
})()
|
data/examples/counter.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
CounterAPI = import 'counter_api'
|
2
|
+
|
3
|
+
export template {
|
4
|
+
html5 {
|
5
|
+
body {
|
6
|
+
p { a '< Home', href: '/' }
|
7
|
+
|
8
|
+
h1 'Counter'
|
9
|
+
|
10
|
+
div {
|
11
|
+
button '-', id: 'decr'
|
12
|
+
value CounterAPI.value, id: 'value'
|
13
|
+
button '+', id: 'incr'
|
14
|
+
}
|
15
|
+
}
|
16
|
+
script src: '/counter.js', type: 'module'
|
17
|
+
style <<~CSS
|
18
|
+
div { font-weight: bold; font-size: 1.3em }
|
19
|
+
value { display: inline-block; padding: 0 1em; color: blue; width: 1em }
|
20
|
+
CSS
|
21
|
+
auto_refresh_watch!
|
22
|
+
}
|
23
|
+
}
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CounterAPI < Syntropy::JSONAPI
|
4
|
+
def initialize(env)
|
5
|
+
@env = env
|
6
|
+
@value = env[:counter_value] || 0
|
7
|
+
end
|
8
|
+
|
9
|
+
def value(req = nil)
|
10
|
+
@value
|
11
|
+
end
|
12
|
+
|
13
|
+
def incr!(req)
|
14
|
+
@value += 1
|
15
|
+
end
|
16
|
+
|
17
|
+
def decr!(req)
|
18
|
+
@value -= 1
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
export CounterAPI
|
File without changes
|
data/examples/index.md
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
Card = import 'card'
|
2
|
+
|
3
|
+
export template {
|
4
|
+
html5 {
|
5
|
+
head {
|
6
|
+
style {
|
7
|
+
raw <<~CSS
|
8
|
+
div {
|
9
|
+
display: grid;
|
10
|
+
grid-template-columns: 1fr 1fr;
|
11
|
+
}
|
12
|
+
span.foo {
|
13
|
+
color: white;
|
14
|
+
background-color: blue;
|
15
|
+
padding: 1em;
|
16
|
+
}
|
17
|
+
span.bar {
|
18
|
+
color: white;
|
19
|
+
background-color: green;
|
20
|
+
padding: 1em;
|
21
|
+
}
|
22
|
+
CSS
|
23
|
+
}
|
24
|
+
}
|
25
|
+
body {
|
26
|
+
p { a '< Home', href: '/' }
|
27
|
+
|
28
|
+
h1 'Testing'
|
29
|
+
|
30
|
+
div {
|
31
|
+
span 'foo', class: 'foo'
|
32
|
+
span 'bar', class: 'bar'
|
33
|
+
}
|
34
|
+
|
35
|
+
Card()
|
36
|
+
}
|
37
|
+
auto_refresh_watch!
|
38
|
+
debug_template!
|
39
|
+
}
|
40
|
+
}
|
data/lib/syntropy/app.rb
CHANGED
@@ -43,6 +43,7 @@ module Syntropy
|
|
43
43
|
@root_dir = File.expand_path(env[:root_dir])
|
44
44
|
@mount_path = env[:mount_path]
|
45
45
|
@env = env
|
46
|
+
@logger = env[:logger]
|
46
47
|
|
47
48
|
@module_loader = Syntropy::ModuleLoader.new(app: self, **env)
|
48
49
|
setup_routing_tree
|
@@ -70,15 +71,22 @@ module Syntropy
|
|
70
71
|
proc = route[:proc] ||= compute_route_proc(route)
|
71
72
|
proc.(req)
|
72
73
|
rescue StandardError => e
|
73
|
-
@
|
74
|
-
message: "Error while serving request",
|
74
|
+
@logger&.error(
|
75
|
+
message: "Error while serving request: #{e.message}",
|
75
76
|
method: req.method,
|
76
|
-
path: req.path
|
77
|
+
path: req.path,
|
78
|
+
error: e
|
77
79
|
)
|
78
80
|
error_handler = get_error_handler(route)
|
79
81
|
error_handler.(req, e)
|
80
82
|
end
|
81
83
|
|
84
|
+
def route(path, params = {}, compute_proc: false)
|
85
|
+
route = @router_proc.(path, params)
|
86
|
+
route[:proc] ||= compute_route_proc(route) if compute_proc
|
87
|
+
route
|
88
|
+
end
|
89
|
+
|
82
90
|
private
|
83
91
|
|
84
92
|
# Instantiates a routing tree with the app settings, and generates a router
|
@@ -89,9 +97,16 @@ module Syntropy
|
|
89
97
|
@routing_tree = Syntropy::RoutingTree.new(
|
90
98
|
root_dir: @root_dir, mount_path: @mount_path, **@env
|
91
99
|
)
|
100
|
+
mount_builtin_applet if @env[:builtin_applet_path]
|
92
101
|
@router_proc = @routing_tree.router_proc
|
93
102
|
end
|
94
103
|
|
104
|
+
def mount_builtin_applet
|
105
|
+
path = @env[:builtin_applet_path]
|
106
|
+
@builtin_applet ||= Syntropy.builtin_applet(@env, mount_path: path)
|
107
|
+
@routing_tree.mount_applet(path, @builtin_applet)
|
108
|
+
end
|
109
|
+
|
95
110
|
# Computes the route proc for the given route, wrapping it in hooks found up
|
96
111
|
# the routing tree.
|
97
112
|
#
|
@@ -146,9 +161,9 @@ module Syntropy
|
|
146
161
|
if (layout = atts[:layout])
|
147
162
|
route[:applied_layouts] ||= {}
|
148
163
|
proc = route[:applied_layouts][layout] ||= markdown_layout_proc(layout)
|
149
|
-
html = proc.render(md
|
164
|
+
html = proc.render(md:, **atts)
|
150
165
|
else
|
151
|
-
html =
|
166
|
+
html = default_markdown_layout_proc.render(md:, **atts)
|
152
167
|
end
|
153
168
|
html
|
154
169
|
end
|
@@ -160,9 +175,22 @@ module Syntropy
|
|
160
175
|
@layouts[layout] = template.apply { |md:, **| markdown(md) }
|
161
176
|
end
|
162
177
|
|
178
|
+
def default_markdown_layout_proc
|
179
|
+
@default_markdown_layout ||= ->(md:, **atts) {
|
180
|
+
html5 {
|
181
|
+
head {
|
182
|
+
title atts[:title]
|
183
|
+
}
|
184
|
+
body {
|
185
|
+
markdown md
|
186
|
+
auto_refresh_watch!
|
187
|
+
}
|
188
|
+
}
|
189
|
+
}
|
190
|
+
end
|
191
|
+
|
163
192
|
def module_route_proc(route)
|
164
193
|
ref = @routing_tree.fn_to_rel_path(route[:target][:fn])
|
165
|
-
# ref = route[:target][:fn].sub(@mount_path, '')
|
166
194
|
mod = @module_loader.load(ref)
|
167
195
|
compute_module_proc(mod)
|
168
196
|
end
|
@@ -171,31 +199,23 @@ module Syntropy
|
|
171
199
|
case mod
|
172
200
|
when P2::Template
|
173
201
|
p2_template_proc(mod)
|
174
|
-
when Papercraft::Template
|
175
|
-
papercraft_template_proc(mod)
|
176
202
|
else
|
177
203
|
mod
|
178
204
|
end
|
179
205
|
end
|
180
206
|
|
181
207
|
def p2_template_proc(template)
|
208
|
+
xml_mode = template.mode == :xml
|
182
209
|
template = template.proc
|
183
|
-
|
210
|
+
mime_type = xml_mode ? 'text/xml; charset=UTF-8' : 'text/html; charset=UTF-8'
|
211
|
+
headers = { 'Content-Type' => mime_type }
|
184
212
|
|
185
|
-
->
|
186
|
-
req.respond_by_http_method(
|
187
|
-
'head' => [nil, headers],
|
188
|
-
'get' => -> { [template.render, headers] }
|
189
|
-
)
|
190
|
-
}
|
191
|
-
end
|
213
|
+
get_proc = xml_mode ? -> { [template.render_xml, headers] } : -> { [template.render, headers] }
|
192
214
|
|
193
|
-
def papercraft_template_proc(template)
|
194
|
-
headers = { 'Content-Type' => template.mime_type }
|
195
215
|
->(req) {
|
196
216
|
req.respond_by_http_method(
|
197
217
|
'head' => [nil, headers],
|
198
|
-
'get' =>
|
218
|
+
'get' => get_proc
|
199
219
|
)
|
200
220
|
}
|
201
221
|
end
|
@@ -232,7 +252,7 @@ module Syntropy
|
|
232
252
|
DEFAULT_ERROR_HANDLER = ->(req, err) {
|
233
253
|
msg = err.message
|
234
254
|
msg = nil if msg.empty? || (req.method == 'head')
|
235
|
-
req.respond(msg, ':status' => Syntropy::Error.http_status(err))
|
255
|
+
req.respond(msg, ':status' => Syntropy::Error.http_status(err)) rescue nil
|
236
256
|
}
|
237
257
|
|
238
258
|
# Returns an error handler for the given route. If route is nil, looks up
|
@@ -286,7 +306,7 @@ module Syntropy
|
|
286
306
|
# setup tasks
|
287
307
|
@machine.sleep 0.2
|
288
308
|
route_count = @routing_tree.static_map.size + @routing_tree.dynamic_map.size
|
289
|
-
@
|
309
|
+
@logger&.info(
|
290
310
|
message: "Serving from #{@root_dir} (#{route_count} routes found)"
|
291
311
|
)
|
292
312
|
|
@@ -302,7 +322,7 @@ module Syntropy
|
|
302
322
|
wf = @env[:watch_files]
|
303
323
|
period = wf.is_a?(Numeric) ? wf : 0.1
|
304
324
|
Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
|
305
|
-
@
|
325
|
+
@logger&.info(message: 'File change detected', fn: fn)
|
306
326
|
@module_loader.invalidate_fn(fn)
|
307
327
|
debounce_file_change
|
308
328
|
end
|
@@ -324,7 +344,23 @@ module Syntropy
|
|
324
344
|
@machine.sleep(0.1)
|
325
345
|
setup_routing_tree
|
326
346
|
@routing_tree_reloader = nil
|
347
|
+
signal_auto_refresh_watchers!
|
327
348
|
end
|
328
349
|
end
|
350
|
+
|
351
|
+
def signal_auto_refresh_watchers!
|
352
|
+
return if !@builtin_applet
|
353
|
+
|
354
|
+
watcher_route_path = File.join(@env[:builtin_applet_path], 'auto_refresh/watch.sse')
|
355
|
+
watcher_route = @builtin_applet.route(watcher_route_path, compute_proc: true)
|
356
|
+
|
357
|
+
watcher_mod = watcher_route[:proc]
|
358
|
+
watcher_mod.signal!
|
359
|
+
rescue => e
|
360
|
+
@logger&.error(
|
361
|
+
message: 'Unexpected error while signalling auto refresh watcher',
|
362
|
+
error: e
|
363
|
+
)
|
364
|
+
end
|
329
365
|
end
|
330
366
|
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
(() => {
|
2
|
+
const jsURL = import.meta.url;
|
3
|
+
const sseURL = jsURL.replace(/\.js$/, '.sse');
|
4
|
+
const eventSource = new EventSource(sseURL);
|
5
|
+
|
6
|
+
eventSource.addEventListener('message', (msg) => {
|
7
|
+
if (msg.data != '') window.location.reload();
|
8
|
+
})
|
9
|
+
eventSource.addEventListener('error', () => {
|
10
|
+
console.log(`Failed to connect to auto refresh watcher (${sseURL})`);
|
11
|
+
})
|
12
|
+
})()
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module implements an SSE (server-sent events) route, that emits a message
|
4
|
+
# to the client when a file has been changed. The module is signalled by the
|
5
|
+
# running app whenever a file change, when in watch mode (`-w`). This route
|
6
|
+
# resides by default at `/.syntropy/auto_refresh/watch.sse`.
|
7
|
+
#
|
8
|
+
# The complementary client-side party is implemented in a small JS script
|
9
|
+
# residing by default at `/.syntropy/auto_refresh/watch.js`.
|
10
|
+
|
11
|
+
# Returns a hash holding references to queues for ongoing `watch.sse` requests.
|
12
|
+
def watchers
|
13
|
+
@watchers ||= {}
|
14
|
+
end
|
15
|
+
|
16
|
+
# Signals a file change by pushing to all watcher queues.
|
17
|
+
def signal!
|
18
|
+
watchers.each_key { @machine.push(it, true) }
|
19
|
+
end
|
20
|
+
|
21
|
+
# Handles incoming requests to the `watch.sse` route. Adds a queue to the list
|
22
|
+
# of watchers, and waits for the queue to be signalled. In the absence of file
|
23
|
+
# change, a timeout occurs after one minute, and the request is terminated.
|
24
|
+
def call(req)
|
25
|
+
queue = UM::Queue.new
|
26
|
+
watchers[queue] = true
|
27
|
+
|
28
|
+
req.send_headers('Content-Type' => 'text/event-stream')
|
29
|
+
req.send_chunk("data: \n\n")
|
30
|
+
@machine.timeout(60, Timeout::Error) do
|
31
|
+
@machine.shift(queue)
|
32
|
+
req.send_chunk("data: refresh\n\n")
|
33
|
+
end
|
34
|
+
req.send_chunk("retry: 0\n\n", done: true) rescue nil
|
35
|
+
rescue Timeout::Error
|
36
|
+
req.send_chunk("retry: 0\n\n", done: true) rescue nil
|
37
|
+
rescue SystemCallError
|
38
|
+
# ignore
|
39
|
+
rescue => e
|
40
|
+
@logger&.error(
|
41
|
+
message: 'Unexpected error encountered while serving auto refresh watcher',
|
42
|
+
error: e
|
43
|
+
)
|
44
|
+
req.finish rescue nil
|
45
|
+
ensure
|
46
|
+
watchers.delete(queue)
|
47
|
+
end
|
48
|
+
|
49
|
+
export self
|
@@ -0,0 +1,61 @@
|
|
1
|
+
debug-attachment {
|
2
|
+
position: relative;
|
3
|
+
/* display: block; */
|
4
|
+
top: 0;
|
5
|
+
right: 0;
|
6
|
+
bottom: 0;
|
7
|
+
left: 0;
|
8
|
+
/* opacity: 0; */
|
9
|
+
width: 100%;
|
10
|
+
height: 100%;
|
11
|
+
}
|
12
|
+
|
13
|
+
debug-label {
|
14
|
+
display: block;
|
15
|
+
position: absolute;
|
16
|
+
top: -12px;
|
17
|
+
left: 4px;
|
18
|
+
background: rgba(0, 0, 0, 0.8);
|
19
|
+
color: white;
|
20
|
+
padding: 2px 6px;
|
21
|
+
font-size: 11px;
|
22
|
+
font-family: 'Segoe UI', Roboto, monospace;
|
23
|
+
font-weight: 500;
|
24
|
+
border-radius: 3px;
|
25
|
+
display: block;
|
26
|
+
z-index: 1001;
|
27
|
+
cursor: pointer;
|
28
|
+
/* transition: all 0.2s ease; */
|
29
|
+
white-space: nowrap;
|
30
|
+
line-height: 1.2;
|
31
|
+
|
32
|
+
a, a:hover, a:visited {
|
33
|
+
color: white;
|
34
|
+
}
|
35
|
+
|
36
|
+
&:hover {
|
37
|
+
display: block;
|
38
|
+
}
|
39
|
+
|
40
|
+
&.fn {
|
41
|
+
display: block;
|
42
|
+
}
|
43
|
+
}
|
44
|
+
|
45
|
+
body *:not(script)[data-syntropy-level]:hover {
|
46
|
+
outline: #13630c dotted 2px;
|
47
|
+
outline-offset: 2px;
|
48
|
+
|
49
|
+
/* debug-label {
|
50
|
+
display: block;
|
51
|
+
} */
|
52
|
+
}
|
53
|
+
|
54
|
+
body[data-syntropy-level="1"], body *:not(script)[data-syntropy-level="1"] {
|
55
|
+
outline: #2357aa dotted 2px;
|
56
|
+
outline-offset: 2px;
|
57
|
+
}
|
58
|
+
|
59
|
+
card {
|
60
|
+
display: block;
|
61
|
+
}
|