tina4-nodejs 3.13.37 → 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.
Files changed (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  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 +66 -44
  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 +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. package/packages/swagger/src/ui.ts +1 -1
@@ -20,6 +20,9 @@
20
20
  * - Error capture (resolver exceptions become GraphQL errors)
21
21
  */
22
22
 
23
+ import { Log } from "./logger.js";
24
+ import { isDebugMode } from "./errorOverlay.js";
25
+
23
26
  // ── Types ────────────────────────────────────────────────────
24
27
 
25
28
  export interface GraphQLField {
@@ -115,6 +118,19 @@ interface ParsedField {
115
118
  selections: ParsedSelection[] | null;
116
119
  }
117
120
 
121
+ interface ParsedFragmentSpread {
122
+ kind: "fragment_spread";
123
+ name: string;
124
+ directives: ParsedDirective[];
125
+ }
126
+
127
+ interface ParsedInlineFragment {
128
+ kind: "inline_fragment";
129
+ on: string | null;
130
+ directives: ParsedDirective[];
131
+ selections: ParsedSelection[];
132
+ }
133
+
118
134
  interface ParsedDirective {
119
135
  name: string;
120
136
  args: Record<string, unknown>;
@@ -129,13 +145,22 @@ interface ParsedOperation {
129
145
  selections: ParsedSelection[];
130
146
  }
131
147
 
148
+ interface ParsedFragment {
149
+ kind: "fragment";
150
+ name: string;
151
+ on: string;
152
+ directives: ParsedDirective[];
153
+ selections: ParsedSelection[];
154
+ }
155
+
132
156
  interface ParsedVariableDef {
133
157
  name: string;
134
158
  type: string;
135
159
  default: unknown;
136
160
  }
137
161
 
138
- type ParsedSelection = ParsedField;
162
+ type ParsedSelection = ParsedField | ParsedFragmentSpread | ParsedInlineFragment;
163
+ type ParsedDefinition = ParsedOperation | ParsedFragment;
139
164
 
140
165
  class Parser {
141
166
  private tokens: Token[];
@@ -174,14 +199,38 @@ class Parser {
174
199
  return null;
175
200
  }
176
201
 
177
- parse(): { definitions: ParsedOperation[] } {
178
- const doc: { definitions: ParsedOperation[] } = { definitions: [] };
202
+ parse(): { definitions: ParsedDefinition[] } {
203
+ const doc: { definitions: ParsedDefinition[] } = { definitions: [] };
179
204
  while (this.pos < this.tokens.length) {
180
- doc.definitions.push(this.parseOperation());
205
+ doc.definitions.push(this.parseDefinition());
181
206
  }
182
207
  return doc;
183
208
  }
184
209
 
210
+ private parseDefinition(): ParsedDefinition {
211
+ const t = this.peek();
212
+ if (t && t.type === "NAME" && t.value === "fragment") {
213
+ return this.parseFragment();
214
+ }
215
+ return this.parseOperation();
216
+ }
217
+
218
+ private parseFragment(): ParsedFragment {
219
+ this.expect("NAME", "fragment");
220
+ const name = this.expect("NAME").value;
221
+ this.expect("NAME", "on");
222
+ const typeName = this.expect("NAME").value;
223
+ const directives = this.parseDirectives();
224
+ const selections = this.parseSelectionSet();
225
+ return {
226
+ kind: "fragment",
227
+ name,
228
+ on: typeName,
229
+ directives,
230
+ selections,
231
+ };
232
+ }
233
+
185
234
  private parseOperation(): ParsedOperation {
186
235
  const t = this.peek();
187
236
  let opType = "query";
@@ -216,7 +265,29 @@ class Parser {
216
265
  this.expect("LBRACE");
217
266
  const selections: ParsedSelection[] = [];
218
267
  while (!this.match("RBRACE")) {
219
- selections.push(this.parseField());
268
+ if (this.match("SPREAD")) {
269
+ const next = this.peek();
270
+ if (next && next.type === "NAME" && next.value === "on") {
271
+ // Inline fragment: ... on Type { ... }
272
+ this.advance();
273
+ const typeName = this.expect("NAME").value;
274
+ const directives = this.parseDirectives();
275
+ const sels = this.parseSelectionSet();
276
+ selections.push({ kind: "inline_fragment", on: typeName, directives, selections: sels });
277
+ } else if (next && next.type === "LBRACE") {
278
+ // Inline fragment without type condition: ... { ... }
279
+ const directives = this.parseDirectives();
280
+ const sels = this.parseSelectionSet();
281
+ selections.push({ kind: "inline_fragment", on: null, directives, selections: sels });
282
+ } else {
283
+ // Fragment spread: ...FragmentName
284
+ const name = this.expect("NAME").value;
285
+ const directives = this.parseDirectives();
286
+ selections.push({ kind: "fragment_spread", name, directives });
287
+ }
288
+ } else {
289
+ selections.push(this.parseField());
290
+ }
220
291
  }
221
292
  return selections;
222
293
  }
@@ -397,6 +468,18 @@ export function graphqlAutoSchemaEnabled(): boolean {
397
468
  return ["true", "1", "yes", "on"].includes(raw);
398
469
  }
399
470
 
471
+ /**
472
+ * Maximum selection-set nesting depth. A deeply nested query (or a circular
473
+ * fragment) would otherwise recurse without bound — a classic GraphQL DoS /
474
+ * stack-overflow vector. `TINA4_GRAPHQL_MAX_DEPTH` overrides the default (50);
475
+ * set `<= 0` to disable the guard. A non-numeric value falls back to 50.
476
+ */
477
+ export function graphqlMaxDepth(): number {
478
+ const raw = (process.env.TINA4_GRAPHQL_MAX_DEPTH ?? "50").trim();
479
+ const n = parseInt(raw, 10);
480
+ return Number.isNaN(n) ? 50 : n;
481
+ }
482
+
400
483
  // ── GraphQL Engine ───────────────────────────────────────────
401
484
 
402
485
  export class GraphQL {
@@ -416,6 +499,14 @@ export class GraphQL {
416
499
  private static classResolvers = new Map<string, Map<string, ResolverFn>>();
417
500
  private static defaultInstance: GraphQL | null = null;
418
501
 
502
+ /**
503
+ * Maximum selection-set nesting depth (read from `TINA4_GRAPHQL_MAX_DEPTH`,
504
+ * default 50; `<= 0` disables the guard). Public so tests can set it and the
505
+ * dev app can introspect it. Counted per selection level AND per fragment
506
+ * spread / inline fragment, so circular fragments are caught too.
507
+ */
508
+ public maxDepth: number = graphqlMaxDepth();
509
+
419
510
  /**
420
511
  * Decorator-style resolver registration.
421
512
  *
@@ -527,21 +618,6 @@ export class GraphQL {
527
618
  return this;
528
619
  }
529
620
 
530
- /**
531
- * Return schema metadata for debugging.
532
- */
533
- introspect(): Record<string, unknown> {
534
- const queries: Record<string, unknown> = {};
535
- for (const [name, config] of Object.entries(this.queries)) {
536
- queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
537
- }
538
- const mutations: Record<string, unknown> = {};
539
- for (const [name, config] of Object.entries(this.mutations)) {
540
- mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
541
- }
542
- return { types: Object.keys(this.types), queries, mutations };
543
- }
544
-
545
621
  /**
546
622
  * Register a query resolver.
547
623
  */
@@ -555,21 +631,6 @@ export class GraphQL {
555
631
  return this;
556
632
  }
557
633
 
558
- /**
559
- * Return schema metadata for debugging.
560
- */
561
- introspect(): Record<string, unknown> {
562
- const queries: Record<string, unknown> = {};
563
- for (const [name, config] of Object.entries(this.queries)) {
564
- queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
565
- }
566
- const mutations: Record<string, unknown> = {};
567
- for (const [name, config] of Object.entries(this.mutations)) {
568
- mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
569
- }
570
- return { types: Object.keys(this.types), queries, mutations };
571
- }
572
-
573
634
  /**
574
635
  * Register a mutation resolver.
575
636
  */
@@ -583,21 +644,6 @@ export class GraphQL {
583
644
  return this;
584
645
  }
585
646
 
586
- /**
587
- * Return schema metadata for debugging.
588
- */
589
- introspect(): Record<string, unknown> {
590
- const queries: Record<string, unknown> = {};
591
- for (const [name, config] of Object.entries(this.queries)) {
592
- queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
593
- }
594
- const mutations: Record<string, unknown> = {};
595
- for (const [name, config] of Object.entries(this.mutations)) {
596
- mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
597
- }
598
- return { types: Object.keys(this.types), queries, mutations };
599
- }
600
-
601
647
  /**
602
648
  * Execute a GraphQL query string.
603
649
  */
@@ -606,7 +652,7 @@ export class GraphQL {
606
652
  const ctx = context ?? {};
607
653
  const errors: Array<{ message: string; path?: string[] }> = [];
608
654
 
609
- let doc: { definitions: ParsedOperation[] };
655
+ let doc: { definitions: ParsedDefinition[] };
610
656
  try {
611
657
  const tokens = tokenize(query);
612
658
  const parser = new Parser(tokens);
@@ -616,11 +662,23 @@ export class GraphQL {
616
662
  return { data: null, errors: [{ message }] };
617
663
  }
618
664
 
619
- if (doc.definitions.length === 0) {
665
+ // Split definitions into operations and fragments (fragments are named
666
+ // and referenced by spreads, so collect them first).
667
+ const fragments: Map<string, ParsedFragment> = new Map();
668
+ const operations: ParsedOperation[] = [];
669
+ for (const defn of doc.definitions) {
670
+ if (defn.kind === "fragment") {
671
+ fragments.set(defn.name, defn);
672
+ } else {
673
+ operations.push(defn);
674
+ }
675
+ }
676
+
677
+ if (operations.length === 0) {
620
678
  return { data: null, errors: [{ message: "No operation found" }] };
621
679
  }
622
680
 
623
- const op = doc.definitions[0];
681
+ const op = operations[0];
624
682
  const resolvers = op.operation === "query" ? this.queries : this.mutations;
625
683
 
626
684
  // Apply variable defaults
@@ -631,16 +689,9 @@ export class GraphQL {
631
689
  }
632
690
 
633
691
  const data: Record<string, unknown> = {};
634
-
635
- for (const sel of op.selections) {
636
- // Check directives (@skip, @include, @auth, @role, @guest)
637
- if (!this.checkDirectives(sel.directives ?? [], vars, ctx)) continue;
638
-
639
- const [value, errs] = this.resolveField(sel, resolvers, null, vars, ctx);
640
- errors.push(...errs);
641
- const key = sel.alias ?? sel.name;
642
- data[key] = value;
643
- }
692
+ // Top-level selections start at depth 1.
693
+ const errs = this.resolveSelectionsInto(op.selections, resolvers, null, vars, ctx, fragments, data, 1);
694
+ errors.push(...errs);
644
695
 
645
696
  const result: GraphQLResult = { data };
646
697
  if (errors.length > 0) {
@@ -650,55 +701,68 @@ export class GraphQL {
650
701
  }
651
702
 
652
703
  /**
653
- * Return schema metadata for debugging.
704
+ * Resolve a list of selections and merge results into the `target` dict.
705
+ * Fragment spreads and inline fragments are merged (not nested).
706
+ *
707
+ * `depth` is incremented on every recursive entry (field sub-selections,
708
+ * fragment spreads, inline fragments) and checked against `maxDepth` so an
709
+ * over-deep query or a circular fragment fails with a structured error
710
+ * instead of recursing until the interpreter stack overflows. Top-level
711
+ * starts at depth 1; `maxDepth <= 0` disables the guard.
654
712
  */
655
- introspect(): Record<string, unknown> {
656
- const queries: Record<string, unknown> = {};
657
- for (const [name, config] of Object.entries(this.queries)) {
658
- queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
659
- }
660
- const mutations: Record<string, unknown> = {};
661
- for (const [name, config] of Object.entries(this.mutations)) {
662
- mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
713
+ private resolveSelectionsInto(
714
+ selections: ParsedSelection[],
715
+ resolvers: Map<string, QueryConfig>,
716
+ parent: unknown,
717
+ variables: Record<string, unknown>,
718
+ context: Record<string, unknown>,
719
+ fragments: Map<string, ParsedFragment>,
720
+ target: Record<string, unknown>,
721
+ depth: number,
722
+ ): Array<{ message: string; path?: string[] }> {
723
+ const errors: Array<{ message: string; path?: string[] }> = [];
724
+
725
+ if (this.maxDepth > 0 && depth > this.maxDepth) {
726
+ return [{ message: `Query exceeds maximum depth of ${this.maxDepth}` }];
663
727
  }
664
- return { types: Object.keys(this.types), queries, mutations };
665
- }
666
728
 
729
+ for (const sel of selections) {
730
+ // Check directives (@skip, @include, @auth, @role, @guest)
731
+ if (!this.checkDirectives(sel.directives ?? [], variables, context)) continue;
667
732
 
668
- /**
669
- * Return schema metadata for debugging.
670
- */
671
- introspect(): Record<string, unknown> {
672
- const queries: Record<string, unknown> = {};
673
- for (const [name, config] of Object.entries(this.queries)) {
674
- queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
675
- }
676
- const mutations: Record<string, unknown> = {};
677
- for (const [name, config] of Object.entries(this.mutations)) {
678
- mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
733
+ if (sel.kind === "fragment_spread") {
734
+ const frag = fragments.get(sel.name);
735
+ if (!frag) {
736
+ errors.push({ message: `Fragment not found: ${sel.name}` });
737
+ continue;
738
+ }
739
+ const errs = this.resolveSelectionsInto(
740
+ frag.selections, resolvers, parent, variables, context, fragments, target, depth + 1,
741
+ );
742
+ errors.push(...errs);
743
+ continue;
744
+ }
745
+
746
+ if (sel.kind === "inline_fragment") {
747
+ const errs = this.resolveSelectionsInto(
748
+ sel.selections, resolvers, parent, variables, context, fragments, target, depth + 1,
749
+ );
750
+ errors.push(...errs);
751
+ continue;
752
+ }
753
+
754
+ const [value, errs] = this.resolveField(sel, resolvers, parent, variables, context, fragments, depth);
755
+ errors.push(...errs);
756
+ const key = sel.alias ?? sel.name;
757
+ target[key] = value;
679
758
  }
680
- return { types: Object.keys(this.types), queries, mutations };
759
+
760
+ return errors;
681
761
  }
682
762
 
683
763
  /**
684
764
  * Generate SDL schema string.
685
765
  */
686
-
687
- /**
688
- * Return schema metadata for debugging.
689
- */
690
- introspect(): Record<string, unknown> {
691
- const queries: Record<string, unknown> = {};
692
- for (const [name, config] of Object.entries(this.queries)) {
693
- queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
694
- }
695
- const mutations: Record<string, unknown> = {};
696
- for (const [name, config] of Object.entries(this.mutations)) {
697
- mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
698
- }
699
- return { types: Object.keys(this.types), queries, mutations };
700
- }
701
-
702
766
  schemaSdl(): string {
703
767
  const lines: string[] = [];
704
768
 
@@ -737,21 +801,6 @@ export class GraphQL {
737
801
  return lines.join("\n");
738
802
  }
739
803
 
740
- /**
741
- * Return schema metadata for debugging.
742
- */
743
- introspect(): Record<string, unknown> {
744
- const queries: Record<string, unknown> = {};
745
- for (const [name, config] of Object.entries(this.queries)) {
746
- queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
747
- }
748
- const mutations: Record<string, unknown> = {};
749
- for (const [name, config] of Object.entries(this.mutations)) {
750
- mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
751
- }
752
- return { types: Object.keys(this.types), queries, mutations };
753
- }
754
-
755
804
  /**
756
805
  * Auto-generate type, queries, and CRUD mutations from an ORM model class.
757
806
  *
@@ -967,6 +1016,8 @@ export class GraphQL {
967
1016
  parent: unknown,
968
1017
  variables: Record<string, unknown>,
969
1018
  context: Record<string, unknown> = {},
1019
+ fragments: Map<string, ParsedFragment> = new Map(),
1020
+ depth: number = 1,
970
1021
  ): [unknown, Array<{ message: string; path?: string[] }>] {
971
1022
  const errors: Array<{ message: string; path?: string[] }> = [];
972
1023
  const name = sel.name;
@@ -997,8 +1048,13 @@ export class GraphQL {
997
1048
  try {
998
1049
  value = config.resolver(null, args, ctx);
999
1050
  } catch (e: unknown) {
1051
+ // Log the real cause; only surface the detail to the client in debug
1052
+ // mode — a resolver exception can carry internal state (DB errors,
1053
+ // credentials) that must not leak. The path is always preserved.
1000
1054
  const message = e instanceof Error ? e.message : String(e);
1001
- errors.push({ message, path: [name] });
1055
+ Log.error(`GraphQL resolver '${name}' failed: ${message}`);
1056
+ const detail = isDebugMode() ? message : "Internal server error";
1057
+ errors.push({ message: detail, path: [name] });
1002
1058
  return [null, errors];
1003
1059
  }
1004
1060
  }
@@ -1011,11 +1067,10 @@ export class GraphQL {
1011
1067
  const result: Record<string, unknown>[] = [];
1012
1068
  for (const item of value) {
1013
1069
  const obj: Record<string, unknown> = {};
1014
- for (const subSel of sel.selections) {
1015
- const [subVal, subErrs] = this.resolveField(subSel, new Map(), item, variables, context);
1016
- errors.push(...subErrs);
1017
- obj[subSel.alias ?? subSel.name] = subVal;
1018
- }
1070
+ const errs = this.resolveSelectionsInto(
1071
+ sel.selections, new Map(), item, variables, context, fragments, obj, depth + 1,
1072
+ );
1073
+ errors.push(...errs);
1019
1074
  result.push(obj);
1020
1075
  }
1021
1076
  return [result, errors];
@@ -1023,11 +1078,10 @@ export class GraphQL {
1023
1078
 
1024
1079
  if (value !== null && value !== undefined) {
1025
1080
  const obj: Record<string, unknown> = {};
1026
- for (const subSel of sel.selections) {
1027
- const [subVal, subErrs] = this.resolveField(subSel, new Map(), value, variables, context);
1028
- errors.push(...subErrs);
1029
- obj[subSel.alias ?? subSel.name] = subVal;
1030
- }
1081
+ const errs = this.resolveSelectionsInto(
1082
+ sel.selections, new Map(), value, variables, context, fragments, obj, depth + 1,
1083
+ );
1084
+ errors.push(...errs);
1031
1085
  return [obj, errors];
1032
1086
  }
1033
1087
 
@@ -45,8 +45,35 @@ const HTML_TAGS: readonly string[] = [
45
45
  "wbr",
46
46
  ];
47
47
 
48
+ /**
49
+ * Raw — marker for trusted, pre-sanitised HTML that must render UNESCAPED.
50
+ *
51
+ * String/scalar children of an HtmlElement are HTML-escaped by default to
52
+ * prevent stored/reflected XSS. Wrap a value in Raw to opt out of escaping
53
+ * when (and only when) you have already sanitised it yourself.
54
+ *
55
+ * new HtmlElement("div", {}, ["<b>x</b>"]).toString() // &lt;b&gt;x&lt;/b&gt; (escaped)
56
+ * new HtmlElement("div", {}, [new Raw("<b>x</b>")]).toString() // <b>x</b> (raw)
57
+ *
58
+ * Alias: SafeString.
59
+ */
60
+ export class Raw {
61
+ readonly value: string;
62
+
63
+ constructor(value: string) {
64
+ this.value = String(value);
65
+ }
66
+
67
+ toString(): string {
68
+ return this.value;
69
+ }
70
+ }
71
+
72
+ // Alias — some callers/frameworks prefer the SafeString name (matches Frond/Python).
73
+ export const SafeString = Raw;
74
+
48
75
  type Attrs = Record<string, string | number | boolean | null | undefined>;
49
- type Child = string | number | HtmlElement;
76
+ type Child = string | number | HtmlElement | Raw;
50
77
 
51
78
  function escapeAttr(value: string): string {
52
79
  return value
@@ -56,8 +83,28 @@ function escapeAttr(value: string): string {
56
83
  .replace(/>/g, "&gt;");
57
84
  }
58
85
 
86
+ /**
87
+ * Escape a plain string/scalar child so it cannot inject markup (defeats XSS).
88
+ */
89
+ function escapeText(value: string): string {
90
+ return value
91
+ .replace(/&/g, "&amp;")
92
+ .replace(/</g, "&lt;")
93
+ .replace(/>/g, "&gt;");
94
+ }
95
+
59
96
  function isAttrs(arg: unknown): arg is Attrs {
60
- return typeof arg === "object" && arg !== null && !(arg instanceof HtmlElement) && !Array.isArray(arg);
97
+ return typeof arg === "object" && arg !== null
98
+ && !(arg instanceof HtmlElement) && !(arg instanceof Raw) && !Array.isArray(arg);
99
+ }
100
+
101
+ /**
102
+ * A builder produced by htmlElement() is a callable function branded with
103
+ * `_isHtmlElement`. Its toString() already produces fully-escaped HTML, so it
104
+ * must render verbatim (not be escaped as a string).
105
+ */
106
+ function isBuilderElement(arg: unknown): boolean {
107
+ return typeof arg === "function" && (arg as { _isHtmlElement?: boolean })._isHtmlElement === true;
61
108
  }
62
109
 
63
110
  /**
@@ -95,7 +142,19 @@ export class HtmlElement {
95
142
  html += ">";
96
143
 
97
144
  for (const child of this.children) {
98
- html += String(child);
145
+ if (child instanceof HtmlElement) {
146
+ // Nested elements render themselves (already escape their own children).
147
+ html += child.toString();
148
+ } else if (child instanceof Raw) {
149
+ // Explicitly trusted markup — emit unescaped.
150
+ html += child.toString();
151
+ } else if (isBuilderElement(child)) {
152
+ // Callable builder produced by htmlElement() — render via its toString.
153
+ html += String(child);
154
+ } else {
155
+ // Plain string/scalar child — escape to defeat XSS.
156
+ html += escapeText(String(child));
157
+ }
99
158
  }
100
159
 
101
160
  html += `</${this.tag}>`;
@@ -15,7 +15,7 @@ export type {
15
15
 
16
16
  export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
17
17
  export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
18
- export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
18
+ export { Router, RouteGroup, RouteRef, WsRouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
19
19
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
20
20
  export type { RouteInfo } from "./router.js";
21
21
  export { discoverRoutes } from "./routeDiscovery.js";
@@ -45,6 +45,7 @@ export {
45
45
  hashPassword, checkPassword,
46
46
  authMiddleware,
47
47
  refreshToken, authenticateRequest, validateApiKey,
48
+ ensureDevSecret,
48
49
  Auth,
49
50
  } from "./auth.js";
50
51
  export { Session, FileSessionHandler, RedisSessionHandler, buildSessionCookie } from "./session.js";
@@ -57,14 +58,15 @@ export { Queue } from "./queue.js";
57
58
  export type { QueueConfig, QueueJob, ProcessOptions } from "./queue.js";
58
59
  export { createJob } from "./job.js";
59
60
  export type { JobData, JobQueueBridge } from "./job.js";
60
- export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled } from "./graphql.js";
61
+ export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled, graphqlMaxDepth } from "./graphql.js";
61
62
  export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
62
63
  export {
63
64
  WebSocketServer,
64
65
  devReloadWs,
65
- computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame,
66
+ computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame, originAllowed,
67
+ wsToken, wsAuthorized, offeredBearerSubprotocol, serveWebSocketRoute, wsRouteManager,
66
68
  OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
67
- CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR,
69
+ CLOSE_NORMAL, CLOSE_GOING_AWAY, CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION,
68
70
  } from "./websocket.js";
69
71
  export type { WebSocketClient } from "./websocket.js";
70
72
  export { ServiceRunner, Tina4Service, matchCronField, matchesCron } from "./service.js";
@@ -86,12 +88,12 @@ export {
86
88
  handleFeedbackWidgetJs,
87
89
  registerFeedbackRoutes,
88
90
  } from "./feedback.js";
89
- export { Messenger } from "./messenger.js";
91
+ export { Messenger, MessengerConnectionError } from "./messenger.js";
90
92
  export type { SendResult, EmailMessage } from "./messenger.js";
91
93
  export { DevMailbox, createMessenger } from "./devMailbox.js";
92
94
  export { WSDLService, WSDLOperation } from "./wsdl.js";
93
95
  export type { WSDLOperationMeta } from "./wsdl.js";
94
- export { HtmlElement, htmlElement, addHtmlHelpers } from "./htmlElement.js";
96
+ export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
95
97
  export { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
96
98
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
97
99
  export type { AiTool } from "./ai.js";
@@ -99,8 +101,12 @@ export type { ImapMessage, ImapFullMessage } from "./messenger.js";
99
101
  export { LiteBackend } from "./queueBackends/liteBackend.js";
100
102
  export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";
101
103
  export type { RabbitMQConfig } from "./queueBackends/rabbitmqBackend.js";
102
- export { KafkaBackend } from "./queueBackends/kafkaBackend.js";
103
- export type { KafkaConfig } from "./queueBackends/kafkaBackend.js";
104
+ export { KafkaBackend, kafkaSecurityConfig } from "./queueBackends/kafkaBackend.js";
105
+ export type {
106
+ KafkaConfig,
107
+ KafkaSecurityConfig,
108
+ KafkaClientConfig,
109
+ } from "./queueBackends/kafkaBackend.js";
104
110
  export { MongoBackend } from "./queueBackends/mongoBackend.js";
105
111
  export type { MongoConfig as MongoQueueConfig } from "./queueBackends/mongoBackend.js";
106
112
  export { DatabaseSessionHandler } from "./sessionHandlers/databaseHandler.js";
@@ -119,8 +125,13 @@ export { Container, container } from "./container.js";
119
125
  export { Validator } from "./validator.js";
120
126
  export type { ValidationError } from "./validator.js";
121
127
  export type { WebSocketConnection } from "./websocketConnection.js";
122
- export { RedisBackplane, NATSBackplane, createBackplane } from "./websocketBackplane.js";
123
- export type { WebSocketBackplane } from "./websocketBackplane.js";
128
+ export {
129
+ RedisBackplane, NATSBackplane, createBackplane,
130
+ WsBackplaneManager, buildEnvelope, WS_BACKPLANE_CHANNEL,
131
+ } from "./websocketBackplane.js";
132
+ export type {
133
+ WebSocketBackplane, WsEnvelope, WsEnvelopeKind, WsBackplaneLogger,
134
+ } from "./websocketBackplane.js";
124
135
  export {
125
136
  McpServer, mcpTool, mcpResource, registerDevTools, getDefaultDevServer,
126
137
  encodeResponse, encodeError, encodeNotification, decodeRequest,