tina4-nodejs 3.13.35 → 3.13.37

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.
@@ -665,14 +665,37 @@ const handleReload: RouteHandler = async (req, res) => {
665
665
  const { rediscoverRoutes } = await import("./routeDiscovery.js");
666
666
  const newRoutes = await rediscoverRoutes();
667
667
  if (newRoutes.length > 0) {
668
- const { defaultRouter } = await import("./router.js");
669
- for (const route of newRoutes) defaultRouter.addRoute(route);
670
- console.log(` Re-discovered ${newRoutes.length} new route(s) on reload`);
668
+ // Add to the LIVE server router — startServer() builds a fresh Router and
669
+ // exposes it as globalThis.__tina4_router; that's the instance dispatch
670
+ // matches against. Adding to defaultRouter would land the re-imported
671
+ // handler in a table nobody serves from, so the stale route keeps winning
672
+ // (an edited route would never hot-reload). addRoute() replaces by pattern,
673
+ // so the fresh handler overwrites the old one in place. Fall back to
674
+ // defaultRouter when no server is running (e.g. unit tests).
675
+ const liveRouter = (globalThis as any).__tina4_router;
676
+ const target = liveRouter ?? (await import("./router.js")).defaultRouter;
677
+ for (const route of newRoutes) target.addRoute(route);
678
+ console.log(` Re-discovered ${newRoutes.length} route(s) on reload`);
671
679
  }
672
680
  } catch (err) {
673
681
  console.error(` Re-discover on reload failed:`, err);
674
682
  }
675
683
 
684
+ // WebSocket-primary reload: push an instant message to every browser
685
+ // connected on /__dev_reload. The toolbar client (and the dev-admin
686
+ // dashboard) act on this immediately — the mtime poll is only a fallback for
687
+ // when the socket is down. CSS changes swap stylesheets; everything else
688
+ // triggers a full page reload, so we normalise the wire `type` to
689
+ // "css"/"reload". The HTTP response still echoes the caller's original type.
690
+ // Wrapped so a broadcast failure (or zero clients) never 500s the endpoint.
691
+ const wsType = reloadType === "css" ? "css" : "reload";
692
+ try {
693
+ const { devReloadWs } = await import("./websocket.js");
694
+ devReloadWs.broadcast(JSON.stringify({ type: wsType, file: _reloadFile, mtime: _reloadMtime }));
695
+ } catch (err) {
696
+ console.error(` Dev-reload WebSocket broadcast failed:`, err);
697
+ }
698
+
676
699
  res.json({ ok: true, type: reloadType });
677
700
  };
678
701
 
@@ -1734,37 +1757,196 @@ const handleExecute: RouteHandler = async (req, res) => {
1734
1757
  }
1735
1758
  };
1736
1759
 
