tina4-nodejs 3.13.35 → 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.
- package/CLAUDE.md +10 -7
- package/package.json +1 -1
- package/packages/core/public/js/tina4-dev-admin.js +437 -759
- package/packages/core/public/js/tina4-dev-admin.min.js +437 -759
- package/packages/core/src/devAdmin.ts +227 -25
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/routeDiscovery.ts +23 -5
- package/packages/core/src/server.ts +39 -1
- package/packages/core/src/websocket.ts +139 -0
|
@@ -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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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,35 +1757,151 @@ const handleExecute: RouteHandler = async (req, res) => {
|
|
|
1734
1757
|
}
|
|
1735
1758
|
};
|
|
1736
1759
|
|
|
1737
|
-
|
|
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
|
-
|
|
1743
|
-
|
|
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
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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 });
|
|
1766
1905
|
};
|
|
1767
1906
|
|
|
1768
1907
|
const handleFileRead: RouteHandler = (req, res) => {
|
|
@@ -2397,5 +2536,68 @@ function tina4VersionModal(){
|
|
|
2397
2536
|
el.style.color='#f38ba8';
|
|
2398
2537
|
});
|
|
2399
2538
|
}
|
|
2539
|
+
</script>
|
|
2540
|
+
<script>
|
|
2541
|
+
(function(){
|
|
2542
|
+
// WebSocket-primary dev reloader. The running server re-imports changed
|
|
2543
|
+
// src/ routes in-process and pushes a {type,file,mtime} message over
|
|
2544
|
+
// /__dev_reload — no respawn, instant refresh. The mtime poll below is a
|
|
2545
|
+
// FALLBACK only, started when the socket is down and stopped on connect.
|
|
2546
|
+
var _t4_css_exts=['.css','.scss'],_t4_debounce=null;
|
|
2547
|
+
var _t4_interval=3000;
|
|
2548
|
+
var _t4_ws=null,_t4_poll_timer=null,_t4_mtime=null;
|
|
2549
|
+
function _t4_apply(d){
|
|
2550
|
+
d=d||{};
|
|
2551
|
+
var f=d.file||'',t=d.type||'';
|
|
2552
|
+
var isCss=t==='css'||_t4_css_exts.some(function(e){return f.endsWith(e)});
|
|
2553
|
+
if(isCss){
|
|
2554
|
+
var links=document.querySelectorAll('link[rel="stylesheet"]');
|
|
2555
|
+
links.forEach(function(l){
|
|
2556
|
+
var href=l.getAttribute('href');
|
|
2557
|
+
if(href){l.setAttribute('href',href.split('?')[0]+'?_t4='+(d.mtime||Date.now()))}
|
|
2558
|
+
});
|
|
2559
|
+
}else{
|
|
2560
|
+
location.reload();
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
function _t4_poll(){
|
|
2564
|
+
fetch('/__dev/api/mtime').then(function(r){return r.json()}).then(function(d){
|
|
2565
|
+
// Sentinel: first poll only records the baseline. Use !== (not >) so
|
|
2566
|
+
// the first change after load is not swallowed and a counter reset on
|
|
2567
|
+
// server restart still triggers a reload.
|
|
2568
|
+
if(_t4_mtime===null){_t4_mtime=d.mtime;return;}
|
|
2569
|
+
if(d.mtime!==_t4_mtime){
|
|
2570
|
+
_t4_mtime=d.mtime;
|
|
2571
|
+
if(_t4_debounce)clearTimeout(_t4_debounce);
|
|
2572
|
+
_t4_debounce=setTimeout(function(){_t4_apply(d);},500);
|
|
2573
|
+
}
|
|
2574
|
+
}).catch(function(){});
|
|
2575
|
+
}
|
|
2576
|
+
function _t4_startPoll(){
|
|
2577
|
+
if(_t4_poll_timer)return;
|
|
2578
|
+
_t4_mtime=null;
|
|
2579
|
+
_t4_poll_timer=setInterval(_t4_poll,_t4_interval);
|
|
2580
|
+
}
|
|
2581
|
+
function _t4_stopPoll(){
|
|
2582
|
+
if(_t4_poll_timer){clearInterval(_t4_poll_timer);_t4_poll_timer=null;}
|
|
2583
|
+
}
|
|
2584
|
+
function _t4_connect(){
|
|
2585
|
+
var url=(location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/__dev_reload';
|
|
2586
|
+
try{_t4_ws=new WebSocket(url);}catch(_){_t4_startPoll();return;}
|
|
2587
|
+
_t4_ws.addEventListener('open',function(){_t4_stopPoll();});
|
|
2588
|
+
_t4_ws.addEventListener('message',function(ev){
|
|
2589
|
+
var d=null;
|
|
2590
|
+
try{d=typeof ev.data==='string'?JSON.parse(ev.data):null;}catch(_){}
|
|
2591
|
+
if(!d)return;
|
|
2592
|
+
if(d.type==='reload'||d.type==='change'||d.type==='css'){
|
|
2593
|
+
if(_t4_debounce)clearTimeout(_t4_debounce);
|
|
2594
|
+
_t4_debounce=setTimeout(function(){_t4_apply(d);},150);
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
_t4_ws.addEventListener('close',function(){_t4_ws=null;_t4_startPoll();setTimeout(_t4_connect,2000);});
|
|
2598
|
+
_t4_ws.addEventListener('error',function(){try{_t4_ws&&_t4_ws.close();}catch(_){}});
|
|
2599
|
+
}
|
|
2600
|
+
_t4_connect();
|
|
2601
|
+
})();
|
|
2400
2602
|
</script>`;
|
|
2401
2603
|
}
|
|
@@ -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,
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
80
|
-
*
|
|
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();
|