tina4-nodejs 3.13.2 → 3.13.3

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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.2)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.3)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.2 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.3 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.2",
6
+ "version": "3.13.3",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Typed environment-variable helpers — zero-deps.
3
+ *
4
+ * Reading env vars by hand gets old fast: every boolean flag becomes a
5
+ * `(process.env.X ?? "false").toLowerCase() === "true" || ...` incantation,
6
+ * every numeric tuning knob needs a parseInt + isNaN guard. `Env` centralises
7
+ * that. Same API across all four Tina4 frameworks (`Tina4\Env` in PHP,
8
+ * `Tina4::Env` in Ruby, `Env` in Python).
9
+ *
10
+ * import { Env } from "@tina4/core";
11
+ *
12
+ * const debug = Env.bool("TINA4_DEBUG"); // default false
13
+ * const workers = Env.int("WORKERS", 4);
14
+ * const rate = Env.float("RATE_LIMIT", 10.0);
15
+ * const region = Env.str("AWS_REGION", "us-east-1");
16
+ *
17
+ * Values are accepted case-insensitively after `.trim().toLowerCase()`. Truthy:
18
+ * `1 / true / on / yes / y / t`. Falsy: `0 / false / off / no / n / f / ""`.
19
+ * Anything else returns `default`. Unparseable ints/floats log a warning via
20
+ * `Log` (when available) and fall back to `default` — never throw.
21
+ */
22
+ const TRUTHY = new Set(["1", "true", "on", "yes", "y", "t"]);
23
+ const FALSY = new Set(["0", "false", "off", "no", "n", "f", ""]);
24
+
25
+ /**
26
+ * Emit a warning via Log without creating a circular import at module load.
27
+ * Log itself depends on env parsing, so we resolve it lazily and swallow any
28
+ * "not yet wired" errors (very early bootstrap, ESM cycle, etc.).
29
+ */
30
+ function logWarning(message: string): void {
31
+ try {
32
+ // Lazy import to avoid the env → logger → env cycle.
33
+ // Top-level await isn't usable here (sync API), so we fire-and-forget.
34
+ import("./logger.js")
35
+ .then((mod) => {
36
+ try {
37
+ mod.Log.warn(message);
38
+ } catch {
39
+ /* Log not ready — skip */
40
+ }
41
+ })
42
+ .catch(() => {
43
+ /* Module not yet loadable — skip */
44
+ });
45
+ } catch {
46
+ /* Defensive — never let logging break the caller */
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Typed environment-variable helpers.
52
+ *
53
+ * All methods are static so callers can write `Env.bool(...)` without
54
+ * instantiating anything — matching the Python/PHP/Ruby ports.
55
+ */
56
+ export class Env {
57
+ /**
58
+ * Read `name` and coerce to bool.
59
+ *
60
+ * Truthy values (case-insensitive after trim): `1`, `true`, `on`, `yes`,
61
+ * `y`, `t`. Falsy: `0`, `false`, `off`, `no`, `n`, `f`, empty string.
62
+ * Anything else returns the `defaultValue` — never throws.
63
+ */
64
+ static bool(name: string, defaultValue = false): boolean {
65
+ const raw = process.env[name];
66
+ if (raw === undefined) return defaultValue;
67
+ const token = raw.trim().toLowerCase();
68
+ if (TRUTHY.has(token)) return true;
69
+ if (FALSY.has(token)) return false;
70
+ return defaultValue;
71
+ }
72
+
73
+ /** Read `name` and coerce to int. Returns `defaultValue` on parse failure. */
74
+ static int(name: string, defaultValue = 0): number {
75
+ const raw = process.env[name];
76
+ if (raw === undefined) return defaultValue;
77
+ const parsed = Number.parseInt(raw.trim(), 10);
78
+ if (Number.isNaN(parsed)) {
79
+ logWarning(
80
+ `Env.int(${JSON.stringify(name)}): could not parse ${JSON.stringify(raw)} as int — using default ${defaultValue}`,
81
+ );
82
+ return defaultValue;
83
+ }
84
+ return parsed;
85
+ }
86
+
87
+ /** Read `name` and coerce to float. Returns `defaultValue` on parse failure. */
88
+ static float(name: string, defaultValue = 0.0): number {
89
+ const raw = process.env[name];
90
+ if (raw === undefined) return defaultValue;
91
+ const parsed = Number.parseFloat(raw.trim());
92
+ if (Number.isNaN(parsed)) {
93
+ logWarning(
94
+ `Env.float(${JSON.stringify(name)}): could not parse ${JSON.stringify(raw)} as float — using default ${defaultValue}`,
95
+ );
96
+ return defaultValue;
97
+ }
98
+ return parsed;
99
+ }
100
+
101
+ /**
102
+ * Read `name` as a string. Returns `defaultValue` if unset.
103
+ *
104
+ * Whitespace is preserved — this is a pass-through for the raw env value.
105
+ * `Env.str("PATH")` is exactly `process.env.PATH ?? ""` with a more
106
+ * discoverable name.
107
+ */
108
+ static str(name: string, defaultValue = ""): string {
109
+ const raw = process.env[name];
110
+ if (raw === undefined) return defaultValue;
111
+ return raw;
112
+ }
113
+ }
@@ -24,6 +24,7 @@ export { createRequest } from "./request.js";
24
24
  export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFrond, getFrameworkFrond } from "./response.js";
25
25
  export { tryServeStatic } from "./static.js";
26
26
  export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
27
+ export { Env } from "./env.js";
27
28
  export { Log } from "./logger.js";
28
29
  export { createHealthRoute, createHealthRoutes, healthPath } from "./health.js";
29
30
  export { rateLimiter } from "./rateLimiter.js";
@@ -28,9 +28,67 @@ interface LogEntry {
28
28
  level: LogLevel;
29
29
  message: string;
30
30
  request_id?: string;
31
+ function?: string;
31
32
  context?: unknown;
32
33
  }
33
34
 
35
+ /**
36
+ * Log frames we walk past when looking for the real caller of a Log.* call.
37
+ * Mirrors Python's `_OWN_FRAMES`. Anything matching is treated as internal.
38
+ */
39
+ const OWN_FRAMES = new Set<string>([
40
+ "log", "Log.log",
41
+ "callerName", "Log.callerName",
42
+ "info", "debug", "warning", "warn", "error", "critical",
43
+ "Log.info", "Log.debug", "Log.warning", "Log.warn", "Log.error", "Log.critical",
44
+ ]);
45
+
46
+ /** V8 stack-trace markers that mean "no real function name". */
47
+ const ANON_NAMES = new Set<string>(["", "anonymous", "<anonymous>"]);
48
+
49
+ /**
50
+ * Return the function name that called Log.{info,debug,warning,error}.
51
+ *
52
+ * Active only when `TINA4_LOG_FUNC=true` — captures `new Error().stack`,
53
+ * walks past Log's own frames (info / warn / error / debug / log /
54
+ * callerName) and returns the first user function name. Anonymous frames
55
+ * (`anonymous`, `<anonymous>`, bare file paths) are filtered out as noise.
56
+ * Returns undefined on any error — never throws. Parity feature #41 across
57
+ * all four Tina4 frameworks.
58
+ */
59
+ function callerName(): string | undefined {
60
+ // Read the env directly (not via Env.bool) to keep the logger ↔ env cycle
61
+ // one-way: env helpers depend on Log, not the other way around.
62
+ const raw = (process.env.TINA4_LOG_FUNC ?? "").trim().toLowerCase();
63
+ if (raw !== "1" && raw !== "true" && raw !== "on" && raw !== "yes" && raw !== "y" && raw !== "t") {
64
+ return undefined;
65
+ }
66
+ try {
67
+ const stack = new Error().stack;
68
+ if (!stack) return undefined;
69
+ const lines = stack.split("\n");
70
+ // Skip line 0 ("Error") and walk frames. Cap at 32 to defend against
71
+ // pathological recursion / wrapper stacks.
72
+ for (let i = 1; i < lines.length && i < 32; i++) {
73
+ const line = lines[i];
74
+ // V8 frame: " at functionName (file:line:col)"
75
+ // or " at file:line:col" (anonymous)
76
+ // or " at async functionName (...)"
77
+ const m = line.match(/^\s+at\s+(?:async\s+)?([^\s(]+)\s*\(/);
78
+ if (!m) continue;
79
+ const name = m[1];
80
+ // Strip the leading `Object.` / class prefix some V8s emit, then check.
81
+ const bare = name.includes(".") ? name.split(".").pop()! : name;
82
+ if (OWN_FRAMES.has(name) || OWN_FRAMES.has(bare)) continue;
83
+ if (ANON_NAMES.has(bare)) continue;
84
+ return bare;
85
+ }
86
+ return undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
34
92
  /** ANSI color codes for terminal output */
35
93
  const COLORS: Record<LogLevel, string> = {
36
94
  DEBUG: "\x1b[36m", // cyan
@@ -308,6 +366,14 @@ export class Log {
308
366
  entry.request_id = Log.requestId;
309
367
  }
310
368
 
369
+ // Caller-name injection — opt-in via TINA4_LOG_FUNC=true. Off by default
370
+ // so existing log output stays byte-identical for users who haven't asked
371
+ // for it. Parity feature across all four Tina4 frameworks.
372
+ const fnName = callerName();
373
+ if (fnName) {
374
+ entry.function = fnName;
375
+ }
376
+
311
377
  if (data !== undefined) {
312
378
  entry.context = data;
313
379
  }
@@ -315,8 +381,9 @@ export class Log {
315
381
  // Build human-readable line
316
382
  const paddedLevel = level.padEnd(8);
317
383
  const reqPart = Log.requestId ? ` [${Log.requestId}]` : "";
384
+ const fnPart = fnName ? ` [${fnName}]` : "";
318
385
  const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
319
- const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart} ${message}${dataPart}`;
386
+ const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${message}${dataPart}`;
320
387
 
321
388
  // Build the file-format line based on TINA4_LOG_FORMAT
322
389
  const fileLine = cfg.format === "json" ? JSON.stringify(entry) : humanLine;