tina4-nodejs 3.12.2 → 3.12.4

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.
@@ -5,12 +5,13 @@ import {
5
5
  renameSync,
6
6
  statSync,
7
7
  unlinkSync,
8
+ writeFileSync,
8
9
  } from "node:fs";
9
- import { join, dirname } from "node:path";
10
+ import { join, dirname, isAbsolute } from "node:path";
10
11
  import { isTruthy } from "./dotenv.js";
11
12
 
12
13
  /** Log level severity */
13
- type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR";
14
+ type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
14
15
 
15
16
  /** Log level priority for filtering */
16
17
  const LEVEL_PRIORITY: Record<LogLevel, number> = {
@@ -18,6 +19,7 @@ const LEVEL_PRIORITY: Record<LogLevel, number> = {
18
19
  INFO: 1,
19
20
  WARNING: 2,
20
21
  ERROR: 3,
22
+ CRITICAL: 4,
21
23
  };
22
24
 
23
25
  /** Structured log entry for JSON output */
@@ -31,10 +33,11 @@ interface LogEntry {
31
33
 
32
34
  /** ANSI color codes for terminal output */
33
35
  const COLORS: Record<LogLevel, string> = {
34
- DEBUG: "\x1b[36m", // cyan
35
- INFO: "\x1b[32m", // green
36
- WARNING: "\x1b[33m", // yellow
37
- ERROR: "\x1b[31m", // red
36
+ DEBUG: "\x1b[36m", // cyan
37
+ INFO: "\x1b[32m", // green
38
+ WARNING: "\x1b[33m", // yellow
39
+ ERROR: "\x1b[31m", // red
40
+ CRITICAL: "\x1b[35m", // magenta
38
41
  };
39
42
  const RESET = "\x1b[0m";
40
43
 
@@ -47,28 +50,103 @@ const DEFAULT_LOG_DIR = "logs";
47
50
  /** Default log filename */
48
51
  const DEFAULT_LOG_FILE = "tina4.log";
49
52
 
53
+ /** Default rotation size — 10 MB */
54
+ const DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024;
55
+
56
+ /** Default rotation keep count */
57
+ const DEFAULT_ROTATE_KEEP = 5;
58
+
50
59
  /** Strip ANSI escape codes from a string */
51
60
  function stripAnsi(text: string): string {
52
61
  return text.replace(ANSI_RE, "");
53
62
  }
54
63
 
64
+ /**
65
+ * Resolve the log file path from env (or constructor options).
66
+ *
67
+ * If `TINA4_LOG_FILE` is set:
68
+ * - absolute path → used as-is.
69
+ * - relative → resolved against `TINA4_LOG_DIR` (default `logs`).
70
+ *
71
+ * Otherwise the directory is `TINA4_LOG_DIR` and filename is `tina4.log`.
72
+ */
73
+ function resolveLogFilePath(logDir: string, logFile: string): string {
74
+ if (isAbsolute(logFile)) return logFile;
75
+ return join(logDir, logFile);
76
+ }
77
+
55
78
  /**
56
79
  * Structured logger for Tina4.
57
80
  *
58
- * Production (TINA4_DEBUG not truthy): JSON lines to logs/tina4.log
81
+ * Production (TINA4_DEBUG not truthy): JSON or text lines to logs/tina4.log
59
82
  * Development (TINA4_DEBUG=true): Colorized human-readable to stdout + file
60
83
  *
61
- * Log rotation: numbered scheme (tina4.log -> tina4.log.1 -> ... -> tina4.log.{keep})
62
- * Raw log file always writes ALL levels (no filtering), plain text (no ANSI codes).
63
- * Console output respects TINA4_LOG_LEVEL.
84
+ * Env vars:
85
+ * TINA4_LOG_FILE — explicit log file (absolute or relative). Empty = use TINA4_LOG_DIR + tina4.log
86
+ * TINA4_LOG_DIR — directory for log files (default: "logs")
87
+ * TINA4_LOG_FORMAT — "text" | "json" (default: "text")
88
+ * TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout")
89
+ * TINA4_LOG_CRITICAL — "true" to enable CRITICAL level shortcut (default: "false")
90
+ * TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
91
+ * TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
92
+ * TINA4_LOG_LEVEL — minimum console level (default: "DEBUG")
93
+ *
94
+ * Rotation is stdlib roll-your-own:
95
+ * - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
96
+ * - app.log.{N-1} → app.log.{N}, …, app.log → app.log.1 via fs.renameSync.
97
+ * - Files beyond _KEEP are dropped via fs.unlinkSync.
98
+ * - _SIZE=0 disables rotation entirely.
64
99
  */
65
100
  export class Log {
66
101
  private static requestId: string | undefined;
67
- private static logDir: string = DEFAULT_LOG_DIR;
68
- private static logFile: string = DEFAULT_LOG_FILE;
69
- private static maxFileSize: number = 10 * 1024 * 1024;
70
- private static keepFiles: number = 5;
71
- private static minLevel: number = 0;
102
+
103
+ /**
104
+ * Re-read all log-related env vars. Called on every log() so tests that
105
+ * mutate process.env between calls see the new values without having to
106
+ * call configure() each time.
107
+ */
108
+ private static readEnv(): {
109
+ logDir: string;
110
+ logFile: string;
111
+ rotateSize: number;
112
+ rotateKeep: number;
113
+ minLevel: number;
114
+ format: "text" | "json";
115
+ output: "stdout" | "file" | "both";
116
+ criticalEnabled: boolean;
117
+ } {
118
+ const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
119
+ const logFile = (process.env.TINA4_LOG_FILE ?? "").trim() || DEFAULT_LOG_FILE;
120
+
121
+ const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
122
+ let rotateSize = DEFAULT_ROTATE_SIZE;
123
+ if (rawSize !== undefined) {
124
+ const n = parseInt(rawSize, 10);
125
+ rotateSize = isNaN(n) || n < 0 ? DEFAULT_ROTATE_SIZE : n;
126
+ }
127
+
128
+ const rawKeep = process.env.TINA4_LOG_ROTATE_KEEP;
129
+ let rotateKeep = DEFAULT_ROTATE_KEEP;
130
+ if (rawKeep !== undefined) {
131
+ const n = parseInt(rawKeep, 10);
132
+ rotateKeep = isNaN(n) || n < 1 ? DEFAULT_ROTATE_KEEP : n;
133
+ }
134
+
135
+ const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
136
+ const minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
137
+
138
+ const fmt = (process.env.TINA4_LOG_FORMAT ?? "text").trim().toLowerCase();
139
+ const format: "text" | "json" = fmt === "json" ? "json" : "text";
140
+
141
+ const out = (process.env.TINA4_LOG_OUTPUT ?? "stdout").trim().toLowerCase();
142
+ let output: "stdout" | "file" | "both" = "stdout";
143
+ if (out === "file") output = "file";
144
+ else if (out === "both") output = "both";
145
+
146
+ const criticalEnabled = isTruthy(process.env.TINA4_LOG_CRITICAL);
147
+
148
+ return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, criticalEnabled };
149
+ }
72
150
 
73
151
  /**
74
152
  * Set the current request ID for log correlation.
@@ -85,21 +163,12 @@ export class Log {
85
163
  }
86
164
 
87
165
  /**
88
- * Configure the log directory, filename, and rotation settings.
166
+ * Configure the log directory / filename. Mostly a no-op now —
167
+ * env vars are re-read on every call. Kept for backwards compatibility.
89
168
  */
90
169
  static configure(options: { logDir?: string; logFile?: string }): void {
91
- if (options.logDir) Log.logDir = options.logDir;
92
- if (options.logFile) Log.logFile = options.logFile;
93
-
94
- // Read rotation config from env (with defaults)
95
- const maxSizeMb = parseInt(process.env.TINA4_LOG_MAX_SIZE ?? "10", 10);
96
- Log.maxFileSize = (isNaN(maxSizeMb) ? 10 : maxSizeMb) * 1024 * 1024;
97
- const keep = parseInt(process.env.TINA4_LOG_KEEP ?? "5", 10);
98
- Log.keepFiles = isNaN(keep) ? 5 : keep;
99
-
100
- // Resolve minimum console log level
101
- const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
102
- Log.minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
170
+ if (options.logDir) process.env.TINA4_LOG_DIR = options.logDir;
171
+ if (options.logFile) process.env.TINA4_LOG_FILE = options.logFile;
103
172
  }
104
173
 
105
174
  /** Log an informational message. */
@@ -117,11 +186,25 @@ export class Log {
117
186
  Log.log("WARNING", message, data);
118
187
  }
119
188
 
189
+ /** Backwards-compat alias for warning(). */
190
+ static warn(message: string, data?: unknown): void {
191
+ Log.log("WARNING", message, data);
192
+ }
193
+
120
194
  /** Log an error message. */
121
195
  static error(message: string, data?: unknown): void {
122
196
  Log.log("ERROR", message, data);
123
197
  }
124
198
 
199
+ /**
200
+ * Log a critical message. Only emitted when TINA4_LOG_CRITICAL=true,
201
+ * otherwise this is a no-op (matches Python parity — critical is the
202
+ * highest-severity bucket and is opt-in to avoid drowning noisy apps).
203
+ */
204
+ static critical(message: string, data?: unknown): void {
205
+ Log.log("CRITICAL", message, data);
206
+ }
207
+
125
208
  /** Check if running in production mode (TINA4_DEBUG is not truthy). */
126
209
  private static isProduction(): boolean {
127
210
  return !isTruthy(process.env.TINA4_DEBUG);
@@ -132,43 +215,59 @@ export class Log {
132
215
  return new Date().toISOString();
133
216
  }
134
217
 
135
- /** Get the full path to the current log file */
136
- private static logFilePath(): string {
137
- return join(Log.logDir, Log.logFile);
138
- }
139
-
140
218
  /** Ensure the log directory exists */
141
- private static ensureLogDir(): void {
142
- const dir = dirname(Log.logFilePath());
219
+ private static ensureLogDir(filePath: string): void {
220
+ const dir = dirname(filePath);
143
221
  if (!existsSync(dir)) {
144
222
  mkdirSync(dir, { recursive: true });
145
223
  }
146
224
  }
147
225
 
148
226
  /**
149
- * Rotate using numbered scheme: tina4.log.{keep} is deleted, all others shift up by 1.
227
+ * Roll-your-own rotation, stdlib only.
228
+ *
229
+ * Sequence on each write:
230
+ * 1. statSync the current file. If size < rotateSize, return.
231
+ * 2. Drop any file beyond keep via unlinkSync (cap the historical count).
232
+ * 3. Atomic shift: app.log.{N-1} → app.log.{N}, …, app.log.1 → app.log.2.
233
+ * 4. Rename current app.log → app.log.1.
234
+ * 5. Truncate via writeFileSync(path, "") so subsequent appends start fresh.
235
+ *
236
+ * Sync calls per write are fine — the worst case is contention on a single
237
+ * file, and the OS atomically serialises rename/unlink anyway.
238
+ *
239
+ * `rotateSize` of 0 disables rotation entirely.
150
240
  */
151
- private static rotateIfNeeded(): void {
152
- const filePath = Log.logFilePath();
241
+ private static rotateIfNeeded(filePath: string, rotateSize: number, rotateKeep: number): void {
242
+ if (rotateSize <= 0) return;
153
243
  if (!existsSync(filePath)) return;
154
244
 
245
+ let size = 0;
155
246
  try {
156
- const stats = statSync(filePath);
157
- if (stats.size < Log.maxFileSize) return;
247
+ size = statSync(filePath).size;
158
248
  } catch {
159
249
  return;
160
250
  }
251
+ if (size < rotateSize) return;
252
+
253
+ // Drop files beyond keep
254
+ for (let n = rotateKeep + 1; n <= rotateKeep + 10; n++) {
255
+ const stale = `${filePath}.${n}`;
256
+ if (existsSync(stale)) {
257
+ try { unlinkSync(stale); } catch { /* ignore */ }
258
+ } else {
259
+ break;
260
+ }
261
+ }
161
262
 
162
- const keep = Log.keepFiles;
163
-
164
- // Delete the oldest rotated file if it exists
165
- const oldest = `${filePath}.${keep}`;
263
+ // Drop the oldest in-window file if at capacity
264
+ const oldest = `${filePath}.${rotateKeep}`;
166
265
  if (existsSync(oldest)) {
167
266
  try { unlinkSync(oldest); } catch { /* ignore */ }
168
267
  }
169
268
 
170
- // Shift existing rotated files: .{n} -> .{n+1}
171
- for (let n = keep - 1; n >= 1; n--) {
269
+ // Shift: .{N-1} -> .{N}, ..., .1 -> .2
270
+ for (let n = rotateKeep - 1; n >= 1; n--) {
172
271
  const src = `${filePath}.${n}`;
173
272
  const dst = `${filePath}.${n + 1}`;
174
273
  if (existsSync(src)) {
@@ -176,16 +275,17 @@ export class Log {
176
275
  }
177
276
  }
178
277
 
179
- // Rename current log to .1
278
+ // Move current .1, then truncate
180
279
  try { renameSync(filePath, `${filePath}.1`); } catch { /* ignore */ }
280
+ try { writeFileSync(filePath, "", "utf-8"); } catch { /* ignore */ }
181
281
  }
182
282
 
183
- /** Write a line to the log file, stripping ANSI codes */
184
- private static writeToFile(line: string): void {
283
+ /** Write a line to the log file, stripping ANSI codes. */
284
+ private static writeToFile(filePath: string, line: string, rotateSize: number, rotateKeep: number): void {
185
285
  try {
186
- Log.ensureLogDir();
187
- Log.rotateIfNeeded();
188
- appendFileSync(Log.logFilePath(), stripAnsi(line) + "\n", "utf-8");
286
+ Log.ensureLogDir(filePath);
287
+ Log.rotateIfNeeded(filePath, rotateSize, rotateKeep);
288
+ appendFileSync(filePath, stripAnsi(line) + "\n", "utf-8");
189
289
  } catch {
190
290
  // Silently fail — logging should never crash the app
191
291
  }
@@ -193,6 +293,11 @@ export class Log {
193
293
 
194
294
  /** Core log method */
195
295
  private static log(level: LogLevel, message: string, data?: unknown): void {
296
+ const cfg = Log.readEnv();
297
+
298
+ // Critical level is opt-in; treat as no-op when disabled.
299
+ if (level === "CRITICAL" && !cfg.criticalEnabled) return;
300
+
196
301
  const entry: LogEntry = {
197
302
  timestamp: Log.timestamp(),
198
303
  level,
@@ -207,20 +312,35 @@ export class Log {
207
312
  entry.context = data;
208
313
  }
209
314
 
210
- // Build human-readable line for file/console
211
- const paddedLevel = level.padEnd(7);
315
+ // Build human-readable line
316
+ const paddedLevel = level.padEnd(8);
212
317
  const reqPart = Log.requestId ? ` [${Log.requestId}]` : "";
213
318
  const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
214
319
  const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart} ${message}${dataPart}`;
215
320
 
216
- // Console output respects TINA4_LOG_LEVEL
217
- const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= Log.minLevel;
218
- if (!Log.isProduction() && shouldLog) {
321
+ // Build the file-format line based on TINA4_LOG_FORMAT
322
+ const fileLine = cfg.format === "json" ? JSON.stringify(entry) : humanLine;
323
+
324
+ const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= cfg.minLevel;
325
+
326
+ // Console output. TINA4_LOG_OUTPUT="file" disables stdout entirely;
327
+ // anything else (stdout, both) prints to console in dev, suppresses in prod.
328
+ if (shouldLog && cfg.output !== "file" && !Log.isProduction()) {
219
329
  const color = COLORS[level];
220
330
  console.log(`${color}${humanLine}${RESET}`);
221
331
  }
222
332
 
223
- // File always gets ALL levels (raw log, no filtering), plain text (no ANSI)
224
- Log.writeToFile(humanLine);
333
+ // File output: always teed for dev (legacy behaviour), and either always
334
+ // (production default) or honoured per output mode.
335
+ //
336
+ // output=stdout (default): file in dev + prod (legacy parity)
337
+ // output=file file only — no console
338
+ // output=both file + console (already handled above)
339
+ //
340
+ // The "stdout-only without file" mode that some Python deployments want
341
+ // is gated on TINA4_LOG_OUTPUT=stdout combined with TINA4_LOG_FILE set
342
+ // explicitly to an empty string in env — we treat empty file as default.
343
+ const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
344
+ Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
225
345
  }
226
346
  }
@@ -162,6 +162,44 @@ export function isLocalhost(): boolean {
162
162
  return ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].includes(host);
163
163
  }
164
164
 
165
+ // ── MCP env config (Python parity) ───────────────────────────
166
+
167
+ /** Truthy check that mirrors `dotenv.isTruthy` without the import cycle. */
168
+ function envTruthy(val: string | undefined): boolean {
169
+ if (val == null) return false;
170
+ return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
171
+ }
172
+
173
+ /**
174
+ * Whether the built-in MCP server should auto-start.
175
+ *
176
+ * Default: `true` when `TINA4_DEBUG=true`, `false` otherwise. The `TINA4_MCP`
177
+ * env var can force either state explicitly. Matches the Python framework
178
+ * which only exposes MCP endpoints in dev mode by default.
179
+ */
180
+ export function mcpEnabled(): boolean {
181
+ const raw = process.env.TINA4_MCP;
182
+ if (raw === undefined || raw.trim() === "") {
183
+ return envTruthy(process.env.TINA4_DEBUG);
184
+ }
185
+ return envTruthy(raw);
186
+ }
187
+
188
+ /**
189
+ * Resolve the MCP HTTP port. Default: HTTP server port + 2000.
190
+ *
191
+ * `TINA4_MCP_PORT` overrides directly. The `mainPort` argument is the
192
+ * primary HTTP port (the framework passes `port` from `resolvePortAndHost`).
193
+ */
194
+ export function mcpPort(mainPort: number = 7148): number {
195
+ const raw = process.env.TINA4_MCP_PORT;
196
+ if (raw && raw.trim() !== "") {
197
+ const n = parseInt(raw, 10);
198
+ if (!isNaN(n) && n > 0) return n;
199
+ }
200
+ return mainPort + 2000;
201
+ }
202
+
165
203
  // ── McpServer class ──────────────────────────────────────────
166
204
 
167
205
  export class McpServer {
@@ -67,6 +67,8 @@ interface MessengerOptions {
67
67
  imapPort?: number;
68
68
  imapUser?: string;
69
69
  imapPass?: string;
70
+ /** IMAP transport security: "tls" (default), "starttls", or "none". */
71
+ imapEncryption?: string;
70
72
  }
71
73
 
72
74
  export interface ImapMessage {
@@ -300,6 +302,7 @@ export class Messenger {
300
302
  private imapPort: number;
301
303
  private imapUser: string;
302
304
  private imapPass: string;
305
+ private imapEncryption: string;
303
306
 
304
307
  constructor(options?: MessengerOptions) {
305
308
  // Priority: constructor > TINA4_MAIL_* > sensible default.
@@ -345,6 +348,22 @@ export class Messenger {
345
348
  this.imapPass = options?.imapPass
346
349
  ?? process.env.TINA4_MAIL_IMAP_PASSWORD
347
350
  ?? this.password;
351
+
352
+ // IMAP encryption — separate from SMTP encryption because IMAP almost
353
+ // always uses port 993 + implicit TLS while SMTP toggles between 587
354
+ // (STARTTLS) and 465 (implicit TLS). Default is "tls" to match
355
+ // industry-standard IMAPS port 993 behaviour.
356
+ this.imapEncryption = (options?.imapEncryption
357
+ ?? process.env.TINA4_MAIL_IMAP_ENCRYPTION
358
+ ?? "tls").toLowerCase();
359
+ }
360
+
361
+ /**
362
+ * Read-only IMAP encryption mode for inspection / tests.
363
+ * Returns one of "tls", "starttls", "none", "ssl".
364
+ */
365
+ getImapEncryption(): string {
366
+ return this.imapEncryption;
348
367
  }
349
368
 
350
369
  /**
@@ -570,7 +589,12 @@ export class Messenger {
570
589
 
571
590
  let socket: net.Socket | tls.TLSSocket;
572
591
 
573
- if (this.imapPort === 993) {
592
+ // Honour TINA4_MAIL_IMAP_ENCRYPTION when set; otherwise infer from port.
593
+ // "tls" / "ssl" → implicit TLS connect; anything else → plain connect.
594
+ const useTls = this.imapEncryption === "tls" || this.imapEncryption === "ssl"
595
+ || (this.imapEncryption === "" && this.imapPort === 993);
596
+
597
+ if (useTls) {
574
598
  socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: false });
575
599
  await new Promise<void>((resolve, reject) => {
576
600
  socket.once("secureConnect", resolve);
@@ -1,4 +1,16 @@
1
1
  import type { RouteHandler, RouteDefinition, RouteMeta, Middleware, Tina4Request, Tina4Response, WebSocketRouteHandler, WebSocketRouteDefinition } from "./types.js";
2
+ import { isTruthy } from "./dotenv.js";
3
+
4
+ /**
5
+ * Whether `TINA4_TRAILING_SLASH_REDIRECT` is enabled.
6
+ *
7
+ * Default: false. When true, a request to `/foo/` that has no exact match
8
+ * but matches `/foo` will be treated as a hit on `/foo` — callers can use
9
+ * the returned pattern to issue a 308 redirect (Python parity).
10
+ */
11
+ export function isTrailingSlashRedirectEnabled(): boolean {
12
+ return isTruthy(process.env.TINA4_TRAILING_SLASH_REDIRECT);
13
+ }
2
14
 
3
15
  interface MatchResult {
4
16
  handler: RouteHandler;
@@ -199,6 +211,11 @@ export class Router {
199
211
 
200
212
  /**
201
213
  * Match a request method + pathname to a registered route.
214
+ *
215
+ * When `TINA4_TRAILING_SLASH_REDIRECT=true` and the request path ends in
216
+ * a trailing slash that doesn't match a registered route, retry without
217
+ * the trailing slash. Returning the de-slashed pattern lets callers issue
218
+ * a 308 redirect instead of a hard 404 — Python parity.
202
219
  */
203
220
  match(method: string, path: string): MatchResult | null {
204
221
  const upperMethod = method.toUpperCase();
@@ -207,6 +224,28 @@ export class Router {
207
224
  const routes = this.routes.get(upperMethod);
208
225
  if (!routes) return null;
209
226
 
227
+ const direct = this.matchRoute(routes, path);
228
+ if (direct) return direct;
229
+
230
+ // Trailing-slash redirect — strip a single trailing "/" and retry.
231
+ // Root "/" is intentionally excluded (it's its own route).
232
+ if (
233
+ isTrailingSlashRedirectEnabled() &&
234
+ path.length > 1 &&
235
+ path.endsWith("/")
236
+ ) {
237
+ const stripped = path.replace(/\/+$/, "");
238
+ if (stripped.length > 0) {
239
+ const retry = this.matchRoute(routes, stripped);
240
+ if (retry) return retry;
241
+ }
242
+ }
243
+
244
+ return null;
245
+ }
246
+
247
+ /** Inner match against a list of compiled routes, no trailing-slash logic. */
248
+ private matchRoute(routes: CompiledRoute[], path: string): MatchResult | null {
210
249
  for (const route of routes) {
211
250
  const match = route.regex.exec(path);
212
251
  if (match) {
@@ -227,7 +266,6 @@ export class Router {
227
266
  };
228
267
  }
229
268
  }
230
-
231
269
  return null;
232
270
  }
233
271
 
@@ -15,7 +15,7 @@ import { createResponse, setDefaultTemplatesDir } from "./response.js";
15
15
  import { MiddlewareChain, cors, requestLogger } from "./middleware.js";
16
16
  import { tryServeStatic } from "./static.js";
17
17
  import { loadEnv, isTruthy } from "./dotenv.js";
18
- import { createHealthRoute } from "./health.js";
18
+ import { createHealthRoutes } from "./health.js";
19
19
  import { rateLimiter } from "./rateLimiter.js";
20
20
  import { Log } from "./logger.js";
21
21
  import { DevAdmin, RequestInspector } from "./devAdmin.js";
@@ -114,7 +114,7 @@ export function _checkLegacyEnvVars(): void {
114
114
  }
115
115
  lines.push(
116
116
  "",
117
- "Run `tina4 env-migrate` to rewrite your .env automatically,",
117
+ "Run `tina4 env --migrate` to rewrite your .env automatically,",
118
118
  "or rename manually. See https://tina4.com/release/3.12.0",
119
119
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
120
120
  bar,
@@ -198,17 +198,32 @@ function openBrowser(url: string) {
198
198
  /**
199
199
  * Resolve port and host with priority: explicit config > ENV var > default.
200
200
  * Exported for testability.
201
+ *
202
+ * Host resolution prefers `TINA4_HOST` (the framework-prefixed name —
203
+ * matches Python parity) and falls back to the unprefixed `HOST` env var
204
+ * for backwards compatibility.
201
205
  */
202
206
  export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } {
203
207
  const port = config?.port
204
208
  ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined)
205
209
  ?? 7148;
206
210
  const host = config?.host
211
+ ?? process.env.TINA4_HOST
207
212
  ?? process.env.HOST
208
213
  ?? "0.0.0.0";
209
214
  return { port, host };
210
215
  }
211
216
 
217
+ /**
218
+ * Whether the boot banner should be suppressed. Set TINA4_SUPPRESS=true to
219
+ * silence the ASCII-art banner and route table on startup — useful in CI,
220
+ * test runners, and embedded contexts where stdout is consumed by another
221
+ * process.
222
+ */
223
+ export function isBannerSuppressed(): boolean {
224
+ return isTruthy(process.env.TINA4_SUPPRESS);
225
+ }
226
+
212
227
  function isDevMode(): boolean {
213
228
  return isTruthy(process.env.TINA4_DEBUG);
214
229
  }
@@ -677,7 +692,8 @@ export async function startServer(config?: Tina4Config): Promise<{
677
692
  const reset = isTty ? "\x1b[0m" : "";
678
693
  const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
679
694
 
680
- console.log(`${color}
695
+ if (!isBannerSuppressed()) {
696
+ console.log(`${color}
681
697
  ______ _ __ __
682
698
  /_ __/(_)___ ____ _/ // /
683
699
  / / / / __ \\/ __ \`/ // /_
@@ -691,6 +707,7 @@ ${reset}
691
707
  Dashboard: http://localhost:${port}/__dev
692
708
  Debug: OFF (Log level: ${logLevel})
693
709
  `);
710
+ }
694
711
 
695
712
  for (let i = 0; i < numCPUs; i++) {
696
713
  cluster.fork();
@@ -737,9 +754,11 @@ ${reset}
737
754
  router.addRoute(route);
738
755
  }
739
756
 
740
- // Register health check endpoint
741
- const healthRoute = createHealthRoute(TINA4_VERSION);
742
- router.addRoute(healthRoute);
757
+ // Register health check endpoint(s). createHealthRoutes returns both the
758
+ // env-configured path (default /__health) and a /health legacy alias.
759
+ for (const healthRoute of createHealthRoutes(TINA4_VERSION)) {
760
+ router.addRoute(healthRoute);
761
+ }
743
762
 
744
763
  // Initialize Frond template engine
745
764
  let frondEngine: any = null;
@@ -837,9 +856,16 @@ ${reset}
837
856
  }
838
857
  }
839
858
 
840
- // Initialize Swagger
859
+ // Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled
860
+ // in debug mode, off in production). Loading the swagger module also
861
+ // pulls in route discovery for the generator, so skip the import entirely
862
+ // when disabled.
841
863
  try {
842
864
  const swagger = await import("../../swagger/src/index.js");
865
+ if (!swagger.swaggerEnabled()) {
866
+ // Skip the rest of the swagger block when disabled.
867
+ throw new Error("__swagger_disabled__");
868
+ }
843
869
  const allRoutes = router.getRoutes();
844
870
 
845
871
  // Collect model definitions for schema generation
@@ -889,9 +915,12 @@ ${reset}
889
915
 
890
916
  // Auto-start session — read cookie, create session, save + set cookie on response end
891
917
  {
892
- const { Session } = await import("./session.js");
918
+ const { Session, buildSessionCookie } = await import("./session.js");
893
919
  const cookieHeader = rawReq.headers.cookie ?? "";
894
- const sidMatch = cookieHeader.match(/tina4_session=([^;]+)/);
920
+ const cookieName = process.env.TINA4_SESSION_NAME ?? "tina4_session";
921
+ // Build a regex from the (possibly customised) cookie name. Escape regex meta-chars.
922
+ const escapedName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
923
+ const sidMatch = cookieHeader.match(new RegExp(`${escapedName}=([^;]+)`));
895
924
  const existingSid = sidMatch ? sidMatch[1] : undefined;
896
925
  const sess = new Session();
897
926
  sess.start(existingSid);
@@ -909,8 +938,7 @@ ${reset}
909
938
  const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
910
939
  if (newSid && newSid !== existingSid && !rawRes.headersSent) {
911
940
  const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
912
- const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
913
- rawRes.setHeader("Set-Cookie", `tina4_session=${newSid}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${ttl}`);
941
+ rawRes.setHeader("Set-Cookie", buildSessionCookie(newSid, ttl));
914
942
  }
915
943
  return origEnd(...args);
916
944
  } as typeof rawRes.end;
@@ -1234,7 +1262,8 @@ ${reset}
1234
1262
  ? `\n Test Port: http://localhost:${testPort} (stable — no hot-reload)`
1235
1263
  : "";
1236
1264
 
1237
- console.log(`${color}
1265
+ if (!isBannerSuppressed()) {
1266
+ console.log(`${color}
1238
1267
  ______ _ __ __
1239
1268
  /_ __/(_)___ ____ _/ // /
1240
1269
  / / / / __ \\/ __ \`/ // /_
@@ -1248,6 +1277,7 @@ ${reset}
1248
1277
  Dashboard: http://localhost:${port}/__dev
1249
1278
  Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})${dualPortLines}
1250
1279
  `);
1280
+ }
1251
1281
  const noBrowser = isTruthy(process.env.TINA4_NO_BROWSER);
1252
1282
  if (!noBrowser) {
1253
1283
  // Open browser on test port (hot-reload) if available, otherwise main port