1737
- const handleFiles: RouteHandler = (req, res) => {
1760
+ // --- file-browser noise filter + git decoration (mirrors PHP/Python dev-admin) ---
1761
+ const DEV_FILES_IGNORED = new Set([
1762
+ "__pycache__", "node_modules", "vendor", ".git",
1763
+ "venv", ".venv", "dist", "target", ".tina4",
1764
+ ]);
1765
+
1766
+ // Hidden dot-entries are filtered too, except the env files.
1767
+ function devFilesHidden(name: string): boolean {
1768
+ if (DEV_FILES_IGNORED.has(name)) return true;
1769
+ return name.startsWith(".") && name !== ".env" && name !== ".env.example";
1770
+ }
1771
+
1772
+ // Same 4-status mapping Python/PHP use for a porcelain code.
1773
+ function devGitStatusLabel(code: string): string {
1774
+ if (code === "??") return "untracked";
1775
+ if (code.includes("M")) return "modified";
1776
+ if (code.includes("A")) return "added";
1777
+ if (code.includes("D")) return "deleted";
1778
+ return "clean";
1779
+ }
1780
+
1781
+ /**
1782
+ * Branch + porcelain status map for the file browser, mirroring PHP's
1783
+ * devAdminGit* helpers 1:1. Paths git reports are relative to the repo
1784
+ * root (always forward-slash); the project root may sit inside a larger
1785
+ * repo (monorepo), so the toplevel is returned too for rebasing. Degrades
1786
+ * to empty (every entry "clean") on any error / no git.
1787
+ */
1788
+ async function devGitInfo(root: string): Promise<{ branch: string; gitRoot: string | null; status: Map<string, string> }> {
1789
+ const empty = { branch: "", gitRoot: null as string | null, status: new Map<string, string>() };
1790
+ try {
1791
+ const { execFileSync } = await import("node:child_process");
1792
+ const git = (args: string[]): string | null => {
1793
+ try {
1794
+ return execFileSync("git", args, { cwd: root, timeout: 3000, encoding: "utf-8" }).toString();
1795
+ } catch {
1796
+ return null;
1797
+ }
1798
+ };
1799
+ const inside = git(["rev-parse", "--is-inside-work-tree"]);
1800
+ if (!inside || inside.trim() !== "true") return empty;
1801
+ const branch = (git(["rev-parse", "--abbrev-ref", "HEAD"]) ?? "").trim();
1802
+ const topRaw = git(["rev-parse", "--show-toplevel"]);
1803
+ const gitRoot = topRaw && topRaw.trim() !== ""
1804
+ ? topRaw.trim().replace(/\\/g, "/").replace(/\/+$/, "")
1805
+ : null;
1806
+ const status = new Map<string, string>();
1807
+ const porcelain = git(["status", "--porcelain", "-uall"]);
1808
+ if (porcelain) {
1809
+ for (const line of porcelain.split(/\r?\n/)) {
1810
+ if (line.length < 4) continue;
1811
+ const code = line.slice(0, 2).trim();
1812
+ let p = line.slice(3).trim();
1813
+ const arrow = p.indexOf(" -> "); // rename/copy — keep destination
1814
+ if (arrow !== -1) p = p.slice(arrow + 4);
1815
+ if (p === "") continue;
1816
+ status.set(p, code);
1817
+ }
1818
+ }
1819
+ return { branch, gitRoot, status };
1820
+ } catch {
1821
+ return empty;
1822
+ }
1823
+ }
1824
+
1825
+ const handleFiles: RouteHandler = async (req, res) => {
1826
+ // Response shape matches tina4-python / tina4-php 1:1 so the dev-admin SPA
1827
+ // works against every framework with no branching: each entry carries
1828
+ // `is_dir`, `has_children`, `git_status` and `size`; the payload carries the
1829
+ // git `branch`. Noise dirs + hidden dot-files (except .env/.env.example) are
1830
+ // filtered out.
1738
1831
  const url = new URL(req.url ?? "/", "http://localhost");
1739
1832
  const rel = url.searchParams.get("path") ?? ".";
1740
1833
  const root = resolve(process.cwd());
1741
1834
  const target = safeJoin(root, rel);
1742
- if (!target || !existsSync(target)) {
1743
- res.json({ error: `Path not found: ${rel}` }, 404);
1835
+ const { branch, gitRoot, status: gitStatus } = await devGitInfo(root);
1836
+
1837
+ // Missing/invalid paths return an empty-but-valid shape (not 404): the SPA
1838
+ // restores expanded-folder state from localStorage, and folders that don't
1839
+ // exist in this harness would otherwise spam the console with red 404s.
1840
+ if (!target || !existsSync(target) || !statSync(target).isDirectory()) {
1841
+ res.json({ path: rel, branch, entries: [], error: "not a directory" });
1744
1842
  return;
1745
1843
  }
1746
- const stat = statSync(target);
1747
- if (!stat.isDirectory()) {
1748
- res.json({ error: `Not a directory: ${rel}` }, 400);
1749
- return;
1844
+
1845
+ // Rebase entry paths onto the git repo root when the project sits inside a
1846
+ // larger repo. Everything runs in forward-slash form.
1847
+ const rootFwd = root.replace(/\\/g, "/");
1848
+ let cwdInGit = "";
1849
+ if (gitRoot && gitRoot !== rootFwd && rootFwd.startsWith(gitRoot)) {
1850
+ cwdInGit = rootFwd.slice(gitRoot.length).replace(/^\/+/, "");
1851
+ if (cwdInGit !== "") cwdInGit += "/";
1750
1852
  }
1751
- const entries = readdirSync(target, { withFileTypes: true })
1752
- .filter((e) => !e.name.startsWith(".") || e.name === ".env")
1753
- .map((e) => {
1754
- const full = join(target, e.name);
1755
- let size = 0;
1756
- try { size = e.isFile() ? statSync(full).size : 0; } catch { /* ignore */ }
1757
- return {
1758
- name: e.name,
1759
- type: e.isDirectory() ? "dir" : "file",
1760
- size,
1761
- path: relative(root, full),
1762
- };
1763
- })
1764
- .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
1765
- res.json({ path: relative(root, target) || ".", entries });
1853
+
1854
+ const entries: Array<Record<string, unknown>> = [];
1855
+ for (const name of readdirSync(target).sort()) { // alphabetical; no re-sort by type
1856
+ if (devFilesHidden(name)) continue;
1857
+ const full = join(target, name);
1858
+ const entryRel = relative(root, full).replace(/\\/g, "/");
1859
+
1860
+ let isDir = false;
1861
+ let size: number | null = null;
1862
+ try {
1863
+ const st = statSync(full);
1864
+ isDir = st.isDirectory();
1865
+ if (!isDir) size = st.size;
1866
+ } catch { /* unreadable entry */ }
1867
+
1868
+ // git status for this entry (same mapping PHP/Python use)
1869
+ const gitPath = cwdInGit + entryRel;
1870
+ let gitLabel = "clean";
1871
+ const code = gitStatus.get(gitPath);
1872
+ if (code !== undefined) {
1873
+ gitLabel = devGitStatusLabel(code);
1874
+ } else if (isDir) {
1875
+ const prefix = gitPath + "/"; // propagate dirty status from any child
1876
+ for (const [gf, gc] of gitStatus) {
1877
+ if (gf.startsWith(prefix)) { gitLabel = gc === "??" ? "untracked" : "modified"; break; }
1878
+ }
1879
+ }
1880
+
1881
+ // has_children: does the dir contain anything visible?
1882
+ let hasChildren: boolean | null = null;
1883
+ if (isDir) {
1884
+ hasChildren = false;
1885
+ try {
1886
+ for (const c of readdirSync(full)) {
1887
+ if (devFilesHidden(c)) continue;
1888
+ hasChildren = true;
1889
+ break;
1890
+ }
1891
+ } catch { /* ignore */ }
1892
+ }
1893
+
1894
+ entries.push({
1895
+ name,
1896
+ path: entryRel,
1897
+ is_dir: isDir,
1898
+ has_children: hasChildren,
1899
+ git_status: gitLabel,
1900
+ size,
1901
+ });
1902
+ }
1903
+
1904
+ res.json({ path: relative(root, target).replace(/\\/g, "/") || ".", branch, entries });
1905
+ };
1906
+
1907
+ // Canonical extension→language map. Kept identical in coverage to the Python
1908
+ // master (tina4_python/tina4_python/dev_admin/__init__.py `lang_map`) and the
1909
+ // PHP/Ruby file-read endpoints. The dev-admin SPA maps the returned "language"
1910
+ // string to a CodeMirror grammar for syntax highlighting.
1911
+ const DEV_ADMIN_LANG_MAP: Record<string, string> = {
1912
+ ".py": "python", ".php": "php", ".rb": "ruby",
1913
+ ".ts": "typescript", ".js": "javascript", ".jsx": "javascript",
1914
+ ".tsx": "typescript", ".json": "json", ".html": "html",
1915
+ ".twig": "html", ".css": "css", ".scss": "css",
1916
+ ".md": "markdown", ".sql": "sql", ".yaml": "yaml",
1917
+ ".yml": "yaml", ".toml": "toml", ".xml": "html",
1918
+ ".env": "env", ".env.example": "env",
1919
+ ".sh": "shell", ".bash": "shell",
1920
+ ".bat": "shell", ".cmd": "shell", ".ps1": "shell",
1921
+ ".rs": "rust", ".go": "go", ".java": "java",
1922
+ ".txt": "text", ".csv": "text", ".log": "text",
1923
+ ".gemspec": "ruby", ".rake": "ruby",
1924
+ ".svg": "svg",
1766
1925
  };
1767
1926
 
1927
+ /**
1928
+ * Resolve a CodeMirror-friendly language id from a file path's basename.
1929
+ *
1930
+ * - `Dockerfile` / `Dockerfile.dev` / `Dockerfile.prod` (no extension) → "dockerfile"
1931
+ * - `.env.example` (two-part) and `.env` → "env"
1932
+ * - otherwise the file extension is looked up in DEV_ADMIN_LANG_MAP
1933
+ * - anything unknown → "text"
1934
+ */
1935
+ export function devAdminLanguage(rel: string): string {
1936
+ const base = (rel.split(/[\\/]/).pop() ?? "").toLowerCase();
1937
+ if (base === "dockerfile" || base === "dockerfile.dev" || base === "dockerfile.prod") {
1938
+ return "dockerfile";
1939
+ }
1940
+ // Two-part extension first (e.g. ".env.example"), then the single extension.
1941
+ if (base.endsWith(".env.example")) return DEV_ADMIN_LANG_MAP[".env.example"];
1942
+ const dot = base.lastIndexOf(".");
1943
+ // A leading dot with no other dot is a dotfile name, not an extension
1944
+ // (e.g. ".env" → ext ".env"); only treat as "no extension" when there's no dot.
1945
+ if (dot < 0) return "text";
1946
+ const ext = base.slice(dot);
1947
+ return DEV_ADMIN_LANG_MAP[ext] ?? "text";
1948
+ }
1949
+
1768
1950
  const handleFileRead: RouteHandler = (req, res) => {
1769
1951
  const url = new URL(req.url ?? "/", "http://localhost");
1770
1952
  const rel = url.searchParams.get("path") ?? "";
@@ -1776,7 +1958,8 @@ const handleFileRead: RouteHandler = (req, res) => {
1776
1958
  }
1777
1959
  try {
1778
1960
  const content = readFileSync(target, "utf-8");
1779
- res.json({ path: relative(root, target), content, bytes: Buffer.byteLength(content, "utf-8") });
1961
+ const path = relative(root, target);
1962
+ res.json({ path, content, language: devAdminLanguage(path), bytes: Buffer.byteLength(content, "utf-8") });
1780
1963
  } catch (e) {
1781
1964
  res.json({ error: (e as Error).message }, 500);
1782
1965
  }
@@ -2397,5 +2580,68 @@ function tina4VersionModal(){
2397
2580
  el.style.color='#f38ba8';
2398
2581
  });
2399
2582
  }
2583
+ </script>
2584
+ <script>
2585
+ (function(){
2586
+ // WebSocket-primary dev reloader. The running server re-imports changed
2587
+ // src/ routes in-process and pushes a {type,file,mtime} message over
2588
+ // /__dev_reload — no respawn, instant refresh. The mtime poll below is a
2589
+ // FALLBACK only, started when the socket is down and stopped on connect.
2590
+ var _t4_css_exts=['.css','.scss'],_t4_debounce=null;
2591
+ var _t4_interval=3000;
2592
+ var _t4_ws=null,_t4_poll_timer=null,_t4_mtime=null;
2593
+ function _t4_apply(d){
2594
+ d=d||{};
2595
+ var f=d.file||'',t=d.type||'';
2596
+ var isCss=t==='css'||_t4_css_exts.some(function(e){return f.endsWith(e)});
2597
+ if(isCss){
2598
+ var links=document.querySelectorAll('link[rel="stylesheet"]');
2599
+ links.forEach(function(l){
2600
+ var href=l.getAttribute('href');
2601
+ if(href){l.setAttribute('href',href.split('?')[0]+'?_t4='+(d.mtime||Date.now()))}
2602
+ });
2603
+ }else{
2604
+ location.reload();
2605
+ }
2606
+ }
2607
+ function _t4_poll(){
2608
+ fetch('/__dev/api/mtime').then(function(r){return r.json()}).then(function(d){
2609
+ // Sentinel: first poll only records the baseline. Use !== (not >) so
2610
+ // the first change after load is not swallowed and a counter reset on
2611
+ // server restart still triggers a reload.
2612
+ if(_t4_mtime===null){_t4_mtime=d.mtime;return;}
2613
+ if(d.mtime!==_t4_mtime){
2614
+ _t4_mtime=d.mtime;
2615
+ if(_t4_debounce)clearTimeout(_t4_debounce);
2616
+ _t4_debounce=setTimeout(function(){_t4_apply(d);},500);
2617
+ }
2618
+ }).catch(function(){});
2619
+ }
2620
+ function _t4_startPoll(){
2621
+ if(_t4_poll_timer)return;
2622
+ _t4_mtime=null;
2623
+ _t4_poll_timer=setInterval(_t4_poll,_t4_interval);
2624
+ }
2625
+ function _t4_stopPoll(){
2626
+ if(_t4_poll_timer){clearInterval(_t4_poll_timer);_t4_poll_timer=null;}
2627
+ }
2628
+ function _t4_connect(){
2629
+ var url=(location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/__dev_reload';
2630
+ try{_t4_ws=new WebSocket(url);}catch(_){_t4_startPoll();return;}
2631
+ _t4_ws.addEventListener('open',function(){_t4_stopPoll();});
2632
+ _t4_ws.addEventListener('message',function(ev){
2633
+ var d=null;
2634
+ try{d=typeof ev.data==='string'?JSON.parse(ev.data):null;}catch(_){}
2635
+ if(!d)return;
2636
+ if(d.type==='reload'||d.type==='change'||d.type==='css'){
2637
+ if(_t4_debounce)clearTimeout(_t4_debounce);
2638
+ _t4_debounce=setTimeout(function(){_t4_apply(d);},150);
2639
+ }
2640
+ });
2641
+ _t4_ws.addEventListener('close',function(){_t4_ws=null;_t4_startPoll();setTimeout(_t4_connect,2000);});
2642
+ _t4_ws.addEventListener('error',function(){try{_t4_ws&&_t4_ws.close();}catch(_){}});
2643
+ }
2644
+ _t4_connect();
2645
+ })();
2400
2646
  </script>`;
2401
2647
  }
@@ -61,6 +61,7 @@ export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled } from "
61
61
  export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
62
62
  export {
63
63
  WebSocketServer,
64
+ devReloadWs,
64
65
  computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame,
65
66
  OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
66
67
  CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR,
@@ -73,7 +74,7 @@ export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
73
74
  export { Api } from "./api.js";
74
75
  export type { ApiResult } from "./api.js";
75
76
  export { Events } from "./events.js";
76
- export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl } from "./devAdmin.js";
77
+ export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage } from "./devAdmin.js";
77
78
  export {
78
79
  feedbackEnabled,
79
80
  feedbackWhitelist,
@@ -11,6 +11,15 @@ const VALID_METHODS = new Set(["get", "post", "put", "delete", "patch"]);
11
11
  */
12
12
  const _seenFiles = new Set<string>();
13
13
 
14
+ /**
15
+ * Last-seen mtime (ms) per route file. A file is (re)imported when it is new
16
+ * OR its mtime has increased since the previous scan — so editing an existing
17
+ * route file hot-reloads its handler instead of serving the stale one. The
18
+ * mtime also drives the import cache-bust query, so unchanged files don't
19
+ * needlessly re-execute.
20
+ */
21
+ const _seenMtimes = new Map<string, number>();
22
+
14
23
  /** The last directory passed to discoverRoutes() — used by rediscoverRoutes(). */
15
24
  let _lastRoutesDir = "";
16
25
 
@@ -31,15 +40,20 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
31
40
 
32
41
  routeFileCount++;
33
42
 
34
- if (_seenFiles.has(filePath)) continue;
43
+ // Skip ONLY if we've already imported this exact file at its current mtime.
44
+ // A new file (not in _seenFiles) or an edited one (mtime increased) falls
45
+ // through and gets re-imported, so a hot-reload picks up the new handler.
46
+ const currentMtime = statSync(filePath).mtimeMs;
47
+ if (_seenFiles.has(filePath) && _seenMtimes.get(filePath) === currentMtime) continue;
35
48
 
36
49
  const method = name.toUpperCase();
37
50
  const relativePath = relative(routesDir, filePath);
38
51
  const pattern = filePathToPattern(relativePath);
39
52
 
40
53
  try {
41
- // Cache-bust for hot-reload
42
- const moduleUrl = `file://${filePath}?t=${Date.now()}`;
54
+ // Cache-bust for hot-reload, keyed on mtime: identical content reuses the
55
+ // same module URL (no needless re-import), an edit produces a fresh URL.
56
+ const moduleUrl = `file://${filePath}?t=${currentMtime}`;
43
57
  const mod = await import(moduleUrl);
44
58
 
45
59
  const handler: RouteHandler = mod.default ?? mod.handler;
@@ -53,6 +67,7 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
53
67
 
54
68
  definitions.push({ method, pattern, handler, filePath, meta, template });
55
69
  _seenFiles.add(filePath);
70
+ _seenMtimes.set(filePath, currentMtime);
56
71
  registeredFromThisScan++;
57
72
  } catch (err) {
58
73
  console.error(` Error loading route ${relativePath}:`, err);
@@ -76,8 +91,10 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
76
91
 
77
92
  /**
78
93
  * Re-run the most recent route scan — called by POST /__dev/api/reload so a
79
- * newly-added file in src/routes/ registers without a server restart. Already
80
- * loaded files are skipped. No-op if discoverRoutes() has never been called.
94
+ * newly-added OR edited file in src/routes/ registers without a server restart.
95
+ * A file is re-imported when it's new or its mtime increased; unchanged files
96
+ * are skipped. The router replaces routes by pattern, so a re-imported route
97
+ * overwrites the stale handler. No-op if discoverRoutes() has never been called.
81
98
  */
82
99
  export async function rediscoverRoutes(): Promise<RouteDefinition[]> {
83
100
  if (!_lastRoutesDir) return [];
@@ -87,6 +104,7 @@ export async function rediscoverRoutes(): Promise<RouteDefinition[]> {
87
104
  /** Test-only: reset the seen-files state so tests can replay the same dir. */
88
105
  export function _resetRouteDiscovery(): void {
89
106
  _seenFiles.clear();
107
+ _seenMtimes.clear();
90
108
  _lastRoutesDir = "";
91
109
  }
92
110
 
@@ -18,7 +18,8 @@ import { loadEnv, isTruthy } from "./dotenv.js";
18
18
  import { createHealthRoutes } from "./health.js";
19
19
  import { rateLimiter } from "./rateLimiter.js";
20
20
  import { Log } from "./logger.js";
21
- import { DevAdmin, RequestInspector } from "./devAdmin.js";
21
+ import { DevAdmin, RequestInspector, WsTracker } from "./devAdmin.js";
22
+ import { devReloadWs } from "./websocket.js";
22
23
  import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
23
24
  import { I18n } from "./i18n.js";
24
25
  import { stopAllBackgroundTasks } from "./background.js";
@@ -1392,6 +1393,32 @@ ${reset}
1392
1393
  // posts /__dev/api/reload to the MAIN port. Matches Python (master).
1393
1394
  const server = createServer(dispatch);
1394
1395
 
1396
+ // WebSocket-primary DevReload: accept and hold /__dev_reload upgrades on the
1397
+ // MAIN port (debug only) so POST /__dev/api/reload can push an instant reload.
1398
+ // Mirrors Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
1399
+ // Without this the handshake 404s and the whole stack silently falls back to
1400
+ // polling. Track connections in WsTracker so they appear in the dev-admin list.
1401
+ if (isDevMode()) {
1402
+ devReloadWs.setTracker(
1403
+ (remoteAddress, p) => WsTracker.add(remoteAddress, p),
1404
+ (id) => { WsTracker.remove(id); },
1405
+ );
1406
+ server.on("upgrade", (req: IncomingMessage, socket, head) => {
1407
+ const upPath = (req.url ?? "/").split("?")[0];
1408
+ if (upPath === "/__dev_reload") {
1409
+ devReloadWs.handleUpgrade(req, socket, head);
1410
+ return;
1411
+ }
1412
+ // Not a dev-reload upgrade — refuse cleanly rather than leaving it hanging.
1413
+ try {
1414
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1415
+ socket.destroy();
1416
+ } catch {
1417
+ /* socket already gone */
1418
+ }
1419
+ });
1420
+ }
1421
+
1395
1422
  return new Promise((resolvePromise) => {
1396
1423
  server.listen(port, host, () => {
1397
1424
  const displayHost = host === "0.0.0.0" ? "localhost" : host;
@@ -1420,6 +1447,17 @@ ${reset}
1420
1447
  await dispatch(req, res);
1421
1448
  });
1422
1449
 
1450
+ // Stable AI port never accepts /__dev_reload (or any) WS upgrade — an AI
1451
+ // tool driving it must never get a reload channel that its own edits trip.
1452
+ aiServer.on("upgrade", (_req: IncomingMessage, socket) => {
1453
+ try {
1454
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1455
+ socket.destroy();
1456
+ } catch {
1457
+ /* socket already gone */
1458
+ }
1459
+ });
1460
+
1423
1461
  aiServer.on("error", (err: any) => {
1424
1462
  if (err.code === "EADDRINUSE") {
1425
1463
  Log.warn(`Test port ${testPort} in use — skipping`);
@@ -562,3 +562,142 @@ export class WebSocketServer {
562
562
  this.clientRooms.delete(clientId);
563
563
  }
564
564
  }
565
+
566
+ // ── Dev-reload WebSocket manager ─────────────────────────────
567
+
568
+ /** A single accepted /__dev_reload socket plus its dashboard tracker id. */
569
+ interface DevReloadClient {
570
+ socket: Socket;
571
+ /** WsTracker id, so the connection shows in the dev-admin /__dev/api/websockets list. */
572
+ trackerId?: string;
573
+ }
574
+
575
+ /**
576
+ * Connection manager for the dev-reload channel (`/__dev_reload`).
577
+ *
578
+ * Mirrors Python's `_ws_manager` scoped to `/__dev_reload`: it accepts the
579
+ * RFC 6455 handshake on the *main* dev server's HTTP `upgrade` event, holds the
580
+ * raw sockets open, and lets `POST /__dev/api/reload` push an instant reload to
581
+ * every connected browser via {@link broadcast}. The framework never reads from
582
+ * the client — the open socket is the whole point. This restores the documented
583
+ * WebSocket-primary DevReload design (the dev toolbar and dev-admin dashboard
584
+ * both connect here). Registered only when `TINA4_DEBUG` is on, and never on the
585
+ * stable AI port.
586
+ */
587
+ class DevReloadWsManager {
588
+ private clients: Set<DevReloadClient> = new Set();
589
+ /** Optional hooks (add/remove) so the dev-admin connection list stays in sync. */
590
+ private onAdd?: (remoteAddress: string, path: string) => string;
591
+ private onRemove?: (id: string) => void;
592
+
593
+ /** Wire dev-admin tracking callbacks (WsTracker.add / WsTracker.remove). */
594
+ setTracker(onAdd: (remoteAddress: string, path: string) => string, onRemove: (id: string) => void): void {
595
+ this.onAdd = onAdd;
596
+ this.onRemove = onRemove;
597
+ }
598
+
599
+ /** Number of currently-open dev-reload sockets (test/diagnostic helper). */
600
+ get size(): number {
601
+ return this.clients.size;
602
+ }
603
+
604
+ /**
605
+ * Accept a WebSocket upgrade on `/__dev_reload` and hold the socket open.
606
+ *
607
+ * Completes the RFC 6455 handshake, registers the connection, and drains
608
+ * inbound frames — responding to pings and cleaning up on close — without
609
+ * ever interpreting client data. Returns true if the handshake was accepted.
610
+ */
611
+ handleUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): boolean {
612
+ const wsKey = req.headers["sec-websocket-key"];
613
+ if (!wsKey || (typeof wsKey === "string" && wsKey.length === 0)) {
614
+ try {
615
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
616
+ socket.destroy();
617
+ } catch {
618
+ /* socket already gone */
619
+ }
620
+ return false;
621
+ }
622
+
623
+ const acceptKey = computeAcceptKey(Array.isArray(wsKey) ? wsKey[0] : wsKey);
624
+ const response = [
625
+ "HTTP/1.1 101 Switching Protocols",
626
+ "Upgrade: websocket",
627
+ "Connection: Upgrade",
628
+ `Sec-WebSocket-Accept: ${acceptKey}`,
629
+ "",
630
+ "",
631
+ ].join("\r\n");
632
+ try {
633
+ socket.write(response);
634
+ } catch {
635
+ return false;
636
+ }
637
+
638
+ const client: DevReloadClient = { socket };
639
+ if (this.onAdd) {
640
+ client.trackerId = this.onAdd(socket.remoteAddress ?? "unknown", "/__dev_reload");
641
+ }
642
+ this.clients.add(client);
643
+
644
+ const cleanup = () => {
645
+ if (!this.clients.has(client)) return;
646
+ this.clients.delete(client);
647
+ if (client.trackerId && this.onRemove) this.onRemove(client.trackerId);
648
+ };
649
+
650
+ // We don't act on client data, but we must still drain frames so the OS
651
+ // buffer doesn't stall, answer pings, and notice a client-side close.
652
+ let buffer = head && head.length > 0 ? Buffer.from(head) : Buffer.alloc(0);
653
+ socket.on("data", (chunk: Buffer) => {
654
+ buffer = Buffer.concat([buffer, chunk]);
655
+ while (buffer.length > 0) {
656
+ const frame = parseFrame(buffer);
657
+ if (!frame) break;
658
+ buffer = buffer.subarray(frame.bytesConsumed);
659
+ if (frame.opcode === OP_PING) {
660
+ try {
661
+ socket.write(buildFrame(OP_PONG, frame.payload));
662
+ } catch {
663
+ /* client disconnected */
664
+ }
665
+ } else if (frame.opcode === OP_CLOSE) {
666
+ try {
667
+ socket.write(buildFrame(OP_CLOSE, Buffer.from([0x03, 0xe8])));
668
+ socket.end();
669
+ } catch {
670
+ /* already closed */
671
+ }
672
+ cleanup();
673
+ return;
674
+ }
675
+ }
676
+ });
677
+ socket.on("close", cleanup);
678
+ socket.on("error", cleanup);
679
+ return true;
680
+ }
681
+
682
+ /**
683
+ * Broadcast a text frame to every connected dev-reload client.
684
+ *
685
+ * Best-effort: a dead socket is dropped silently. Never throws — the caller
686
+ * (`POST /__dev/api/reload`) must not 500 because a browser tab went away.
687
+ */
688
+ broadcast(message: string): void {
689
+ if (this.clients.size === 0) return;
690
+ const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
691
+ for (const client of Array.from(this.clients)) {
692
+ try {
693
+ client.socket.write(frame);
694
+ } catch {
695
+ this.clients.delete(client);
696
+ if (client.trackerId && this.onRemove) this.onRemove(client.trackerId);
697
+ }
698
+ }
699
+ }
700
+ }
701
+
702
+ /** Process-wide dev-reload manager (one channel: `/__dev_reload`). */
703
+ export const devReloadWs = new DevReloadWsManager();