tina4-nodejs 3.13.38 → 3.13.39
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 +16 -3
- package/README.md +6 -6
- package/package.json +1 -1
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/devAdmin.ts +36 -19
- package/packages/core/src/index.ts +8 -3
- package/packages/core/src/logger.ts +84 -27
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/metrics.ts +330 -70
- package/packages/core/src/middleware.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +97 -0
- package/packages/core/src/router.ts +54 -6
- package/packages/core/src/server.ts +113 -20
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +419 -9
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/orm/src/baseModel.ts +167 -22
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/migration.ts +149 -22
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
|
@@ -67,6 +67,23 @@ interface CompiledRoute {
|
|
|
67
67
|
template?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Thin reference to a registered WebSocket route, enabling chained modifiers
|
|
72
|
+
* — the WS analogue of {@link RouteRef}.
|
|
73
|
+
*
|
|
74
|
+
* Usage:
|
|
75
|
+
* router.websocket("/ws/secure", handler).secure();
|
|
76
|
+
*/
|
|
77
|
+
export class WsRouteRef {
|
|
78
|
+
constructor(private route: WebSocketRouteDefinition) {}
|
|
79
|
+
|
|
80
|
+
/** Mark this WS route as requiring a valid JWT on the upgrade handshake. */
|
|
81
|
+
secure(): this {
|
|
82
|
+
this.route.authRequired = true;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
/**
|
|
71
88
|
* Thin reference to a registered route, enabling chained modifiers.
|
|
72
89
|
*
|
|
@@ -412,11 +429,29 @@ export class Router {
|
|
|
412
429
|
|
|
413
430
|
/**
|
|
414
431
|
* Register a WebSocket route.
|
|
432
|
+
*
|
|
433
|
+
* A WS route is PUBLIC by default (mirrors GET). It can be marked secured in
|
|
434
|
+
* EITHER way the HTTP routes support:
|
|
435
|
+
* • imperatively — `websocket(path, fn, { secured: true })`, or chain the
|
|
436
|
+
* returned ref: `websocket(path, fn).secure()`;
|
|
437
|
+
* • decorator-style — a `_secured` flag on the handler function, set in
|
|
438
|
+
* either order relative to registration (the ref keeps a back-reference
|
|
439
|
+
* to the route so a later `.secure()` / `_secured` still flips it).
|
|
440
|
+
*
|
|
441
|
+
* When secured, the upgrade handshake requires a valid JWT (Authorization
|
|
442
|
+
* header / `bearer` subprotocol / `?token=`) or the upgrade is rejected.
|
|
415
443
|
*/
|
|
416
|
-
websocket(path: string, handler: WebSocketRouteHandler):
|
|
444
|
+
websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
|
|
417
445
|
// Remove existing ws route with same pattern (for hot-reload)
|
|
418
446
|
this.wsRoutes = this.wsRoutes.filter((r) => r.pattern !== path);
|
|
419
|
-
|
|
447
|
+
const route: WebSocketRouteDefinition = {
|
|
448
|
+
pattern: path,
|
|
449
|
+
handler,
|
|
450
|
+
// Public unless explicitly secured via options OR a handler `_secured` flag.
|
|
451
|
+
authRequired: Boolean(options?.secured ?? handler._secured ?? false),
|
|
452
|
+
};
|
|
453
|
+
this.wsRoutes.push(route);
|
|
454
|
+
return new WsRouteRef(route);
|
|
420
455
|
}
|
|
421
456
|
|
|
422
457
|
/**
|
|
@@ -503,8 +538,21 @@ export class Router {
|
|
|
503
538
|
/**
|
|
504
539
|
* Register a WebSocket route on the default global router.
|
|
505
540
|
*/
|
|
506
|
-
static websocket(path: string, handler: WebSocketRouteHandler):
|
|
507
|
-
defaultRouter.websocket(path, handler);
|
|
541
|
+
static websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
|
|
542
|
+
return defaultRouter.websocket(path, handler, options);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Match a WebSocket upgrade path against routes on the default global router.
|
|
547
|
+
* Returns the matched route definition (with its `authRequired` flag) or null.
|
|
548
|
+
*/
|
|
549
|
+
static matchWebSocket(pathname: string): WebSocketRouteDefinition | null {
|
|
550
|
+
return defaultRouter.matchWebSocket(pathname);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** All WebSocket route definitions on the default global router. */
|
|
554
|
+
static getWebSocketRoutes(): WebSocketRouteDefinition[] {
|
|
555
|
+
return defaultRouter.getWebSocketRoutes();
|
|
508
556
|
}
|
|
509
557
|
|
|
510
558
|
/**
|
|
@@ -816,8 +864,8 @@ export function any(path: string, handler: RouteHandler, middlewares?: Middlewar
|
|
|
816
864
|
return defaultRouter.any(path, handler, middlewares, meta);
|
|
817
865
|
}
|
|
818
866
|
|
|
819
|
-
export function websocket(path: string, handler: WebSocketRouteHandler):
|
|
820
|
-
defaultRouter.websocket(path, handler);
|
|
867
|
+
export function websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
|
|
868
|
+
return defaultRouter.websocket(path, handler, options);
|
|
821
869
|
}
|
|
822
870
|
|
|
823
871
|
// Re-export "del" as "delete" for developer convenience (use: import { delete as del } from "@tina4/core")
|
|
@@ -20,7 +20,7 @@ import { createHealthRoutes } from "./health.js";
|
|
|
20
20
|
import { rateLimiter } from "./rateLimiter.js";
|
|
21
21
|
import { Log } from "./logger.js";
|
|
22
22
|
import { DevAdmin, RequestInspector, WsTracker } from "./devAdmin.js";
|
|
23
|
-
import { devReloadWs } from "./websocket.js";
|
|
23
|
+
import { devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
|
|
24
24
|
import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
|
|
25
25
|
import { I18n } from "./i18n.js";
|
|
26
26
|
import { stopAllBackgroundTasks } from "./background.js";
|
|
@@ -34,6 +34,72 @@ const BUILTIN_ERROR_TEMPLATES_DIR = resolve(__dirname, "..", "templates");
|
|
|
34
34
|
/** Built-in public directory for framework-bundled static assets. */
|
|
35
35
|
const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Apply pending DB migrations on startup — NON-BREAKING.
|
|
39
|
+
*
|
|
40
|
+
* When a `migrations/` folder exists (with at least one `.sql` file, excluding
|
|
41
|
+
* `.down.sql`) and `TINA4_AUTO_MIGRATE` is not disabled (default "true";
|
|
42
|
+
* false/0/no/off disable), pending migrations are applied during boot so the
|
|
43
|
+
* schema is current with no manual `tina4 migrate` step. A failure here is
|
|
44
|
+
* logged LOUD via `Log.error` and the service STILL starts — a bad migration
|
|
45
|
+
* must never take the backend down. (The explicit `tina4 migrate` CLI stays
|
|
46
|
+
* fail-fast so CI still gets a non-zero exit. Only this startup hook swallows.)
|
|
47
|
+
*
|
|
48
|
+
* Disable with `TINA4_AUTO_MIGRATE=false` — e.g. multi-instance production that
|
|
49
|
+
* migrates as a separate deploy step (concurrent first-apply can race).
|
|
50
|
+
*
|
|
51
|
+
* @param migrationDir - migrations directory (default "migrations", relative to base)
|
|
52
|
+
* @param base - project root used to resolve the migrations directory
|
|
53
|
+
*/
|
|
54
|
+
export async function autoMigrateOnStartup(
|
|
55
|
+
migrationDir = "migrations",
|
|
56
|
+
base = process.cwd(),
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const dir = resolve(base, migrationDir);
|
|
59
|
+
|
|
60
|
+
// Gate 1: a migrations/ folder with at least one .sql (non-down) file.
|
|
61
|
+
if (!existsSync(dir)) return;
|
|
62
|
+
let hasSql = false;
|
|
63
|
+
try {
|
|
64
|
+
hasSql = readdirSync(dir).some((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"));
|
|
65
|
+
} catch {
|
|
66
|
+
return; // unreadable dir → nothing to do (silent)
|
|
67
|
+
}
|
|
68
|
+
if (!hasSql) return;
|
|
69
|
+
|
|
70
|
+
// Gate 2: TINA4_AUTO_MIGRATE not falsy (default "true").
|
|
71
|
+
const flag = process.env.TINA4_AUTO_MIGRATE;
|
|
72
|
+
if (flag != null && !isTruthy(flag)) {
|
|
73
|
+
Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Gate 3: a DB adapter must be resolvable. (initDatabase() has already run by
|
|
78
|
+
// the time this is called from startServer.)
|
|
79
|
+
let orm: typeof import("../../orm/src/index.js");
|
|
80
|
+
try {
|
|
81
|
+
orm = await import("../../orm/src/index.js");
|
|
82
|
+
orm.getAdapter(); // throws if no adapter configured
|
|
83
|
+
} catch (err) {
|
|
84
|
+
Log.debug(`Startup migrations skipped (no database configured): ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Run the EXISTING migrate runner inside try/catch — NEVER re-raise out of
|
|
89
|
+
// the startup hook (non-breaking).
|
|
90
|
+
try {
|
|
91
|
+
const result = await orm.migrate(undefined, { migrationsDir: dir });
|
|
92
|
+
if (result.applied.length > 0) {
|
|
93
|
+
Log.info(`Applied ${result.applied.length} pending migration(s) on startup`);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
Log.error(
|
|
97
|
+
`Startup auto-migration failed: ${err instanceof Error ? err.message : String(err)} — ` +
|
|
98
|
+
"the service is starting anyway. Run `tina4 migrate` to retry.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
37
103
|
/** Read version from root package.json so the banner always matches the published version. */
|
|
38
104
|
function readPackageVersion(): string {
|
|
39
105
|
try {
|
|
@@ -884,6 +950,12 @@ ${reset}
|
|
|
884
950
|
} catch (err) {
|
|
885
951
|
console.warn(`\n ORM not available (install @tina4/orm to enable):`, err);
|
|
886
952
|
}
|
|
953
|
+
|
|
954
|
+
// Auto-run pending migrations on startup — AFTER initDatabase()/model sync,
|
|
955
|
+
// BEFORE the server listens. Non-breaking: a failure is logged and boot
|
|
956
|
+
// continues (the helper never throws). Gated on a migrations/ dir + the
|
|
957
|
+
// TINA4_AUTO_MIGRATE flag (default on) + a resolvable DB adapter.
|
|
958
|
+
await autoMigrateOnStartup("migrations", base);
|
|
887
959
|
}
|
|
888
960
|
|
|
889
961
|
// Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled
|
|
@@ -1415,31 +1487,52 @@ ${reset}
|
|
|
1415
1487
|
// posts /__dev/api/reload to the MAIN port. Matches Python (master).
|
|
1416
1488
|
const server = createServer(dispatch);
|
|
1417
1489
|
|
|
1418
|
-
// WebSocket
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1490
|
+
// WebSocket upgrade handling on the MAIN port. Two responsibilities:
|
|
1491
|
+
//
|
|
1492
|
+
// 1. WebSocket-primary DevReload (debug only): accept and hold /__dev_reload
|
|
1493
|
+
// upgrades so POST /__dev/api/reload can push an instant reload. Mirrors
|
|
1494
|
+
// Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
|
|
1495
|
+
//
|
|
1496
|
+
// 2. USER WS ROUTES (always): a route registered via Router.websocket() /
|
|
1497
|
+
// WebSocketServer.route() is dispatched to a live open/message/close
|
|
1498
|
+
// lifecycle on the real connection — parity with Python/PHP/Ruby. Per-route
|
|
1499
|
+
// auth is enforced here on the upgrade (serveWebSocketRoute): a @secured /
|
|
1500
|
+
// .secure() route rejects a missing/invalid JWT before accepting; public
|
|
1501
|
+
// routes pass. Previously the integrated server only handled /__dev_reload,
|
|
1502
|
+
// so user WS routes never reached a live connection.
|
|
1503
|
+
//
|
|
1504
|
+
// Tracking goes through WsTracker so connections show in the dev-admin list.
|
|
1423
1505
|
if (isDevMode()) {
|
|
1424
1506
|
devReloadWs.setTracker(
|
|
1425
1507
|
(remoteAddress, p) => WsTracker.add(remoteAddress, p),
|
|
1426
1508
|
(id) => { WsTracker.remove(id); },
|
|
1427
1509
|
);
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
return;
|
|
1433
|
-
}
|
|
1434
|
-
// Not a dev-reload upgrade — refuse cleanly rather than leaving it hanging.
|
|
1435
|
-
try {
|
|
1436
|
-
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
1437
|
-
socket.destroy();
|
|
1438
|
-
} catch {
|
|
1439
|
-
/* socket already gone */
|
|
1440
|
-
}
|
|
1441
|
-
});
|
|
1510
|
+
wsRouteManager.setTracker(
|
|
1511
|
+
(remoteAddress, p) => WsTracker.add(remoteAddress, p),
|
|
1512
|
+
(id) => { WsTracker.remove(id); },
|
|
1513
|
+
);
|
|
1442
1514
|
}
|
|
1515
|
+
server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
|
|
1516
|
+
const upPath = (req.url ?? "/").split("?")[0];
|
|
1517
|
+
// Dev-reload channel (debug only).
|
|
1518
|
+
if (isDevMode() && upPath === "/__dev_reload") {
|
|
1519
|
+
devReloadWs.handleUpgrade(req, socket, head);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
// User-registered WS route — enforces per-route auth, then drives the
|
|
1523
|
+
// open/message/close lifecycle on this connection. Returns false only when
|
|
1524
|
+
// no WS route matches this path.
|
|
1525
|
+
if (serveWebSocketRoute(req, socket, head)) {
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
// No dev-reload and no matching user route — refuse cleanly.
|
|
1529
|
+
try {
|
|
1530
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
1531
|
+
socket.destroy();
|
|
1532
|
+
} catch {
|
|
1533
|
+
/* socket already gone */
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1443
1536
|
|
|
1444
1537
|
return new Promise((resolvePromise) => {
|
|
1445
1538
|
server.listen(port, host, () => {
|
|
@@ -179,13 +179,28 @@ export type MiddlewareSpec = Middleware | string;
|
|
|
179
179
|
* event — one of "open", "message", or "close".
|
|
180
180
|
* data — the incoming text message (only present for "message" events).
|
|
181
181
|
*/
|
|
182
|
-
export type WebSocketRouteHandler = (
|
|
182
|
+
export type WebSocketRouteHandler = ((
|
|
183
183
|
connection: import("./websocketConnection.js").WebSocketConnection,
|
|
184
184
|
event: "open" | "message" | "close",
|
|
185
185
|
data: string,
|
|
186
|
-
) => void | Promise<void
|
|
186
|
+
) => void | Promise<void>) & {
|
|
187
|
+
/**
|
|
188
|
+
* Decorator-style secured flag — mirrors Python's `handler._secured`. When
|
|
189
|
+
* truthy the WS route requires a valid JWT on the upgrade. `Router.websocket`
|
|
190
|
+
* reads this into the route's `authRequired`. Lets a handler be marked
|
|
191
|
+
* secured in EITHER order (before or after registration).
|
|
192
|
+
*/
|
|
193
|
+
_secured?: boolean;
|
|
194
|
+
};
|
|
187
195
|
|
|
188
196
|
export interface WebSocketRouteDefinition {
|
|
189
197
|
pattern: string;
|
|
190
198
|
handler: WebSocketRouteHandler;
|
|
199
|
+
/**
|
|
200
|
+
* True when this WS route requires a valid JWT on the upgrade. Public by
|
|
201
|
+
* default (mirrors GET). Set imperatively via `Router.websocket(path, fn,
|
|
202
|
+
* { secured: true })` / the returned `.secure()`, or by a `_secured` flag on
|
|
203
|
+
* the handler (decorator style).
|
|
204
|
+
*/
|
|
205
|
+
authRequired?: boolean;
|
|
191
206
|
}
|