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.
- package/CLAUDE.md +11 -8
- 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 +305 -28
- package/packages/core/src/index.ts +2 -1
- package/packages/core/src/mcp.test.ts +25 -25
- package/packages/core/src/mcp.ts +112 -47
- package/packages/core/src/routeDiscovery.ts +23 -5
- package/packages/core/src/server.ts +46 -1
- package/packages/core/src/websocket.ts +139 -0
- package/packages/orm/src/database.ts +27 -11
package/packages/core/src/mcp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
867
|
-
|
|
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 {
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
return { info: "Swagger
|
|
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 {
|
|
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
|
-
|
|
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 } =
|
|
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 =
|
|
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 {
|
|
1226
|
-
const
|
|
1227
|
-
|
|
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 } =
|
|
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 = () =>
|
|
1373
|
+
const loadPlan = () => req("./plan.js").Plan as typeof import("./plan.js").Plan;
|
|
1309
1374
|
const loadIndex = () =>
|
|
1310
|
-
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
|
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";
|
|
@@ -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();
|