tina4-nodejs 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.
@@ -18,6 +18,32 @@ import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import * as os from "node:os";
20
20
  import { spawnSync } from "node:child_process";
21
+ import { createRequire } from "node:module";
22
+
23
+ // Synchronous CommonJS-style require that works under real ESM (where the
24
+ // bare `require` global is undefined). Dev-tool handlers are synchronous, so
25
+ // they can't `await import()` — this gives them a working require. Mirrors the
26
+ // pattern already used by the ORM adapters (mysql.ts, postgres.ts, etc.).
27
+ const req = createRequire(import.meta.url);
28
+
29
+ /**
30
+ * Require a sibling @tina4 workspace package (orm / swagger / frond) in a way
31
+ * that works whether we're running from source (monorepo, where the package's
32
+ * `exports` only exposes the TS `import` condition that `require()` can't see)
33
+ * or as an installed dependency (where the package name resolves directly).
34
+ *
35
+ * Tries the package name first; on failure falls back to the in-repo source
36
+ * path relative to this file (packages/core/src → packages/<name>/src). This
37
+ * is why `route_list`, `database_query`, `swagger_spec`, etc. work when a real
38
+ * MCP client hits /__dev/mcp from a from-source dev server.
39
+ */
40
+ function reqSibling(pkg: "orm" | "swagger" | "frond"): Record<string, unknown> {
41
+ try {
42
+ return req(`@tina4/${pkg}`) as Record<string, unknown>;
43
+ } catch {
44
+ return req(`../../${pkg}/src/index.ts`) as Record<string, unknown>;
45
+ }
46
+ }
21
47
 
22
48
  // ── Types ─────────────────────────────────────────────────────
23
49
 
@@ -34,7 +60,11 @@ export interface McpToolDefinition {
34
60
  name: string;
35
61
  description: string;
36
62
  inputSchema: JsonSchema;
37
- handler: (args: Record<string, unknown>) => unknown;
63
+ // Handlers may be sync or async — the dispatch (`_handleToolsCall`) awaits the
64
+ // return value, so DB tools that hit the async Database wrapper resolve before
65
+ // the result is formatted. A sync handler's plain return passes through awaiting
66
+ // unchanged.
67
+ handler: (args: Record<string, unknown>) => unknown | Promise<unknown>;
38
68
  }
39
69
 
40
70
  export interface McpResourceDefinition {
@@ -251,7 +281,13 @@ export class McpServer {
251
281
  });
252
282
  }
253
283
 
