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.
@@ -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 ? "" : "/* tina4:reload-js */"}
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
- routes << route
331
- method_index[route.method] << route
332
- Tina4::Log.debug("Route registered: #{method.upcase} #{path}")
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
- # Idempotent: files already loaded by a previous call are skipped, so
489
- # calling load_routes repeatedly (e.g. on /__dev/api/reload) only
490
- # picks up NEW files. Records the directory so #rescan_routes! can
491
- # re-run without re-passing it.
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
- next if @loaded_route_files[file]
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] = true
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.34"
4
+ VERSION = "3.13.36"
5
5
  end
@@ -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__)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.34
4
+ version: 3.13.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team