tina4-nodejs 3.13.36 → 3.13.38

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.
Files changed (50) hide show
  1. package/CLAUDE.md +51 -19
  2. package/package.json +5 -3
  3. package/packages/cli/src/bin.ts +7 -0
  4. package/packages/cli/src/commands/init.ts +1 -0
  5. package/packages/cli/src/commands/metrics.ts +154 -0
  6. package/packages/cli/src/commands/routes.ts +3 -3
  7. package/packages/core/public/js/tina4-dev-admin.js +212 -212
  8. package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +75 -26
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +14 -8
  18. package/packages/core/src/logger.ts +1 -1
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/messenger.ts +111 -11
  21. package/packages/core/src/metrics.ts +232 -33
  22. package/packages/core/src/middleware.ts +129 -39
  23. package/packages/core/src/plan.ts +1 -1
  24. package/packages/core/src/queue.ts +1 -1
  25. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  26. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  27. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  28. package/packages/core/src/rateLimiter.ts +1 -1
  29. package/packages/core/src/response.ts +90 -6
  30. package/packages/core/src/router.ts +2 -2
  31. package/packages/core/src/server.ts +26 -4
  32. package/packages/core/src/session.ts +130 -18
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  34. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  35. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  36. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  37. package/packages/core/src/testClient.ts +1 -1
  38. package/packages/core/src/websocket.ts +247 -33
  39. package/packages/core/src/websocketBackplane.ts +210 -10
  40. package/packages/core/src/wsdl.ts +55 -21
  41. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  42. package/packages/orm/src/adapters/postgres.ts +26 -4
  43. package/packages/orm/src/adapters/sqlite.ts +112 -13
  44. package/packages/orm/src/baseModel.ts +8 -3
  45. package/packages/orm/src/cachedDatabase.ts +15 -6
  46. package/packages/orm/src/database.ts +257 -55
  47. package/packages/orm/src/index.ts +2 -1
  48. package/packages/orm/src/migration.ts +2 -2
  49. package/packages/orm/src/seeder.ts +443 -65
  50. package/packages/swagger/src/ui.ts +1 -1
@@ -12,7 +12,117 @@
12
12
  * checkPassword("secret123", hash); // true
13
13
  */
