tina4ruby 3.13.34 → 3.13.36
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/lib/tina4/dev_admin.rb +160 -15
- data/lib/tina4/mcp.rb +16 -6
- data/lib/tina4/public/js/tina4-dev-admin.js +437 -759
- data/lib/tina4/public/js/tina4-dev-admin.min.js +437 -759
- data/lib/tina4/rack_app.rb +105 -1
- data/lib/tina4/router.rb +43 -9
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +48 -0
- data/lib/tina4.rb +1 -0
- metadata +1 -1
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -36,6 +36,29 @@ module Tina4
|
|
|
36
36
|
|
|
37
37
|
# Shared WebSocket engine for route-based WS handling
|
|
38
38
|
@websocket_engine = Tina4::WebSocket.new
|
|
39
|
+
|
|
40
|
+
# Register the dev-reload WebSocket route (debug mode only) so a browser
|
|
41
|
+
# handshake to /__dev_reload is accepted and held open by the connection
|
|
42
|
+
# manager. Without this the handshake never matches a route and falls
|
|
43
|
+
# through to 404, silently degrading the whole reloader to polling.
|
|
44
|
+
RackApp.register_dev_reload_ws if dev_mode?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# WebSocket handler for the dev-reload channel (/__dev_reload).
|
|
48
|
+
#
|
|
49
|
+
# Connections are accepted and held open; the shared Tina4::DevReload
|
|
50
|
+
# manager keeps a reference (wired in handle_websocket_upgrade) so
|
|
51
|
+
# POST /__dev/api/reload can broadcast an instant reload to every browser.
|
|
52
|
+
# Incoming frames are ignored — the open socket is the whole point.
|
|
53
|
+
DEV_RELOAD_WS_HANDLER = proc { |_connection, _event, _data| nil }
|
|
54
|
+
|
|
55
|
+
# Register the /__dev_reload WebSocket route (idempotent). Guarded on the
|
|
56
|
+
# router's actual state rather than a one-shot flag so that a Router.clear!
|
|
57
|
+
# (specs, hot-reload rescans) followed by a fresh RackApp re-registers it.
|
|
58
|
+
def self.register_dev_reload_ws
|
|
59
|
+
return if Tina4::Router.find_ws_route("/__dev_reload")
|
|
60
|
+
|
|
61
|
+
Tina4::Router.websocket("/__dev_reload", &DEV_RELOAD_WS_HANDLER)
|
|
39
62
|
end
|
|
40
63
|
|
|
41
64
|
def call(env)
|
|
@@ -865,8 +888,15 @@ module Tina4
|
|
|
865
888
|
# Create a dedicated WebSocket engine for this route so handlers stay isolated
|
|
866
889
|
ws = Tina4::WebSocket.new
|
|
867
890
|
|
|
891
|
+
# The dev-reload channel is held by a process-wide shared manager so a
|
|
892
|
+
# broadcast from POST /__dev/api/reload reaches every browser, not just
|
|
893
|
+
# the connections of this one isolated per-socket engine. Mirrors
|
|
894
|
+
# Python's single _ws_manager keyed on the /__dev_reload path.
|
|
895
|
+
dev_reload = ws_route.path == "/__dev_reload"
|
|
896
|
+
|
|
868
897
|
ws.on(:open) do |connection|
|
|
869
898
|
connection.params = ws_params
|
|
899
|
+
Tina4::DevReload.add(connection) if dev_reload
|
|
870
900
|
handler.call(connection, :open, nil)
|
|
871
901
|
end
|
|
872
902
|
|
|
@@ -875,6 +905,7 @@ module Tina4
|
|
|
875
905
|
end
|
|
876
906
|
|
|
877
907
|
ws.on(:close) do |connection|
|
|
908
|
+
Tina4::DevReload.remove(connection) if dev_reload
|
|
878
909
|
handler.call(connection, :close, nil)
|
|
879
910
|
end
|
|
880
911
|
|
|
@@ -1014,7 +1045,7 @@ module Tina4
|
|
|
1014
1045
|
el.style.color='#f38ba8';
|
|
1015
1046
|
});
|
|
1016
1047
|
}
|
|
1017
|
-
#{ai_port ? "" :
|
|
1048
|
+
#{ai_port ? "" : dev_reload_client_js}
|
|
1018
1049
|
</script>
|
|
1019
1050
|
HTML
|
|
1020
1051
|
|
|
@@ -1025,6 +1056,79 @@ module Tina4
|
|
|
1025
1056
|
end
|
|
1026
1057
|
end
|
|
1027
1058
|
|
|
1059
|
+
# WebSocket-primary dev reloader injected into HTML pages in debug mode.
|
|
1060
|
+
#
|
|
1061
|
+
# The running server re-imports changed src/ route files in-process and
|
|
1062
|
+
# pushes a {type,file,mtime} message over /__dev_reload — no respawn,
|
|
1063
|
+
# instant refresh. The mtime poll is a FALLBACK only: it is started when
|
|
1064
|
+
# the socket is down and stopped the moment it connects. On a CSS change
|
|
1065
|
+
# the client swaps <link rel=stylesheet> hrefs with a cache-bust query;
|
|
1066
|
+
# any other change does a full location.reload(). The poll seeds its
|
|
1067
|
+
# last-seen mtime to a null sentinel (NOT 0) and reloads whenever the
|
|
1068
|
+
# polled mtime DIFFERS (not just when greater) so the first change after
|
|
1069
|
+
# load isn't swallowed and a counter reset on restart still triggers.
|
|
1070
|
+
# Mirrors the Python master's injected client exactly.
|
|
1071
|
+
def dev_reload_client_js
|
|
1072
|
+
poll_interval_ms = (ENV["TINA4_DEV_POLL_INTERVAL"] || "3000").to_i
|
|
1073
|
+
poll_interval_ms = 3000 if poll_interval_ms <= 0
|
|
1074
|
+
<<~JS
|
|
1075
|
+
(function(){
|
|
1076
|
+
var _t4_css_exts=['.css','.scss'],_t4_debounce=null;
|
|
1077
|
+
var _t4_interval=parseInt('#{poll_interval_ms}')||3000;
|
|
1078
|
+
var _t4_ws=null,_t4_poll_timer=null,_t4_mtime=null;
|
|
1079
|
+
function _t4_apply(d){
|
|
1080
|
+
d=d||{};
|
|
1081
|
+
var f=d.file||'',t=d.type||'';
|
|
1082
|
+
var isCss=t==='css'||_t4_css_exts.some(function(e){return f.endsWith(e)});
|
|
1083
|
+
if(isCss){
|
|
1084
|
+
var links=document.querySelectorAll('link[rel="stylesheet"]');
|
|
1085
|
+
links.forEach(function(l){
|
|
1086
|
+
var href=l.getAttribute('href');
|
|
1087
|
+
if(href){l.setAttribute('href',href.split('?')[0]+'?_t4='+(d.mtime||Date.now()))}
|
|
1088
|
+
});
|
|
1089
|
+
}else{
|
|
1090
|
+
location.reload();
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function _t4_poll(){
|
|
1094
|
+
fetch('/__dev/api/mtime').then(function(r){return r.json()}).then(function(d){
|
|
1095
|
+
if(_t4_mtime===null){_t4_mtime=d.mtime;return;}
|
|
1096
|
+
if(d.mtime!==_t4_mtime){
|
|
1097
|
+
_t4_mtime=d.mtime;
|
|
1098
|
+
if(_t4_debounce)clearTimeout(_t4_debounce);
|
|
1099
|
+
_t4_debounce=setTimeout(function(){_t4_apply(d);},500);
|
|
1100
|
+
}
|
|
1101
|
+
}).catch(function(){});
|
|
1102
|
+
}
|
|
1103
|
+
function _t4_startPoll(){
|
|
1104
|
+
if(_t4_poll_timer)return;
|
|
1105
|
+
_t4_mtime=null;
|
|
1106
|
+
_t4_poll_timer=setInterval(_t4_poll,_t4_interval);
|
|
1107
|
+
}
|
|
1108
|
+
function _t4_stopPoll(){
|
|
1109
|
+
if(_t4_poll_timer){clearInterval(_t4_poll_timer);_t4_poll_timer=null;}
|
|
1110
|
+
}
|
|
1111
|
+
function _t4_connect(){
|
|
1112
|
+
var url=(location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/__dev_reload';
|
|
1113
|
+
try{_t4_ws=new WebSocket(url);}catch(_){_t4_startPoll();return;}
|
|
1114
|
+
_t4_ws.addEventListener('open',function(){_t4_stopPoll();});
|
|
1115
|
+
_t4_ws.addEventListener('message',function(ev){
|
|
1116
|
+
var d=null;
|
|
1117
|
+
try{d=typeof ev.data==='string'?JSON.parse(ev.data):null;}catch(_){}
|
|
1118
|
+
if(!d)return;
|
|
1119
|
+
if(d.type==='reload'||d.type==='change'||d.type==='css'){
|
|
1120
|
+
if(_t4_debounce)clearTimeout(_t4_debounce);
|
|
1121
|
+
_t4_debounce=setTimeout(function(){_t4_apply(d);},150);
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
_t4_ws.addEventListener('close',function(){_t4_ws=null;_t4_startPoll();setTimeout(_t4_connect,2000);});
|
|
1125
|
+
_t4_ws.addEventListener('error',function(){try{_t4_ws&&_t4_ws.close();}catch(_){}});
|
|
1126
|
+
}
|
|
1127
|
+
_t4_connect();
|
|
1128
|
+
})();
|
|
1129
|
+
JS
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1028
1132
|
|
|
1029
1133
|
# Read and rewind the Rack input body. Returns the raw body string.
|
|
1030
1134
|
def _read_rack_body(env)
|
data/lib/tina4/router.rb
CHANGED
|
@@ -327,9 +327,31 @@ module Tina4
|
|
|
327
327
|
swagger_meta: swagger_meta,
|
|
328
328
|
middleware: middleware,
|
|
329
329
|
template: template)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
330
|
+
# Replace semantics: re-registering the same (method, path) overwrites
|
|
331
|
+
# the existing entry in place rather than appending a second one.
|
|
332
|
+
# This is what makes dev hot-reload work — when a changed route file is
|
|
333
|
+
# re-loaded, its Router.get("/x") call runs again with a fresh handler,
|
|
334
|
+
# and #find_route returns the FIRST match, so a stale leftover would
|
|
335
|
+
# otherwise shadow the new handler forever. Overwriting keeps the
|
|
336
|
+
# registry free of duplicates and ensures the latest handler wins.
|
|
337
|
+
# Distinct (method, path) pairs are untouched — only an exact dup
|
|
338
|
+
# collapses onto the prior slot, preserving its position/order.
|
|
339
|
+
bucket = method_index[route.method]
|
|
340
|
+
existing_index = routes.index { |r| r.method == route.method && r.path == route.path }
|
|
341
|
+
if existing_index
|
|
342
|
+
routes[existing_index] = route
|
|
343
|
+
bucket_index = bucket.index { |r| r.path == route.path }
|
|
344
|
+
if bucket_index
|
|
345
|
+
bucket[bucket_index] = route
|
|
346
|
+
else
|
|
347
|
+
bucket << route
|
|
348
|
+
end
|
|
349
|
+
Tina4::Log.debug("Route replaced: #{route.method} #{route.path}")
|
|
350
|
+
else
|
|
351
|
+
routes << route
|
|
352
|
+
bucket << route
|
|
353
|
+
Tina4::Log.debug("Route registered: #{route.method} #{route.path}")
|
|
354
|
+
end
|
|
333
355
|
route
|
|
334
356
|
end
|
|
335
357
|
# Convenience registration methods
|
|
@@ -485,10 +507,20 @@ module Tina4
|
|
|
485
507
|
|
|
486
508
|
# Load route files from a directory (file-based route discovery).
|
|
487
509
|
#
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
#
|
|
491
|
-
#
|
|
510
|
+
# mtime-tracked & re-runnable so re-discovery on /__dev/api/reload is
|
|
511
|
+
# cheap and picks up edits without a server restart:
|
|
512
|
+
#
|
|
513
|
+
# * NEW file (not seen before) → load it, record its mtime.
|
|
514
|
+
# * CHANGED file (mtime newer than seen) → load it again. Ruby's `load`
|
|
515
|
+
# RE-EXECUTES the file, so its Router.get(...) calls run afresh and
|
|
516
|
+
# #add replaces the (method, path) in place — the new handler wins
|
|
517
|
+
# instead of being shadowed by the stale one.
|
|
518
|
+
# * UNCHANGED file (present, same mtime) → skip (keeps reload cheap).
|
|
519
|
+
#
|
|
520
|
+
# Scope guard: the glob is rooted at the user's routes/`src` `directory`,
|
|
521
|
+
# so only application route files are ever (re)loaded — framework files
|
|
522
|
+
# are never touched. Records the directory so #rescan_routes! can re-run
|
|
523
|
+
# without re-passing it.
|
|
492
524
|
def load_routes(directory)
|
|
493
525
|
return unless Dir.exist?(directory)
|
|
494
526
|
|
|
@@ -498,10 +530,12 @@ module Tina4
|
|
|
498
530
|
files = Dir.glob(File.join(directory, "**/*.rb")).sort
|
|
499
531
|
total = files.length
|
|
500
532
|
files.each do |file|
|
|
501
|
-
|
|
533
|
+
current_mtime = File.mtime(file).to_i
|
|
534
|
+
# Skip only when we've seen this file AND it hasn't changed since.
|
|
535
|
+
next if @loaded_route_files.key?(file) && current_mtime <= @loaded_route_files[file]
|
|
502
536
|
begin
|
|
503
537
|
load file
|
|
504
|
-
@loaded_route_files[file] =
|
|
538
|
+
@loaded_route_files[file] = current_mtime
|
|
505
539
|
Tina4::Log.debug("Route loaded: #{file}")
|
|
506
540
|
rescue ScriptError, StandardError => e
|
|
507
541
|
# ScriptError catches SyntaxError, which is NOT a StandardError —
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/websocket.rb
CHANGED
|
@@ -344,4 +344,52 @@ module Tina4
|
|
|
344
344
|
Tina4.build_frame(opcode, data)
|
|
345
345
|
end
|
|
346
346
|
end
|
|
347
|
+
|
|
348
|
+
# Shared, process-wide manager for the dev-reload channel (/__dev_reload).
|
|
349
|
+
#
|
|
350
|
+
# Every browser that loads a page in debug mode opens a WebSocket here and
|
|
351
|
+
# keeps it open. POST /__dev/api/reload (issued by the tina4 Rust CLI on a
|
|
352
|
+
# file change) calls Tina4::DevReload.broadcast(...) to push an instant
|
|
353
|
+
# {type, file, mtime} message to every connected client — no respawn, no
|
|
354
|
+
# poll. Mirrors Python's single `_ws_manager` holding /__dev_reload
|
|
355
|
+
# connections by path; in Ruby each route upgrade otherwise spins its own
|
|
356
|
+
# isolated WebSocket engine, so a dedicated shared manager is what makes a
|
|
357
|
+
# broadcast reach all clients.
|
|
358
|
+
module DevReload
|
|
359
|
+
@manager = nil
|
|
360
|
+
@mutex = Mutex.new
|
|
361
|
+
|
|
362
|
+
class << self
|
|
363
|
+
# The shared WebSocket manager that holds every /__dev_reload connection.
|
|
364
|
+
def manager
|
|
365
|
+
@mutex.synchronize { @manager ||= Tina4::WebSocket.new }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Register an open connection so a later broadcast reaches it.
|
|
369
|
+
def add(connection)
|
|
370
|
+
manager.connections[connection.id] = connection
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Drop a connection on close.
|
|
374
|
+
def remove(connection)
|
|
375
|
+
manager.connections.delete(connection.id)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Number of live dev-reload clients (used by tests / introspection).
|
|
379
|
+
def count
|
|
380
|
+
manager.connections.size
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Push a text frame to every connected dev-reload client. Best-effort:
|
|
384
|
+
# a dead socket or zero clients must never raise into the caller (the
|
|
385
|
+
# /__dev/api/reload endpoint), so failures are swallowed per-connection.
|
|
386
|
+
def broadcast(message)
|
|
387
|
+
manager.connections.values.each do |conn|
|
|
388
|
+
conn.send_text(message)
|
|
389
|
+
rescue StandardError
|
|
390
|
+
next
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
347
395
|
end
|
data/lib/tina4.rb
CHANGED
|
@@ -107,6 +107,7 @@ module Tina4
|
|
|
107
107
|
autoload :GraphQL, File.expand_path("tina4/graphql", __dir__)
|
|
108
108
|
autoload :WebSocket, File.expand_path("tina4/websocket", __dir__)
|
|
109
109
|
autoload :WebSocketConnection, File.expand_path("tina4/websocket", __dir__)
|
|
110
|
+
autoload :DevReload, File.expand_path("tina4/websocket", __dir__)
|
|
110
111
|
autoload :Testing, File.expand_path("tina4/testing", __dir__)
|
|
111
112
|
autoload :ScssCompiler, File.expand_path("tina4/scss_compiler", __dir__)
|
|
112
113
|
autoload :FakeData, File.expand_path("tina4/seeder", __dir__)
|