254
- handleMessage(rawData: string | Record<string, unknown>): string {
284
+ // Async since the DB dev-tool handlers (database_query/execute/tables/columns,
285
+ // migration_*, seed_table, project_overview) reach the Database wrapper on
286
+ // `globalThis.__tina4_db`, whose read/write methods are async. The handler is
287
+ // awaited below; sync handlers (the file/plan/route tools) are unaffected
288
+ // because awaiting a non-Promise resolves immediately. Returns a
289
+ // Promise<string> — every caller must await it.
290
+ async handleMessage(rawData: string | Record<string, unknown>): Promise<string> {
255
291
  let method: string;
256
292
  let params: Record<string, unknown>;
257
293
  let requestId: number | string | null;
@@ -278,7 +314,7 @@ export class McpServer {
278
314
  }
279
315
 
280
316
  try {
281
- const result = handler(params);
317
+ const result = await handler(params);
282
318
  if (requestId === null) {
283
319
  return ""; // Notification — no response
284
320
  }
@@ -323,7 +359,7 @@ export class McpServer {
323
359
  return { tools };
324
360
  }
325
361
 
326
- private _handleToolsCall(params: Record<string, unknown>): Record<string, unknown> {
362
+ private async _handleToolsCall(params: Record<string, unknown>): Promise<Record<string, unknown>> {
327
363
  const toolName = params.name as string | undefined;
328
364
  if (!toolName) {
329
365
  throw new Error("Missing tool name");
@@ -335,7 +371,9 @@ export class McpServer {
335
371
  }
336
372
 
337
373
  const args = (params.arguments as Record<string, unknown>) || {};
338
- const result = tool.handler(args);
374
+ // Tool handlers may be async (the DB tools await the Database wrapper); a
375
+ // sync handler's plain return value passes through `await` unchanged.
376
+ const result = await tool.handler(args);
339
377
 
340
378
  // Format result as MCP content
341
379
  let content: { type: string; text: string }[];
@@ -412,7 +450,7 @@ export class McpServer {
412
450
  const ssePath = `${this.path}/sse`;
413
451
 
414
452
  router
415
- .post(msgPath, (req: unknown, res: unknown) => {
453
+ .post(msgPath, async (req: unknown, res: unknown) => {
416
454
  const request = req as { body: unknown; url?: string };
417
455
  const response = res as ((data: unknown, status?: number, contentType?: string) => unknown);
418
456
  const body = request.body;
@@ -422,7 +460,7 @@ export class McpServer {
422
460
  } else {
423
461
  raw = typeof body === "string" ? body : String(body);
424
462
  }
425
- const result = server.handleMessage(raw);
463
+ const result = await server.handleMessage(raw);
426
464
  if (!result) {
427
465
  return response("", 204);
428
466
  }
@@ -484,6 +522,7 @@ export class McpServer {
484
522
  // ── Decorator API ──────────────────────────────────────────────
485
523
 
486
524
  let _defaultServer: McpServer | null = null;
525
+ let _defaultToolsRegistered = false;
487
526
 
488
527
  function _getDefaultServer(): McpServer {
489
528
  if (_defaultServer === null) {
@@ -492,6 +531,24 @@ function _getDefaultServer(): McpServer {
492
531
  return _defaultServer;
493
532
  }
494
533
 
534
+ /**
535
+ * The default `/__dev/mcp` MCP server with the built-in dev tools registered.
536
+ *
537
+ * This is the single shared instance backing BOTH the browser REST shim
538
+ * (`/__dev/api/mcp/tools` + `/__dev/api/mcp/call`) and the JSON-RPC + SSE
539
+ * endpoints (`/__dev/mcp[/message]` + `/__dev/mcp/sse`) that real MCP clients
540
+ * (Claude Code/Desktop) speak. Tools are registered exactly once (idempotent).
541
+ * Mirrors Python's default MCP server used by `get_api_handlers()`.
542
+ */
543
+ export function getDefaultDevServer(): McpServer {
544
+ const server = _getDefaultServer();
545
+ if (!_defaultToolsRegistered) {
546
+ registerDevTools(server);
547
+ _defaultToolsRegistered = true;
548
+ }
549
+ return server;
550
+ }
551
+
495
552
  /**
496
553
  * Register a function as an MCP tool.
497
554
  *
@@ -787,13 +844,12 @@ export function registerDevTools(server: McpServer): void {
787
844
 
788
845
  server.registerTool(
789
846
  "database_query",
790
- (args) => {
847
+ async (args) => {
791
848
  try {
792
- const { initDatabase } = require("@tina4/orm");
793
849
  const db = (globalThis as any).__tina4_db;
794
850
  if (!db) return { error: "No database connection" };
795
851
  const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
796
- const result = db.fetch(args.sql as string, params);
852
+ const result = await db.fetch(args.sql as string, params);
797
853
  return { records: result.records || [], count: result.count || 0 };
798
854
  } catch (e) {
799
855
  return { error: (e as Error).message };
@@ -808,14 +864,14 @@ export function registerDevTools(server: McpServer): void {
808
864
 
809
865
  server.registerTool(
810
866
  "database_execute",
811
- (args) => {
867
+ async (args) => {
812
868
  try {
813
869
  const db = (globalThis as any).__tina4_db;
814
870
  if (!db) return { error: "No database connection" };
815
871
  const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
816
- const result = db.execute(args.sql as string, params);
817
- db.commit?.();
818
- return { success: true, affected_rows: result?.count ?? 0 };
872
+ const result = await db.execute(args.sql as string, params);
873
+ await db.commit?.();
874
+ return { success: true, affected_rows: (result as any)?.count ?? 0 };
819
875
  } catch (e) {
820
876
  return { error: (e as Error).message };
821
877
  }
@@ -829,11 +885,11 @@ export function registerDevTools(server: McpServer): void {
829
885
 
830
886
  server.registerTool(
831
887
  "database_tables",
832
- (_args) => {
888
+ async (_args) => {
833
889
  try {
834
890
  const db = (globalThis as any).__tina4_db;
835
891
  if (!db) return { error: "No database connection" };
836
- return db.getTables?.() ?? [];
892
+ return (await db.getTables?.()) ?? [];
837
893
  } catch (e) {
838
894
  return { error: (e as Error).message };
839
895
  }
@@ -844,11 +900,11 @@ export function registerDevTools(server: McpServer): void {
844
900
 
845
901
  server.registerTool(
846
902
  "database_columns",
847
- (args) => {
903
+ async (args) => {
848
904
  try {
849
905
  const db = (globalThis as any).__tina4_db;
850
906
  if (!db) return { error: "No database connection" };
851
- return db.getColumns?.(args.table as string) ?? [];
907
+ return (await db.getColumns?.(args.table as string)) ?? [];
852
908
  } catch (e) {
853
909
  return { error: (e as Error).message };
854
910
  }
@@ -863,8 +919,16 @@ export function registerDevTools(server: McpServer): void {
863
919
  "route_list",
864
920
  (_args) => {
865
921
  try {
866
- const { defaultRouter } = require("@tina4/core");
867
- const routes = defaultRouter?.listRoutes?.() ?? [];
922
+ // Prefer the active server router (set by startServer) so file-discovered
923
+ // routes are included; startServer builds a fresh Router rather than using
924
+ // defaultRouter. Fall back to defaultRouter when no server is running.
925
+ // route_list lives inside @tina4/core, so reach the router via the local
926
+ // module — req("@tina4/core") depends on a built dist/ and fails under a
927
+ // from-source ESM runtime (the real MCP-client code path).
928
+ const { defaultRouter } = req("./router.js") as typeof import("./router.js");
929
+ const activeRouter = (globalThis as any).__tina4_router;
930
+ const router = activeRouter ?? defaultRouter;
931
+ const routes = router?.listRoutes?.() ?? [];
868
932
  return routes.map((r: any) => ({
869
933
  method: r.method || "",
870
934
  path: r.pattern || r.path || "",
@@ -901,10 +965,12 @@ export function registerDevTools(server: McpServer): void {
901
965
  "swagger_spec",
902
966
  (_args) => {
903
967
  try {
904
- const { generateSpec } = require("@tina4/swagger");
905
- return generateSpec?.() ?? { info: "Swagger not available" };
906
- } catch {
907
- return { info: "Swagger package not loaded" };
968
+ const { generate } = reqSibling("swagger") as { generate?: (routes: unknown[], models?: unknown) => unknown };
969
+ const { defaultRouter } = req("./router.js") as typeof import("./router.js");
970
+ const routes = defaultRouter?.getRoutes?.() ?? [];
971
+ return generate?.(routes, []) ?? { info: "Swagger not available" };
972
+ } catch (e) {
973
+ return { error: (e as Error).message };
908
974
  }
909
975
  },
910
976
  "Return the OpenAPI 3.0.3 JSON spec",
@@ -917,9 +983,11 @@ export function registerDevTools(server: McpServer): void {
917
983
  "template_render",
918
984
  (args) => {
919
985
  try {
920
- const { renderTemplate } = require("@tina4/twig");
986
+ const { Frond } = reqSibling("frond") as { Frond?: new (dir?: string) => { renderString: (s: string, d?: Record<string, unknown>) => string } };
987
+ if (!Frond) return "Template engine not available";
921
988
  const data = typeof args.data === "string" ? JSON.parse(args.data as string) : (args.data || {});
922
- return renderTemplate?.(args.template as string, data) ?? "Template engine not available";
989
+ const frond = new Frond(path.join(projectRoot, "src", "templates"));
990
+ return frond.renderString(args.template as string, data as Record<string, unknown>);
923
991
  } catch (e) {
924
992
  return { error: (e as Error).message };
925
993
  }
@@ -1066,7 +1134,7 @@ export function registerDevTools(server: McpServer): void {
1066
1134
 
1067
1135
  server.registerTool(
1068
1136
  "migration_status",
1069
- (_args) => {
1137
+ async (_args) => {
1070
1138
  try {
1071
1139
  const db = (globalThis as any).__tina4_db;
1072
1140
  if (!db) return { error: "No database connection" };
@@ -1099,7 +1167,7 @@ export function registerDevTools(server: McpServer): void {
1099
1167
 
1100
1168
  server.registerTool(
1101
1169
  "migration_run",
1102
- (_args) => {
1170
+ async (_args) => {
1103
1171
  try {
1104
1172
  const db = (globalThis as any).__tina4_db;
1105
1173
  if (!db) return { error: "No database connection" };
@@ -1118,7 +1186,7 @@ export function registerDevTools(server: McpServer): void {
1118
1186
  "queue_status",
1119
1187
  (args) => {
1120
1188
  try {
1121
- const { Queue } = require("@tina4/core");
1189
+ const { Queue } = req("./queue.js") as typeof import("./queue.js");
1122
1190
  const topic = (args.topic as string) || "default";
1123
1191
  const q = new Queue({ topic });
1124
1192
  return {
@@ -1166,7 +1234,7 @@ export function registerDevTools(server: McpServer): void {
1166
1234
  // latest snapshot once available. The very first call may report the
1167
1235
  // pending placeholder; subsequent calls return live figures.
1168
1236
  try {
1169
- const mod = require("@tina4/core");
1237
+ const mod = req("./cache.js") as typeof import("./cache.js");
1170
1238
  const stats = mod.cacheStats?.();
1171
1239
  if (stats && typeof stats.then === "function") {
1172
1240
  stats.then((s: unknown) => { _lastCacheStats = s as Record<string, unknown>; }).catch(() => {});
@@ -1222,12 +1290,9 @@ export function registerDevTools(server: McpServer): void {
1222
1290
  "error_log",
1223
1291
  (args) => {
1224
1292
  try {
1225
- const { DevAdmin } = require("@tina4/core");
1226
- const tracker = DevAdmin?.errorTracker;
1227
- if (tracker?.get) {
1228
- return tracker.get(args.limit || 20);
1229
- }
1230
- return [];
1293
+ const { ErrorTracker } = req("./devAdmin.js") as typeof import("./devAdmin.js");
1294
+ const limit = (args.limit as number) || 20;
1295
+ return ErrorTracker.get().slice(0, limit);
1231
1296
  } catch {
1232
1297
  return [];
1233
1298
  }
@@ -1256,13 +1321,13 @@ export function registerDevTools(server: McpServer): void {
1256
1321
 
1257
1322
  server.registerTool(
1258
1323
  "seed_table",
1259
- (args) => {
1324
+ async (args) => {
1260
1325
  try {
1261
- const { seedTable } = require("@tina4/orm");
1326
+ const { seedTable } = reqSibling("orm") as { seedTable?: (db: unknown, table: string, count: number) => number | Promise<number> };
1262
1327
  const db = (globalThis as any).__tina4_db;
1263
1328
  if (!db) return { error: "No database connection" };
1264
1329
  const count = (args.count as number) || 10;
1265
- const inserted = seedTable?.(db, args.table as string, count) ?? 0;
1330
+ const inserted = (await seedTable?.(db, args.table as string, count)) ?? 0;
1266
1331
  return { table: args.table, inserted };
1267
1332
  } catch (e) {
1268
1333
  return { error: (e as Error).message };
@@ -1305,9 +1370,9 @@ export function registerDevTools(server: McpServer): void {
1305
1370
  // Ported from Python's tina4_python.mcp.tools — names match exactly.
1306
1371
  // The Plan storage format is byte-for-byte compatible across frameworks.
1307
1372
 
1308
- const loadPlan = () => require("./plan.js").Plan as typeof import("./plan.js").Plan;
1373
+ const loadPlan = () => req("./plan.js").Plan as typeof import("./plan.js").Plan;
1309
1374
  const loadIndex = () =>
1310
- require("./projectIndex.js").ProjectIndex as typeof import("./projectIndex.js").ProjectIndex;
1375
+ req("./projectIndex.js").ProjectIndex as typeof import("./projectIndex.js").ProjectIndex;
1311
1376
 
1312
1377
  server.registerTool(
1313
1378
  "plan_current",
@@ -1429,14 +1494,14 @@ export function registerDevTools(server: McpServer): void {
1429
1494
 
1430
1495
  server.registerTool(
1431
1496
  "project_overview",
1432
- () => {
1497
+ async () => {
1433
1498
  const out: Record<string, unknown> = {};
1434
1499
  try { out.index = loadIndex().overview(); } catch (e) { out.index = { error: (e as Error).message }; }
1435
1500
  try { out.plans = loadPlan().listPlans(); } catch (e) { out.plans = { error: (e as Error).message }; }
1436
1501
  try { out.current_plan = loadPlan().current(); } catch (e) { out.current_plan = { error: (e as Error).message }; }
1437
1502
  try {
1438
1503
  const db = (globalThis as any).__tina4_db;
1439
- out.tables = db?.getTables?.() ?? [];
1504
+ out.tables = (await db?.getTables?.()) ?? [];
1440
1505
  } catch (e) { out.tables = { error: (e as Error).message }; }
1441
1506
  return out;
1442
1507
  },
@@ -1633,7 +1698,7 @@ export function registerDevTools(server: McpServer): void {
1633
1698
  "git_status",
1634
1699
  () => {
1635
1700
  try {
1636
- const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
1701
+ const { execFileSync } = req("node:child_process") as typeof import("node:child_process");
1637
1702
  const cwd = path.resolve(process.cwd());
1638
1703
  const run = (args: string[]): string => {
1639
1704
  return execFileSync("git", args, { cwd, timeout: 3000, encoding: "utf-8" }).toString().trim();
@@ -1685,7 +1750,7 @@ export function registerDevTools(server: McpServer): void {
1685
1750
  (args) => {
1686
1751
  try {
1687
1752
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1688
- const { Docs } = require("./docs.js") as typeof import("./docs.js");
1753
+ const { Docs } = req("./docs.js") as typeof import("./docs.js");
1689
1754
  return Docs.mcpSearch(
1690
1755
  (args.query as string) || "",
1691
1756
  parseInt(String(args.k ?? 5), 10) || 5,
@@ -1711,7 +1776,7 @@ export function registerDevTools(server: McpServer): void {
1711
1776
  (args) => {
1712
1777
  try {
1713
1778
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1714
- const { Docs } = require("./docs.js") as typeof import("./docs.js");
1779
+ const { Docs } = req("./docs.js") as typeof import("./docs.js");
1715
1780
  const spec = Docs.mcpClass((args.name as string) || "");
1716
1781
  return spec ?? { error: `class not found: ${args.name}` };
1717
1782
  } catch (e) {
@@ -1727,7 +1792,7 @@ export function registerDevTools(server: McpServer): void {
1727
1792
  (args) => {
1728
1793
  try {
1729
1794
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1730
- const { Docs } = require("./docs.js") as typeof import("./docs.js");
1795
+ const { Docs } = req("./docs.js") as typeof import("./docs.js");
1731
1796
  // PHP names the param `class`, Python names it `class_` — Node.js MCP
1732
1797
  // accepts the raw `class` field from the JSON-RPC payload.
1733
1798
  const cls = (args.class as string) || (args.class_name as string) || "";
@@ -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";
@@ -756,6 +757,13 @@ ${reset}
756
757
  const router = new Router();
757
758
  const middleware = new MiddlewareChain();
758
759
 
760
+ // Expose the active server router globally so dev tools (e.g. the MCP
761
+ // route_list tool) can introspect the real, fully-populated route table —
762
+ // startServer builds a fresh Router rather than using defaultRouter, so
763
+ // file-discovered routes only live here. Mirrors the globalThis.__tina4_db
764
+ // hook other dev tools read.
765
+ (globalThis as any).__tina4_router = router;
766
+
759
767
  // Merge routes registered via top-level get(), post(), etc.
760
768
  for (const route of defaultRouter.getRoutes()) {
761
769
  router.addRoute(route);
@@ -1385,6 +1393,32 @@ ${reset}
1385
1393
  // posts /__dev/api/reload to the MAIN port. Matches Python (master).
1386
1394
  const server = createServer(dispatch);
1387
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
+
1388
1422
  return new Promise((resolvePromise) => {
1389
1423
  server.listen(port, host, () => {
1390
1424
  const displayHost = host === "0.0.0.0" ? "localhost" : host;
@@ -1413,6 +1447,17 @@ ${reset}
1413
1447
  await dispatch(req, res);
1414
1448
  });
1415
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
+
1416
1461
  aiServer.on("error", (err: any) => {
1417
1462
  if (err.code === "EADDRINUSE") {
1418
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();