14
14
  import { createHmac, createSign, createVerify, pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
15
17
  import type { Middleware, Tina4Request, Tina4Response } from "./types.js";
18
+ import { isTruthy } from "./dotenv.js";
19
+
20
+ // ── Blank-secret warning + dev-secret bootstrap ────────────────────
21
+ //
22
+ // Mirrors the Python master (tina4_python/auth/__init__.py):
23
+ // • The default signing secret stays BLANK — never a guessable built-in.
24
+ // • In DEV (not CI, not production) a blank secret is auto-generated once and
25
+ // persisted to a gitignored .env.local, so a local dev never has to be told
26
+ // what to set. INFO, not a warning.
27
+ // • In CI / production a blank secret keeps the loud, ACTIONABLE warning.
28
+
29
+ /** Actionable blank-secret warning — emitted from both the bootstrap (CI/prod) and the lazy resolvers. */
30
+ const BLANK_SECRET_WARNING =
31
+ "Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET to a random " +
32
+ "value (e.g. `openssl rand -hex 32`) in your environment or .env before serving traffic.";
33
+
34
+ /** True when running under CI — the de-facto `CI` env var (set by every major CI). */
35
+ function _isCi(): boolean {
36
+ return isTruthy(process.env.CI);
37
+ }
38
+
39
+ /** True in development — the framework debug flag is truthy (e.g. TINA4_DEBUG=true). */
40
+ function _isDev(): boolean {
41
+ return isTruthy(process.env.TINA4_DEBUG);
42
+ }
43
+
44
+ /** True when TINA4_ENV is explicitly "production". */
45
+ function _isProduction(): boolean {
46
+ return (process.env.TINA4_ENV ?? "development") === "production";
47
+ }
48
+
49
+ /** INFO log via Tina4 Log when available, else stderr. (Local import avoids a load-time cycle.) */
50
+ async function _logInfo(message: string): Promise<void> {
51
+ try {
52
+ const { Log } = await import("./logger.js");
53
+ Log.info(message);
54
+ } catch {
55
+ process.stderr.write(message + "\n");
56
+ }
57
+ }
58
+
59
+ /** WARNING log via Tina4 Log when available, else stderr. */
60
+ async function _logWarning(message: string): Promise<void> {
61
+ try {
62
+ const { Log } = await import("./logger.js");
63
+ Log.warning(message);
64
+ } catch {
65
+ process.stderr.write(message + "\n");
66
+ }
67
+ }
68
+
69
+ /** Emit the shared, actionable blank-secret warning (used by CI/prod bootstrap + lazy resolvers). */
70
+ function _warnBlankSecret(): void {
71
+ void _logWarning(BLANK_SECRET_WARNING);
72
+ }
73
+
74
+ /**
75
+ * Ensure a usable TINA4_SECRET exists. Run ONCE at server boot, after env load
76
+ * and before auth is used. Mirrors Python's `ensure_dev_secret()`.
77
+ *
78
+ * Order:
79
+ * 1. TINA4_SECRET already set → no-op (return null).
80
+ * 2. NOT dev, OR CI, OR production → emit the actionable warning, return null.
81
+ * NEVER generates or persists a secret in CI / production / non-dev.
82
+ * 3. Otherwise (dev, not CI, not prod, blank secret) → generate a 32-byte hex
83
+ * secret, set it in process.env for THIS run immediately, then try to append
84
+ * it to <cwd>/.env.local (create if missing; never touch .env). On a write
85
+ * failure keep the in-memory secret and warn — boot must never crash.
86
+ *
87
+ * @param cwd - Directory to write .env.local into. Tests pass a temp dir; production passes nothing.
88
+ * @returns The newly-generated secret, or null when nothing was generated.
89
+ */
90
+ export function ensureDevSecret(cwd?: string): string | null {
91
+ if (process.env.TINA4_SECRET) return null; // already configured
92
+
93
+ // Only the dev-and-not-CI-and-not-production path may generate / persist.
94
+ if (!_isDev() || _isCi() || _isProduction()) {
95
+ _warnBlankSecret();
96
+ return null;
97
+ }
98
+
99
+ // 32 bytes hex = 64 hex chars (parity with Python's secrets.token_hex(32)).
100
+ const newSecret = randomBytes(32).toString("hex");
101
+ // Set immediately so it's available for this run even if the write fails.
102
+ process.env.TINA4_SECRET = newSecret;
103
+
104
+ const baseDir = cwd ?? process.cwd();
105
+ const envLocalPath = join(baseDir, ".env.local");
106
+ try {
107
+ // If the file exists and its content doesn't end in a newline, prepend one
108
+ // so the new key lands on its own line.
109
+ let prefix = "";
110
+ if (existsSync(envLocalPath)) {
111
+ const existing = readFileSync(envLocalPath, "utf-8");
112
+ if (existing.length > 0 && !existing.endsWith("\n")) prefix = "\n";
113
+ }
114
+ appendFileSync(envLocalPath, `${prefix}TINA4_SECRET=${newSecret}\n`);
115
+ void _logInfo("Auth: generated a development secret, saved to .env.local (gitignored)");
116
+ } catch {
117
+ // Keep the in-memory secret for this run; warn but never crash boot.
118
+ void _logWarning(
119
+ "Auth: generated a development secret but could not write .env.local — " +
120
+ "using it in-memory for this run only.",
121
+ );
122
+ }
123
+
124
+ return newSecret;
125
+ }
16
126
 
17
127
  // ── Base64url helpers (RFC 7515) ──────────────────────────────────
18
128
 
@@ -58,7 +168,7 @@ export function getToken(
58
168
  }
59
169
 
60
170
  if (!resolvedSecret) {
61
- console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
171
+ _warnBlankSecret();
62
172
  }
63
173
  const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
64
174
 
@@ -95,7 +205,7 @@ export function getToken(
95
205
  export function validToken(token: string, secret?: string, algorithm?: string): Record<string, unknown> | null {
96
206
  const resolvedSecret = secret ?? process.env.TINA4_SECRET ?? "";
97
207
  if (!resolvedSecret) {
98
- console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
208
+ _warnBlankSecret();
99
209
  }
100
210
  const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
101
211
  try {
@@ -247,7 +247,7 @@ class RespClient {
247
247
  private username: string | null;
248
248
  private password: string | null;
249
249
  private sock: net.Socket | null = null;
250
- private buffer = Buffer.alloc(0);
250
+ private buffer: Buffer = Buffer.alloc(0);
251
251
  private waiters: Array<{ resolve: (r: RespReply) => void; reject: (e: Error) => void }> = [];
252
252
  private connecting: Promise<void> | null = null;
253
253
  private connected = false;
@@ -662,7 +662,7 @@ class MemcachedClient {
662
662
  private host: string;
663
663
  private port: number;
664
664
  private sock: net.Socket | null = null;
665
- private buffer = Buffer.alloc(0);
665
+ private buffer: Buffer = Buffer.alloc(0);
666
666
  private pending: { terminator: string; resolve: (s: string) => void } | null = null;
667
667
  private connecting: Promise<void> | null = null;
668
668
  private connected = false;
@@ -1041,8 +1041,16 @@ const handleTables: RouteHandler = async (_req, res) => {
1041
1041
 
1042
1042
  const handleSeed: RouteHandler = async (req, res) => {
1043
1043
  const url = new URL(req.url ?? "/", "http://localhost");
1044
- const table = url.searchParams.get("table") ?? (req as any).body?.table ?? "";
1045
- const count = parseInt(String(url.searchParams.get("count") ?? (req as any).body?.count ?? "10"), 10) || 10;
1044
+ const body = (req as any).body ?? {};
1045
+ const table = url.searchParams.get("table") ?? body?.table ?? "";
1046
+ const count = parseInt(String(url.searchParams.get("count") ?? body?.count ?? "10"), 10) || 10;
1047
+ // P4b — accept seed/clear/strict; drop the previous hard-coded behaviour.
1048
+ const seedRaw = url.searchParams.get("seed") ?? body?.seed;
1049
+ const seed = seedRaw !== undefined && seedRaw !== null && String(seedRaw) !== ""
1050
+ ? (Number.isNaN(parseInt(String(seedRaw), 10)) ? undefined : parseInt(String(seedRaw), 10))
1051
+ : undefined;
1052
+ const clear = String(url.searchParams.get("clear") ?? body?.clear ?? "") === "true" || body?.clear === true;
1053
+ const strict = String(url.searchParams.get("strict") ?? body?.strict ?? "") === "true" || body?.strict === true;
1046
1054
  if (!table) {
1047
1055
  res.json({ error: "Missing table parameter" });
1048
1056
  return;
@@ -1051,43 +1059,36 @@ const handleSeed: RouteHandler = async (req, res) => {
1051
1059
  const orm = await import("@tina4/orm");
1052
1060
  const db = orm.getAdapter();
1053
1061
  const { seedTable } = orm;
1054
- const fake = new orm.FakeData();
1062
+ // A shared FakeData seeds the RNG so a `seed` makes the run reproducible.
1063
+ const fake = new orm.FakeData(seed);
1055
1064
  const columns = db.columns(table);
1056
1065
  if (!columns.length) {
1057
1066
  res.json({ error: `Table '${table}' not found or has no columns` });
1058
1067
  return;
1059
1068
  }
1060
- // Build a field map based on column info
1069
+ // Build a field map based on column info (skip auto-increment/id PKs).
1061
1070
  const fieldMap: Record<string, () => unknown> = {};
1062
1071
  for (const col of columns) {
1063
1072
  const name = col.name.toLowerCase();
1064
1073
  const type = col.type.toLowerCase();
1065
- if (name === "id") continue; // skip primary key
1074
+ if (name === "id" || (col as any).primaryKey === true) continue; // skip primary key
1066
1075
  if (name.includes("email")) { fieldMap[col.name] = () => fake.email(); }
1067
1076
  else if (name.includes("name")) { fieldMap[col.name] = () => fake.name(); }
1068
1077
  else if (name.includes("phone")) { fieldMap[col.name] = () => fake.phone(); }
1069
1078
  else if (name.includes("address") || name.includes("city") || name.includes("country")) { fieldMap[col.name] = () => fake.address(); }
1070
1079
  else if (name.includes("url") || name.includes("website")) { fieldMap[col.name] = () => fake.url(); }
1071
1080
  else if (type.includes("int")) { fieldMap[col.name] = () => fake.integer(1, 1000); }
1072
- else if (type.includes("real") || type.includes("float") || type.includes("double") || type.includes("numeric") || type.includes("decimal")) { fieldMap[col.name] = () => fake.decimal(0, 1000, 2); }
1081
+ else if (type.includes("real") || type.includes("float") || type.includes("double") || type.includes("numeric") || type.includes("decimal")) { fieldMap[col.name] = () => fake.numeric(0, 1000, 2); }
1073
1082
  else if (type.includes("bool")) { fieldMap[col.name] = () => fake.boolean(); }
1074
1083
  else if (type.includes("date") || type.includes("time")) { fieldMap[col.name] = () => fake.date(); }
1075
1084
  else { fieldMap[col.name] = () => fake.sentence(3); }
1076
1085
  }
1077
- let inserted = 0;
1078
- for (let i = 0; i < count; i++) {
1079
- const row: Record<string, unknown> = {};
1080
- for (const [col, gen] of Object.entries(fieldMap)) {
1081
- row[col] = gen();
1082
- }
1083
- try {
1084
- db.insert(table, row);
1085
- inserted++;
1086
- } catch { /* skip failed rows */ }
1087
- }
1088
- res.json({ inserted, table });
1089
- } catch {
1090
- res.json({ error: "Database not connected" });
1086
+ // P1 delegate to the shared seedTable so each row is wrapped (no
1087
+ // unhandled failure can crash the endpoint) and we get a summary back.
1088
+ const summary = await seedTable(db, table, count, fieldMap, undefined, { clear, seed, strict });
1089
+ res.json({ seeded: summary.seeded, failed: summary.failed, errors: summary.errors, table });
1090
+ } catch (e) {
1091
+ res.json({ error: (e as Error)?.message ?? "Database not connected" });
1091
1092
  }
1092
1093
  };
1093
1094
 
@@ -1495,20 +1496,24 @@ const handleConnectionsTest: RouteHandler = async (req, res) => {
1495
1496
  } catch { tableCount = 0; }
1496
1497
  try {
1497
1498
  const urlLower = url.toLowerCase();
1499
+ // NOTE: db.execute() is async; these calls are intentionally left
1500
+ // un-awaited to preserve the exact existing runtime behaviour during
1501
+ // this type-only cleanup. `row` is therefore a Promise and the `as any`
1502
+ // access below evaluates to the fallback string. See report open question.
1498
1503
  if (urlLower.includes("sqlite")) {
1499
- const row = db.execute("SELECT sqlite_version() as v") as Record<string, unknown>[] | undefined;
1504
+ const row = db.execute("SELECT sqlite_version() as v");
1500
1505
  version = `SQLite ${(row as any)?.[0]?.v ?? ""}`;
1501
1506
  } else if (urlLower.includes("postgres")) {
1502
- const row = db.execute("SELECT version() as v") as Record<string, unknown>[] | undefined;
1507
+ const row = db.execute("SELECT version() as v");
1503
1508
  version = ((row as any)?.[0]?.v ?? "PostgreSQL").toString().split(",")[0];
1504
1509
  } else if (urlLower.includes("mysql")) {
1505
- const row = db.execute("SELECT version() as v") as Record<string, unknown>[] | undefined;
1510
+ const row = db.execute("SELECT version() as v");
1506
1511
  version = `MySQL ${(row as any)?.[0]?.v ?? ""}`;
1507
1512
  } else if (urlLower.includes("mssql")) {
1508
- const row = db.execute("SELECT @@VERSION as v") as Record<string, unknown>[] | undefined;
1513
+ const row = db.execute("SELECT @@VERSION as v");
1509
1514
  version = ((row as any)?.[0]?.v ?? "MSSQL").toString().split("\n")[0];
1510
1515
  } else if (urlLower.includes("firebird")) {
1511
- const row = db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database") as Record<string, unknown>[] | undefined;
1516
+ const row = db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database");
1512
1517
  version = `Firebird ${(row as any)?.[0]?.v ?? ""}`;
1513
1518
  }
1514
1519
  } catch { /* keep version as Connected */ }
@@ -1904,6 +1909,49 @@ const handleFiles: RouteHandler = async (req, res) => {
1904
1909
  res.json({ path: relative(root, target).replace(/\\/g, "/") || ".", branch, entries });
1905
1910
  };
1906
1911
 
1912
+ // Canonical extension→language map. Kept identical in coverage to the Python
1913
+ // master (tina4_python/tina4_python/dev_admin/__init__.py `lang_map`) and the
1914
+ // PHP/Ruby file-read endpoints. The dev-admin SPA maps the returned "language"
1915
+ // string to a CodeMirror grammar for syntax highlighting.
1916
+ const DEV_ADMIN_LANG_MAP: Record<string, string> = {
1917
+ ".py": "python", ".php": "php", ".rb": "ruby",
1918
+ ".ts": "typescript", ".js": "javascript", ".jsx": "javascript",
1919
+ ".tsx": "typescript", ".json": "json", ".html": "html",
1920
+ ".twig": "html", ".css": "css", ".scss": "css",
1921
+ ".md": "markdown", ".sql": "sql", ".yaml": "yaml",
1922
+ ".yml": "yaml", ".toml": "toml", ".xml": "html",
1923
+ ".env": "env", ".env.example": "env",
1924
+ ".sh": "shell", ".bash": "shell",
1925
+ ".bat": "shell", ".cmd": "shell", ".ps1": "shell",
1926
+ ".rs": "rust", ".go": "go", ".java": "java",
1927
+ ".txt": "text", ".csv": "text", ".log": "text",
1928
+ ".gemspec": "ruby", ".rake": "ruby",
1929
+ ".svg": "svg",
1930
+ };
1931
+
1932
+ /**
1933
+ * Resolve a CodeMirror-friendly language id from a file path's basename.
1934
+ *
1935
+ * - `Dockerfile` / `Dockerfile.dev` / `Dockerfile.prod` (no extension) → "dockerfile"
1936
+ * - `.env.example` (two-part) and `.env` → "env"
1937
+ * - otherwise the file extension is looked up in DEV_ADMIN_LANG_MAP
1938
+ * - anything unknown → "text"
1939
+ */
1940
+ export function devAdminLanguage(rel: string): string {
1941
+ const base = (rel.split(/[\\/]/).pop() ?? "").toLowerCase();
1942
+ if (base === "dockerfile" || base === "dockerfile.dev" || base === "dockerfile.prod") {
1943
+ return "dockerfile";
1944
+ }
1945
+ // Two-part extension first (e.g. ".env.example"), then the single extension.
1946
+ if (base.endsWith(".env.example")) return DEV_ADMIN_LANG_MAP[".env.example"];
1947
+ const dot = base.lastIndexOf(".");
1948
+ // A leading dot with no other dot is a dotfile name, not an extension
1949
+ // (e.g. ".env" → ext ".env"); only treat as "no extension" when there's no dot.
1950
+ if (dot < 0) return "text";
1951
+ const ext = base.slice(dot);
1952
+ return DEV_ADMIN_LANG_MAP[ext] ?? "text";
1953
+ }
1954
+
1907
1955
  const handleFileRead: RouteHandler = (req, res) => {
1908
1956
  const url = new URL(req.url ?? "/", "http://localhost");
1909
1957
  const rel = url.searchParams.get("path") ?? "";
@@ -1915,7 +1963,8 @@ const handleFileRead: RouteHandler = (req, res) => {
1915
1963
  }
1916
1964
  try {
1917
1965
  const content = readFileSync(target, "utf-8");
1918
- res.json({ path: relative(root, target), content, bytes: Buffer.byteLength(content, "utf-8") });
1966
+ const path = relative(root, target);
1967
+ res.json({ path, content, language: devAdminLanguage(path), bytes: Buffer.byteLength(content, "utf-8") });
1919
1968
  } catch (e) {
1920
1969
  res.json({ error: (e as Error).message }, 500);
1921
1970
  }
@@ -296,6 +296,10 @@ export function createMessenger(): Messenger | DevMailbox {
296
296
  const debug = process.env.TINA4_DEBUG;
297
297
  const smtpHost = process.env.TINA4_MAIL_HOST;
298
298
 
299
+ // Production = NOT debug mode AND NODE_ENV is "production".
300
+ // Derived here (was previously referenced undefined → ReferenceError).
301
+ const isProd = !isTruthy(debug) && process.env.NODE_ENV === "production";
302
+
299
303
  // Force dev mode when TINA4_DEBUG is truthy
300
304
  if (isTruthy(debug)) {
301
305
  return new DevMailbox();
@@ -74,17 +74,26 @@ function parseEnvContent(content: string): Record<string, string> {
74
74
 
75
75
  /**
76
76
  * Load environment variables from a .env file into process.env.
77
- * Does not override existing process.env values unless they are undefined.
77
+ *
78
+ * By default does NOT override existing process.env values — it is first-wins:
79
+ * a key is only set if it is not already present. This is how real env vars
80
+ * always win. To get the precedence real-env > `.env.local` > `.env`, load
81
+ * `.env.local` FIRST then `.env`, both with override=false (the default): the
82
+ * real env (already present) wins over both, `.env.local` fills local-only keys,
83
+ * and `.env` fills the rest. Do NOT load `.env.local` with override=true — that
84
+ * would let a stray gitignored `.env.local` clobber an explicitly set real env
85
+ * var (e.g. a production TINA4_SECRET).
78
86
  *
79
87
  * Resolution order for the env file path:
80
88
  * 1. Explicit `path` argument
81
89
  * 2. `TINA4_ENV_FILE` env var (if set and non-empty)
82
90
  * 3. `.env` in the current working directory
83
91
  *
84
- * @param path - Path to the .env file. Optional override.
92
+ * @param path - Path to the .env file. Optional override.
93
+ * @param override - When true, overwrite keys already present in process.env.
85
94
  * @returns The parsed key-value pairs, or an empty object if the file doesn't exist.
86
95
  */
87
- export function loadEnv(path?: string): Record<string, string> {
96
+ export function loadEnv(path?: string, override = false): Record<string, string> {
88
97
  const fromEnv = (process.env.TINA4_ENV_FILE ?? "").trim();
89
98
  const target = path ?? (fromEnv.length > 0 ? fromEnv : ".env");
90
99
  const envPath = resolve(target);
@@ -97,7 +106,7 @@ export function loadEnv(path?: string): Record<string, string> {
97
106
  const parsed = parseEnvContent(content);
98
107
 
99
108
  for (const [key, value] of Object.entries(parsed)) {
100
- if (process.env[key] === undefined) {
109
+ if (override || process.env[key] === undefined) {
101
110
  process.env[key] = value;
102
111
  _loadedKeys.push(key);
103
112
  }
@@ -11,6 +11,8 @@
11
11
  * Events.once("app.ready", () => console.log("App started!"));
12
12
  */
13
13
 
14
+ import { Log } from "./logger.js";
15
+
14
16
  interface ListenerEntry {
15
17
  priority: number;
16
18
  callback: (...args: unknown[]) => void;
@@ -19,6 +21,50 @@ interface ListenerEntry {
19
21
 
20
22
  const _listeners: Map<string, ListenerEntry[]> = new Map();
21
23
 
24
+ /**
25
+ * Log a listener error — NEVER silent. Mirrors Python's _log_listener_error:
26
+ * routes through the Tina4 Log (warning) with BOTH the event name and the
27
+ * error type + message. The log call is itself wrapped so a broken logger
28
+ * can't break the event bus — on any logger failure it falls back to
29
+ * console.error so the error is still surfaced.
30
+ */
31
+ function logListenerError(event: string, error: unknown): void {
32
+ const err = error as { name?: string; message?: string };
33
+ const type = err?.name ?? (error as object)?.constructor?.name ?? "Error";
34
+ const message = err?.message ?? String(error);
35
+ try {
36
+ Log.warning(`Event listener for '${event}' raised ${type}: ${message}`);
37
+ } catch {
38
+ try {
39
+ console.error(`Event listener for '${event}' raised ${type}: ${message}`);
40
+ } catch {
41
+ /* a broken console can't break the bus either */
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Mirror Python's keyword-only `strict` param within JS's positional-args
48
+ * model. If the FIRST emit argument is a plain options object carrying a
49
+ * boolean `strict`, it is consumed as the option and the remaining args are
50
+ * the event payload; otherwise every arg is treated as payload (so the
51
+ * common `emit("evt", a, b)` call is unchanged). Listeners never see the
52
+ * options object.
53
+ */
54
+ function parseEmitArgs(rest: unknown[]): { strict: boolean; args: unknown[] } {
55
+ const first = rest[0];
56
+ if (
57
+ first !== null &&
58
+ typeof first === "object" &&
59
+ !Array.isArray(first) &&
60
+ Object.prototype.hasOwnProperty.call(first, "strict") &&
61
+ typeof (first as { strict?: unknown }).strict === "boolean"
62
+ ) {
63
+ return { strict: (first as { strict: boolean }).strict, args: rest.slice(1) };
64
+ }
65
+ return { strict: false, args: rest };
66
+ }
67
+
22
68
  function getEntries(event: string): ListenerEntry[] {
23
69
  let entries = _listeners.get(event);
24
70
  if (!entries) {
@@ -68,8 +114,24 @@ export class Events {
68
114
 
69
115
  /**
70
116
  * Fire an event synchronously. Returns array of listener results.
117
+ *
118
+ * Listener isolation (E1): each listener call is wrapped — a listener
119
+ * that THROWS does NOT abort the rest of emit(). The error is LOGGED
120
+ * (never silent) and the failed listener contributes a `null` slot, so
121
+ * N listeners always yield N results in priority order; surviving
122
+ * listeners run regardless of an earlier throw.
123
+ *
124
+ * Pass `{ strict: true }` to RE-RAISE on the first listener error
125
+ * instead of isolating it (later listeners then do NOT run).
126
+ *
127
+ * once() cleanup stays correct under isolation: the one-shot listener is
128
+ * spliced out BEFORE its callback runs, so a throw never leaves it
129
+ * registered.
71
130
  */
72
- static emit(event: string, ...args: unknown[]): unknown[] {
131
+ static emit(event: string, ...args: unknown[]): unknown[];
132
+ static emit(event: string, options: { strict?: boolean }, ...args: unknown[]): unknown[];
133
+ static emit(event: string, ...rest: unknown[]): unknown[] {
134
+ const { strict, args } = parseEmitArgs(rest);
73
135
  const entries = _listeners.get(event);
74
136
  if (!entries) return [];
75
137
 
@@ -81,7 +143,13 @@ export class Events {
81
143
  const idx = entries.indexOf(entry);
82
144
  if (idx !== -1) entries.splice(idx, 1);
83
145
  }
84
- results.push(entry.callback(...args));
146
+ try {
147
+ results.push(entry.callback(...args));
148
+ } catch (error) {
149
+ if (strict) throw error;
150
+ logListenerError(event, error);
151
+ results.push(null);
152
+ }
85
153
  }
86
154
 
87
155
  return results;
@@ -90,8 +158,16 @@ export class Events {
90
158
  /**
91
159
  * Emit an event and await all async listeners.
92
160
  * Returns array of resolved results from each listener.
161
+ *
162
+ * Listener isolation (E1): identical to emit() — each awaited listener
163
+ * is isolated; a rejection/throw is LOGGED and contributes a `null`
164
+ * slot without aborting the others. `{ strict: true }` re-raises on the
165
+ * first error.
93
166
  */
94
- static async emitAsync(event: string, ...args: unknown[]): Promise<unknown[]> {
167
+ static async emitAsync(event: string, ...args: unknown[]): Promise<unknown[]>;
168
+ static async emitAsync(event: string, options: { strict?: boolean }, ...args: unknown[]): Promise<unknown[]>;
169
+ static async emitAsync(event: string, ...rest: unknown[]): Promise<unknown[]> {
170
+ const { strict, args } = parseEmitArgs(rest);
95
171
  const entries = _listeners.get(event);
96
172
  if (!entries) return [];
97
173
 
@@ -103,7 +179,13 @@ export class Events {
103
179
  const idx = entries.indexOf(entry);
104
180
  if (idx !== -1) entries.splice(idx, 1);
105
181
  }
106
- results.push(await entry.callback(...args));
182
+ try {
183
+ results.push(await entry.callback(...args));
184
+ } catch (error) {
185
+ if (strict) throw error;
186
+ logListenerError(event, error);
187
+ results.push(null);
188
+ }
107
189
  }
108
190
 
109
191
  return results;