tina4-nodejs 3.13.38 → 3.13.40

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,8 +67,9 @@ function escapeRegExp(s: string): string {
67
67
  /**
68
68
  * Top-level classes DEFINED in a source file. A test that references one of
69
69
  * these genuinely exercises this file. Classes only (distinctive PascalCase,
70
- * length > 3)module-level function names like `get`/`run`/`init` are too
71
- * generic to trust as a coverage signal.
70
+ * length > 2so short-but-real names like `ORM`/`Api`/`Log`/`Env` count)
71
+ * module-level function names like `get`/`run`/`init` are too generic to trust
72
+ * as a coverage signal.
72
73
  */
73
74
  function definedClasses(source: string): Set<string> {
74
75
  const names = new Set<string>();
@@ -76,7 +77,10 @@ function definedClasses(source: string): Set<string> {
76
77
  let m: RegExpExecArray | null;
77
78
  while ((m = re.exec(source)) !== null) {
78
79
  const name = m[1];
79
- if (name && !name.startsWith("_") && name.length > 3) {
80
+ // length > 2 (NOT > 3): a 3-char class like ORM/Api/Log/Env is a genuine,
81
+ // distinctive coverage signal — the old > 3 gate silently dropped them so a
82
+ // test that references `new Api()` / `Log.error()` left the file "untested".
83
+ if (name && !name.startsWith("_") && name.length > 2) {
80
84
  names.add(name);
81
85
  }
82
86
  }
@@ -98,8 +102,9 @@ function definedClasses(source: string): Set<string> {
98
102
  * 2. Import — a test that actually IMPORTS this module by its path
99
103
  * (`import … from ".../<m>.js"`, `require(".../<m>")`).
100
104
  * 3. Class reference — a test that references a top-level class DEFINED in
101
- * this file (distinctive PascalCase, length > 3). NO bare module-name word
102
- * match and NO guessed CamelCase-from-snake_case match.
105
+ * this file (distinctive PascalCase, length > 2 short-but-real names
106
+ * like ORM/Api/Log/Env count). NO bare module-name word match and NO
107
+ * guessed CamelCase-from-snake_case match.
103
108
  *
104
109
  * Returns true only on a real signal, so the "untested" offenders surfaced by
105
110
  * `tina4 metrics` and the dashboard "T" badge are trustworthy.
@@ -169,6 +174,11 @@ function hasMatchingTest(relPath: string): boolean {
169
174
  new RegExp(`import\\b[^;\\n]*?from\\s*${spec}`),
170
175
  // require("<spec>")
171
176
  new RegExp(`require\\s*\\(\\s*${spec}\\s*\\)`),
177
+ // dynamic / inline-type import("<spec>") — covers `await import("…/m.js")`
178
+ // AND the TS inline type position `import("…/m.ts").SomeType`. The full
179
+ // package path a test uses ("../packages/core/src/<m>.ts") matches as a
180
+ // SUFFIX via the leading `(?:[^"']*\/)?` in <spec>.
181
+ new RegExp(`import\\s*\\(\\s*${spec}\\s*\\)`),
172
182
  // side-effect import "<spec>"
173
183
  new RegExp(`import\\s*${spec}`),
174
184
  ];
@@ -248,6 +258,214 @@ function countLines(source: string): LineCounts {
248
258
  return { loc, blank, comment };
249
259
  }
250
260
 
261
+ // ── Literal / comment stripping ──────────────────────────────
262
+
263
+ /**
264
+ * Replace the CONTENTS of string literals, template literals (including
265
+ * interpolations), regex literals, and both comment styles with neutral
266
+ * placeholder characters (spaces), preserving newlines so line numbers and
267
+ * structure stay intact. The surrounding delimiters are kept.
268
+ *
269
+ * This is the regex-based stand-in for Python's AST: the decision-point
270
+ * patterns and the function-extraction patterns must only ever see real code,
271
+ * never text that happens to live inside a string, template, regex, line
272
+ * comment or block comment. Without this, a string full of boolean operators
273
+ * or a regex inflates complexity and yields bogus "functions".
274
+ *
275
+ * Regex-vs-division is resolved conservatively: a slash only starts a regex
276
+ * when the previous significant token can't end an expression (an operator,
277
+ * keyword, open bracket, comma, semicolon, etc.). When in doubt we treat the
278
+ * slash as division and DON'T strip — favouring "leave code intact" over
279
+ * "wrongly blank out a division", per the brief.
280
+ */
281
+ function stripLiterals(source: string): string {
282
+ const out: string[] = [];
283
+ const n = source.length;
284
+ let i = 0;
285
+
286
+ // The last non-whitespace, non-comment character we EMITTED as real code —
287
+ // used to decide whether a `/` opens a regex or is a division operator.
288
+ let prevSignificant = "";
289
+ // The last "word" token (identifier/keyword) emitted, for keyword checks.
290
+ let prevWord = "";
291
+
292
+ /** Keywords after which a `/` is a regex, not division. */
293
+ const regexKeywords = new Set([
294
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
295
+ "throw", "case", "do", "else", "yield", "await",
296
+ ]);
297
+
298
+ /** Can the previous significant token end an expression? If so, `/` = division. */
299
+ function prevEndsExpression(): boolean {
300
+ if (prevSignificant === "") return false; // start of input → regex
301
+ // Identifier/number ending char → could be a value → division …
302
+ if (/[A-Za-z0-9_$]/.test(prevSignificant)) {
303
+ // …unless it's a keyword like `return`/`case` that precedes a regex.
304
+ return !regexKeywords.has(prevWord);
305
+ }
306
+ // Closing brackets and these chars end an expression → division.
307
+ if (prevSignificant === ")" || prevSignificant === "]") return true;
308
+ // `.` (member access) ends an expression-ish context → division ( `a./` is odd, treat as div).
309
+ if (prevSignificant === ".") return true;
310
+ // Everything else (operators, `(`, `,`, `{`, `[`, `;`, `:`, `=`, `<`, `>`, `&`,
311
+ // `|`, `!`, `?`, `+`, `-`, `*`, `%`, `^`, `~`) → regex context.
312
+ return false;
313
+ }
314
+
315
+ while (i < n) {
316
+ const ch = source[i];
317
+ const next = i + 1 < n ? source[i + 1] : "";
318
+
319
+ // ── Line comment ──
320
+ if (ch === "/" && next === "/") {
321
+ out.push("//");
322
+ i += 2;
323
+ while (i < n && source[i] !== "\n") {
324
+ out.push(" ");
325
+ i++;
326
+ }
327
+ continue;
328
+ }
329
+
330
+ // ── Block comment ──
331
+ if (ch === "/" && next === "*") {
332
+ out.push("/*");
333
+ i += 2;
334
+ while (i < n && !(source[i] === "*" && source[i + 1] === "/")) {
335
+ out.push(source[i] === "\n" ? "\n" : " ");
336
+ i++;
337
+ }
338
+ if (i < n) {
339
+ out.push("*/");
340
+ i += 2;
341
+ }
342
+ continue;
343
+ }
344
+
345
+ // ── String literals ' " ──
346
+ if (ch === '"' || ch === "'") {
347
+ const quote = ch;
348
+ out.push(quote);
349
+ i++;
350
+ while (i < n && source[i] !== quote) {
351
+ if (source[i] === "\\" && i + 1 < n) {
352
+ out.push(" "); // blank the escape pair, stay 2 chars wide
353
+ i += 2;
354
+ continue;
355
+ }
356
+ if (source[i] === "\n") {
357
+ out.push("\n"); // unterminated string safety
358
+ i++;
359
+ break;
360
+ }
361
+ out.push(" ");
362
+ i++;
363
+ }
364
+ if (i < n && source[i] === quote) {
365
+ out.push(quote);
366
+ i++;
367
+ }
368
+ prevSignificant = quote;
369
+ prevWord = "";
370
+ continue;
371
+ }
372
+
373
+ // ── Template literals ` ` (with ${ ... } interpolation, recursively code) ──
374
+ if (ch === "`") {
375
+ out.push("`");
376
+ i++;
377
+ while (i < n && source[i] !== "`") {
378
+ if (source[i] === "\\" && i + 1 < n) {
379
+ out.push(source[i + 1] === "\n" ? " \n" : " ");
380
+ i += 2;
381
+ continue;
382
+ }
383
+ // Interpolation: ${ ... } — the inside IS real code, recurse on it.
384
+ if (source[i] === "$" && source[i + 1] === "{") {
385
+ out.push("${");
386
+ i += 2;
387
+ let depth = 1;
388
+ const exprStart = i;
389
+ while (i < n && depth > 0) {
390
+ if (source[i] === "{") depth++;
391
+ else if (source[i] === "}") depth--;
392
+ if (depth === 0) break;
393
+ i++;
394
+ }
395
+ // Strip literals INSIDE the interpolation too (handles nested strings/regex).
396
+ out.push(stripLiterals(source.slice(exprStart, i)));
397
+ if (i < n && source[i] === "}") {
398
+ out.push("}");
399
+ i++;
400
+ }
401
+ continue;
402
+ }
403
+ out.push(source[i] === "\n" ? "\n" : " ");
404
+ i++;
405
+ }
406
+ if (i < n && source[i] === "`") {
407
+ out.push("`");
408
+ i++;
409
+ }
410
+ prevSignificant = "`";
411
+ prevWord = "";
412
+ continue;
413
+ }
414
+
415
+ // ── Regex literal / vs division ──
416
+ if (ch === "/" && !prevEndsExpression()) {
417
+ // Scan a regex literal: /.../flags, honouring escapes and [...] classes.
418
+ let j = i + 1;
419
+ let ok = false;
420
+ let inClass = false;
421
+ while (j < n) {
422
+ const c = source[j];
423
+ if (c === "\\") {
424
+ j += 2;
425
+ continue;
426
+ }
427
+ if (c === "\n") break; // regex can't span a newline → not a regex
428
+ if (c === "[") inClass = true;
429
+ else if (c === "]") inClass = false;
430
+ else if (c === "/" && !inClass) {
431
+ ok = true;
432
+ break;
433
+ }
434
+ j++;
435
+ }
436
+ if (ok) {
437
+ out.push("/");
438
+ for (let k = i + 1; k < j; k++) out.push(" ");
439
+ out.push("/");
440
+ i = j + 1;
441
+ // consume flags
442
+ while (i < n && /[a-z]/i.test(source[i])) {
443
+ out.push(source[i]);
444
+ i++;
445
+ }
446
+ prevSignificant = "/"; // a regex value ends an expression-ish slot
447
+ prevWord = "";
448
+ continue;
449
+ }
450
+ // Not a regex — fall through, emit `/` as division.
451
+ }
452
+
453
+ // ── Ordinary code char ──
454
+ out.push(ch);
455
+ if (!/\s/.test(ch)) {
456
+ prevSignificant = ch;
457
+ if (/[A-Za-z0-9_$]/.test(ch)) {
458
+ prevWord = /[A-Za-z0-9_$]/.test(source[i - 1] ?? "") ? prevWord + ch : ch;
459
+ } else {
460
+ prevWord = "";
461
+ }
462
+ }
463
+ i++;
464
+ }
465
+
466
+ return out.join("");
467
+ }
468
+
251
469
  // ── Class & function counting (quick) ────────────────────────
252
470
 
253
471
  function countClassesQuick(source: string): number {
@@ -259,21 +477,24 @@ function countClassesQuick(source: string): number {
259
477
  }
260
478
 
261
479
  function countFunctionsQuick(source: string): number {
480
+ // Count on cleaned source so `something(...)` inside a string/regex/comment is
481
+ // never mistaken for a method (the chief source of the old over-count).
482
+ const clean = stripLiterals(source);
262
483
  let count = 0;
263
484
  // function declarations: function foo(, async function foo(, export function foo(
264
- const funcDecls = source.match(
485
+ const funcDecls = clean.match(
265
486
  /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+\w+\s*\(/g
266
487
  );
267
488
  if (funcDecls) count += funcDecls.length;
268
489
 
269
490
  // Method declarations inside classes: name(, async name(, static name(, get name(, set name(
270
- const methods = source.match(
491
+ const methods = clean.match(
271
492
  /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\w+\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/g
272
493
  );
273
494
  if (methods) count += methods.length;
274
495
 
275
496
  // Arrow functions assigned to const/let/var
276
- const arrows = source.match(
497
+ const arrows = clean.match(
277
498
  /(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?\(/g
278
499
  );
279
500
  if (arrows) count += arrows.length;
@@ -286,8 +507,12 @@ function countFunctionsQuick(source: string): number {
286
507
  function cycloMaticComplexity(funcBody: string): number {
287
508
  let cc = 1;
288
509
 
289
- // Count decision points via regex
290
- // if statements (not inside strings ideally, but regex-based is approximate)
510
+ // Decision points must be counted on REAL code only — strip string/template/
511
+ // regex literals and comments first so `&&`/`if`/`? :` inside data don't inflate
512
+ // the count (matches the intent of Python's AST-based analyzer).
513
+ const body = stripLiterals(funcBody);
514
+
515
+ // Count decision points via regex.
291
516
  const patterns: [RegExp, number][] = [
292
517
  [/\bif\s*\(/g, 1],
293
518
  [/\belse\s+if\s*\(/g, 1],
@@ -304,7 +529,7 @@ function cycloMaticComplexity(funcBody: string): number {
304
529
  ];
305
530
 
306
531
  for (const [pattern, weight] of patterns) {
307
- const matches = funcBody.match(pattern);
532
+ const matches = body.match(pattern);
308
533
  if (matches) cc += matches.length * weight;
309
534
  }
310
535
 
@@ -322,94 +547,129 @@ interface FunctionInfo {
322
547
  file?: string;
323
548
  }
324
549
 
550
+ /**
551
+ * Reserved words that look like `keyword(...)` (a call/control-flow head) but are
552
+ * NEVER a function declaration. Guards the loose class-method pattern from
553
+ * extracting `if (...)`, `for (...)`, `return (...)`, `await foo()` etc. as
554
+ * "functions" (the bogus-name source).
555
+ */
556
+ const NON_FUNCTION_WORDS = new Set([
557
+ "if", "for", "while", "switch", "catch", "return", "new", "class", "import",
558
+ "export", "from", "do", "else", "typeof", "instanceof", "in", "of", "void",
559
+ "delete", "await", "yield", "throw", "super", "this", "function", "const",
560
+ "let", "var", "async", "static", "public", "private", "protected", "get",
561
+ "set", "type", "interface", "enum", "extends", "implements", "as", "case",
562
+ "default", "with", "debugger",
563
+ ]);
564
+
325
565
  function extractFunctions(source: string, filePath: string, root: string = "."): FunctionInfo[] {
326
566
  const functions: FunctionInfo[] = [];
327
- const lines = source.split("\n");
328
-
329
- // Patterns to match function/method declarations
330
- const patterns = [
331
- // function name(args) or async function name(args)
332
- /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/,
333
- // Class method: name(args) { or async name(args) {
334
- /(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{/,
335
- // Arrow: const name = (args) => or const name = async (args) =>
336
- /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/,
337
- ];
567
+ // Detect/extract on LITERAL-STRIPPED source only — a `word(...)` or a bogus
568
+ // name like "name"/"if" living inside a string/template/regex/comment is now
569
+ // blanked out, so it can never be mistaken for a declaration. Newlines are
570
+ // preserved, so line numbers and the brace-matched body stay accurate.
571
+ const lines = stripLiterals(source).split("\n");
572
+
573
+ // Patterns anchored at the START of the trimmed line so a mid-line call can
574
+ // never match. Only real declaration shapes are accepted.
575
+ // 1) function name(args) / async function name(args) / export …
576
+ const fnDecl = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*\(([^)]*)\)/;
577
+ // 2) Arrow assigned to a binding: const name = (args) => / = async (args) =>
578
+ const arrowDecl = /^(?:export\s+)?(?:default\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/;
579
+ // 3) A class member: optional modifiers then name(args) … { — only trusted
580
+ // when we're inside a class body, OR a modifier keyword is present.
581
+ const methodMod = /^(?:(public|private|protected)\s+)?(?:(static)\s+)?(?:(async)\s+)?(?:(get|set)\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{;]+)?\s*\{/;
338
582
 
339
583
  // Track which class we're in
340
584
  let currentClass: string | null = null;
341
585
 
342
586
  for (let i = 0; i < lines.length; i++) {
343
- const line = lines[i];
344
- const stripped = line.trim();
587
+ const stripped = lines[i].trim();
345
588
 
346
589
  // Detect class entry
347
590
  const classMatch = stripped.match(
348
- /(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/
591
+ /^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)/
349
592
  );
350
593
  if (classMatch) {
351
594
  currentClass = classMatch[1];
352
595
  }
353
596
 
354
- for (const pattern of patterns) {
355
- const match = stripped.match(pattern);
356
- if (match && match[1]) {
357
- const funcName = match[1];
597
+ let funcName: string | null = null;
598
+ let argsStr = "";
599
+ let isTopLevelDecl = false; // function/arrow at module/top scope (not class-qualified)
600
+
601
+ // 1) function declaration
602
+ let m = stripped.match(fnDecl);
603
+ if (m) {
604
+ funcName = m[1];
605
+ argsStr = m[2] || "";
606
+ isTopLevelDecl = true;
607
+ }
358
608
 
359
- // Skip keywords that look like function calls
609
+ // 2) arrow binding
610
+ if (funcName === null) {
611
+ m = stripped.match(arrowDecl);
612
+ if (m) {
613
+ funcName = m[1];
614
+ argsStr = m[2] || "";
615
+ isTopLevelDecl = true;
616
+ }
617
+ }
618
+
619
+ // 3) class method (only with a modifier, or while inside a class)
620
+ if (funcName === null) {
621
+ m = stripped.match(methodMod);
622
+ if (m) {
623
+ const hasModifier = !!(m[1] || m[2] || m[3] || m[4]);
624
+ const candidate = m[5];
625
+ // Accept only a genuine member: needs a modifier OR an enclosing class.
626
+ // Never accept a reserved control-flow / declaration keyword.
360
627
  if (
361
- ["if", "for", "while", "switch", "catch", "return", "new", "class", "import", "export", "from", "constructor"].includes(funcName) &&
362
- !stripped.includes("constructor")
628
+ candidate &&
629
+ !NON_FUNCTION_WORDS.has(candidate) &&
630
+ (hasModifier || currentClass !== null)
363
631
  ) {
364
- if (funcName !== "constructor") continue;
632
+ funcName = candidate;
633
+ argsStr = m[6] || "";
365
634
  }
635
+ }
636
+ }
366
637
 
367
- // Handle constructor specifically
368
- const displayName =
369
- funcName === "constructor" && currentClass
370
- ? `${currentClass}.constructor`
371
- : currentClass && !stripped.startsWith("function") &&
372
- !stripped.startsWith("export function") &&
373
- !stripped.startsWith("async function") &&
374
- !stripped.startsWith("export async function") &&
375
- !stripped.startsWith("const ") &&
376
- !stripped.startsWith("let ") &&
377
- !stripped.startsWith("var ") &&
378
- !stripped.startsWith("export const ") &&
379
- !stripped.startsWith("export let ")
638
+ if (funcName !== null && !NON_FUNCTION_WORDS.has(funcName)) {
639
+ // Constructor / class-qualified display name.
640
+ const displayName =
641
+ funcName === "constructor" && currentClass
642
+ ? `${currentClass}.constructor`
643
+ : currentClass !== null && !isTopLevelDecl
380
644
  ? `${currentClass}.${funcName}`
381
645
  : funcName;
382
646
 
383
- // Extract function body by brace matching
384
- const funcBody = extractFunctionBody(lines, i);
385
- const funcLoc = funcBody.split("\n").length;
386
- const complexity = cycloMaticComplexity(funcBody);
387
-
388
- // Parse args
389
- const argsStr = match[2] || "";
390
- const args = argsStr
391
- .split(",")
392
- .map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim())
393
- .filter((a) => a && a !== "this");
394
-
395
- functions.push({
396
- name: displayName,
397
- line: i + 1,
398
- complexity,
399
- loc: funcLoc,
400
- args,
401
- file: relativePath(filePath, root),
402
- });
403
-
404
- break; // Only match first pattern per line
405
- }
647
+ // Extract function body by brace matching (on cleaned lines).
648
+ const funcBody = extractFunctionBody(lines, i);
649
+ const funcLoc = funcBody.split("\n").length;
650
+ const complexity = cycloMaticComplexity(funcBody);
651
+
652
+ // Parse args
653
+ const args = argsStr
654
+ .split(",")
655
+ .map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim())
656
+ .filter((a) => a && a !== "this");
657
+
658
+ functions.push({
659
+ name: displayName,
660
+ line: i + 1,
661
+ complexity,
662
+ loc: funcLoc,
663
+ args,
664
+ file: relativePath(filePath, root),
665
+ });
406
666
  }
407
667
 
408
668
  // Detect class exit (simple heuristic: closing brace at column 0)
409
669
  if (
410
670
  currentClass &&
411
671
  stripped === "}" &&
412
- line.match(/^\}/) // brace at start of line
672
+ /^\}/.test(lines[i]) // brace at start of line
413
673
  ) {
414
674
  currentClass = null;
415
675
  }
@@ -345,7 +345,7 @@ export class CorsMiddleware {
345
345
  const allowedHeaders = process.env.TINA4_CORS_HEADERS
346
346
  ?? "Content-Type,Authorization,X-Request-ID";
347
347
 
348
- const credentials = process.env.TINA4_CORS_CREDENTIALS ?? "true";
348
+ const credentials = process.env.TINA4_CORS_CREDENTIALS ?? "false";
349
349
 
350
350
  const maxAge = process.env.TINA4_CORS_MAX_AGE
351
351
  ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10)
@@ -9,6 +9,11 @@
9
9
  * TINA4_KAFKA_BROKERS (override; default: "localhost:9092")
10
10
  * TINA4_KAFKA_GROUP_ID (default: "tina4_consumer_group")
11
11
  *
12
+ * TLS/SASL (each read as TINA4_KAFKA_<NAME> first, then bare KAFKA_<NAME>):
13
+ * TINA4_KAFKA_SECURITY_PROTOCOL — e.g. SSL / SASL_SSL (default: PLAINTEXT)
14
+ * TINA4_KAFKA_SSL_CA_LOCATION — CA cert path for TLS brokers/proxies
15
+ * TINA4_KAFKA_SASL_MECHANISM / TINA4_KAFKA_SASL_USERNAME / TINA4_KAFKA_SASL_PASSWORD — optional SASL
16
+ *
12
17
  * Precedence for brokers: specific TINA4_KAFKA_BROKERS var (if set)
13
18
  * > value derived from TINA4_QUEUE_URL > existing default.
14
19
  */
@@ -24,6 +29,62 @@ export interface KafkaConfig {
24
29
  groupId?: string;
25
30
  }
26
31
 
32
+ /**
33
+ * librdkafka-style SSL/SASL client config (a TLS broker/proxy). Mirrors the
34
+ * keys produced by Python's `KafkaConnector._security_config`. Every key is
35
+ * optional — an unset env var leaves the key OUT (librdkafka defaults to the
36
+ * PLAINTEXT protocol with no SASL).
37
+ */
38
+ export interface KafkaSecurityConfig {
39
+ "security.protocol"?: string;
40
+ "ssl.ca.location"?: string;
41
+ "sasl.mechanism"?: string;
42
+ "sasl.username"?: string;
43
+ "sasl.password"?: string;
44
+ }
45
+
46
+ /** Resolved producer/consumer config — brokers, client id, and security keys. */
47
+ export interface KafkaClientConfig extends KafkaSecurityConfig {
48
+ "bootstrap.servers": string;
49
+ "client.id": string;
50
+ "group.id"?: string;
51
+ "auto.offset.reset"?: string;
52
+ "enable.auto.commit"?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Build the SSL/SASL client config from the environment (for a TLS broker or
57
+ * proxy in front of Kafka). Each setting is read from the Tina4-namespaced env
58
+ * var FIRST (`TINA4_KAFKA_SECURITY_PROTOCOL` …) and falls back to the bare
59
+ * librdkafka-convention name (`KAFKA_SECURITY_PROTOCOL` …) that many Kafka
60
+ * deployments already set. Honours security.protocol (e.g. SSL, SASL_SSL),
61
+ * ssl.ca.location, and optional SASL (mechanism / username / password). Unset
62
+ * keys are omitted so librdkafka keeps its PLAINTEXT defaults.
63
+ *
64
+ * Exported for testing/introspection — and exact parity with Python's
65
+ * `_security_config` (same key set, same precedence, same omit-when-unset).
66
+ */
67
+ export function kafkaSecurityConfig(
68
+ env: NodeJS.ProcessEnv = process.env
69
+ ): KafkaSecurityConfig {
70
+ // rdkafka key -> env suffix (read as TINA4_KAFKA_<suffix>, then KAFKA_<suffix>)
71
+ const mapping: [keyof KafkaSecurityConfig, string][] = [
72
+ ["security.protocol", "SECURITY_PROTOCOL"],
73
+ ["ssl.ca.location", "SSL_CA_LOCATION"],
74
+ ["sasl.mechanism", "SASL_MECHANISM"],
75
+ ["sasl.username", "SASL_USERNAME"],
76
+ ["sasl.password", "SASL_PASSWORD"],
77
+ ];
78
+ const config: KafkaSecurityConfig = {};
79
+ for (const [rdk, suffix] of mapping) {
80
+ const value = env[`TINA4_KAFKA_${suffix}`] || env[`KAFKA_${suffix}`];
81
+ if (value) {
82
+ config[rdk] = value;
83
+ }
84
+ }
85
+ return config;
86
+ }
87
+
27
88
  export interface QueueBackend {
28
89
  push(queue: string, payload: unknown, delay?: number): string;
29
90
  pop(queue: string): QueueJob | null;
@@ -77,6 +138,42 @@ export class KafkaBackend implements QueueBackend {
77
138
  return { brokers: this.brokers, groupId: this.groupId };
78
139
  }
79
140
 
141
+ /**
142
+ * Resolved SSL/SASL client config from the environment (PLAINTEXT default).
143
+ * Mirrors Python's `KafkaConnector._security_config`.
144
+ */
145
+ securityConfig(): KafkaSecurityConfig {
146
+ return kafkaSecurityConfig();
147
+ }
148
+
149
+ /**
150
+ * Full producer config — brokers + client id + the resolved security block.
151
+ * The security keys are applied to BOTH producer and consumer (matching
152
+ * Python's `_connect_confluent`).
153
+ */
154
+ producerConfig(): KafkaClientConfig {
155
+ return {
156
+ "bootstrap.servers": this.brokers,
157
+ "client.id": "tina4-nodejs",
158
+ ...this.securityConfig(),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Full consumer config — brokers + client id + group id + the SAME resolved
164
+ * security block applied to the producer.
165
+ */
166
+ consumerConfig(): KafkaClientConfig {
167
+ return {
168
+ "bootstrap.servers": this.brokers,
169
+ "client.id": "tina4-nodejs",
170
+ "group.id": this.groupId,
171
+ "auto.offset.reset": "earliest",
172
+ "enable.auto.commit": false,
173
+ ...this.securityConfig(),
174
+ };
175
+ }
176
+
80
177
  /**
81
178
  * Parse broker string into host:port.
82
179
  */