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.
@@ -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): void {
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
- this.wsRoutes.push({ pattern: path, handler });
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): void {
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): void {
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-primary DevReload: accept and hold /__dev_reload upgrades on the
1419
- // MAIN port (debug only) so POST /__dev/api/reload can push an instant reload.
1420
- // Mirrors Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
1421
- // Without this the handshake 404s and the whole stack silently falls back to
1422
- // polling. Track connections in WsTracker so they appear in the dev-admin list.
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
- server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
1429
- const upPath = (req.url ?? "/").split("?")[0];
1430
- if (upPath === "/__dev_reload") {
1431
- devReloadWs.handleUpgrade(req, socket, head);
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
  }