tina4-nodejs 3.10.91 → 3.10.92

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.10.91",
3
+ "version": "3.10.92",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
@@ -608,7 +608,7 @@ npx tina4nodejs test # Run all tests
608
608
 
609
609
  Add test files in \`test/\` directory. Use built-in inline testing:
610
610
  \`\`\`typescript
611
- import { tests, assertEqual, runAllTests } from "@tina4/core";
611
+ import { tests, assertEqual, runAll } from "@tina4/core";
612
612
  \`\`\`
613
613
 
614
614
  ## Important
@@ -191,15 +191,40 @@ export class RequestInspector {
191
191
 
192
192
  export class ErrorTracker {
193
193
  private static errors: ErrorEntry[] = [];
194
+ private static maxErrors = 200;
195
+ private static registered = false;
194
196
 
197
+ /**
198
+ * Capture an error with dedup (matches PHP/Ruby/Python signature).
199
+ * Duplicate errors (same message) increment count and update last_seen.
200
+ */
201
+ static capture(errorType: string, message: string, traceback = "", file = "", line = 0): void {
202
+ const fingerprint = `${errorType}|${message}|${file}|${line}`;
203
+ const existing = this.errors.find((e) => (e as any).fingerprint === fingerprint);
204
+ const now = new Date().toISOString();
205
+
206
+ if (existing) {
207
+ (existing as any).count = ((existing as any).count || 1) + 1;
208
+ (existing as any).last_seen = now;
209
+ existing.resolved = false; // re-open resolved duplicates
210
+ } else {
211
+ this.errors.push({
212
+ id: `err_${Date.now()}_${this.errors.length}`,
213
+ timestamp: now,
214
+ message,
215
+ stack: traceback || undefined,
216
+ resolved: false,
217
+ ...({ fingerprint, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now } as any),
218
+ });
219
+ if (this.errors.length > this.maxErrors) {
220
+ this.errors = this.errors.slice(-this.maxErrors);
221
+ }
222
+ }
223
+ }
224
+
225
+ /** Legacy alias for capture (backward compatibility). */
195
226
  static track(message: string, stack?: string): void {
196
- this.errors.push({
197
- id: `err_${Date.now()}_${this.errors.length}`,
198
- timestamp: new Date().toISOString(),
199
- message,
200
- stack,
201
- resolved: false,
202
- });
227
+ this.capture("Error", message, stack || "");
203
228
  }
204
229
 
205
230
  static get(): ErrorEntry[] {
@@ -218,6 +243,56 @@ export class ErrorTracker {
218
243
  static clearResolved(): void {
219
244
  this.errors = this.errors.filter((e) => !e.resolved);
220
245
  }
246
+
247
+ /** Remove ALL tracked errors. */
248
+ static clearAll(): void {
249
+ this.errors = [];
250
+ }
251
+
252
+ /** Health summary — are there unresolved errors? */
253
+ static health(): { healthy: boolean; total: number; unresolved: number; resolved: number } {
254
+ const total = this.errors.length;
255
+ const resolved = this.errors.filter((e) => e.resolved).length;
256
+ const unresolved = total - resolved;
257
+ return { healthy: unresolved === 0, total, unresolved, resolved };
258
+ }
259
+
260
+ /** Count of unresolved errors. */
261
+ static unresolvedCount(): number {
262
+ return this.errors.filter((e) => !e.resolved).length;
263
+ }
264
+
265
+ /** Reset all state (for testing). */
266
+ static reset(): void {
267
+ this.errors = [];
268
+ this.registered = false;
269
+ }
270
+
271
+ /**
272
+ * Register global error handlers to feed the tracker.
273
+ * Safe to call multiple times — only registers once.
274
+ */
275
+ static register(): void {
276
+ if (this.registered) return;
277
+ this.registered = true;
278
+
279
+ process.on("uncaughtException", (err: Error) => {
280
+ this.capture(
281
+ err.constructor.name,
282
+ err.message,
283
+ err.stack || "",
284
+ );
285
+ });
286
+
287
+ process.on("unhandledRejection", (reason: unknown) => {
288
+ const err = reason instanceof Error ? reason : new Error(String(reason));
289
+ this.capture(
290
+ err.constructor.name,
291
+ err.message,
292
+ err.stack || "",
293
+ );
294
+ });
295
+ }
221
296
  }
222
297
 
223
298
  // ---------------------------------------------------------------------------
@@ -355,6 +430,9 @@ export class DevAdmin {
355
430
  * Register all /__dev routes on the given router.
356
431
  */
357
432
  static register(router: Router): void {
433
+ // Register error handlers to feed the ErrorTracker
434
+ ErrorTracker.register();
435
+
358
436
  const routes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
359
437
  // Dashboard
360
438
  { method: "GET", pattern: "/__dev", handler: handleDashboard },
@@ -7,7 +7,7 @@
7
7
  * import { DevMailbox, createMessenger } from "@tina4/core";
8
8
  *
9
9
  * const mailbox = new DevMailbox();
10
- * mailbox.capture({ to: "alice@test.com", subject: "Hello", body: "Hi!" });
10
+ * mailbox.capture("alice@test.com", "Hello", "Hi!");
11
11
  * const messages = mailbox.inbox();
12
12
  */
13
13
  import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
@@ -39,33 +39,33 @@ export class DevMailbox {
39
39
  /**
40
40
  * Capture an email to the dev mailbox instead of sending it.
41
41
  */
42
- capture(options: {
43
- to: string | string[];
44
- subject: string;
45
- body: string;
46
- html?: boolean;
47
- cc?: string[];
48
- bcc?: string[];
49
- replyTo?: string;
50
- attachments?: string[];
51
- from?: string;
52
- }): SendResult {
42
+ capture(
43
+ to: string | string[],
44
+ subject: string,
45
+ body: string,
46
+ html: boolean = false,
47
+ cc: string[] = [],
48
+ bcc: string[] = [],
49
+ replyTo?: string,
50
+ attachments: string[] = [],
51
+ from?: string,
52
+ ): SendResult {
53
53
  const id = randomUUID();
54
- const toList = Array.isArray(options.to) ? options.to : [options.to];
54
+ const toList = Array.isArray(to) ? to : [to];
55
55
  const now = new Date().toISOString();
56
56
 
57
57
  const message: EmailMessage = {
58
58
  id,
59
59
  type: "outbox",
60
- from: options.from ?? process.env.SMTP_FROM ?? "dev@localhost",
60
+ from: from ?? process.env.SMTP_FROM ?? "dev@localhost",
61
61
  to: toList,
62
- cc: options.cc ?? [],
63
- bcc: options.bcc ?? [],
64
- reply_to: options.replyTo,
65
- subject: options.subject,
66
- body: options.body,
67
- html: options.html ?? false,
68
- attachments: options.attachments ?? [],
62
+ cc,
63
+ bcc,
64
+ reply_to: replyTo,
65
+ subject,
66
+ body,
67
+ html,
68
+ attachments,
69
69
  date: now,
70
70
  read: false,
71
71
  };
@@ -932,3 +932,39 @@ export class GraphQL {
932
932
  return resolved;
933
933
  }
934
934
  }
935
+
936
+ /**
937
+ * Lightweight GraphQL type wrapper matching Ruby's GraphQLType.
938
+ */
939
+ export class GraphQLType {
940
+ static readonly SCALARS = ["String", "Int", "Float", "Boolean", "ID"];
941
+
942
+ name: string;
943
+ kind: string;
944
+ ofType: GraphQLType | null;
945
+
946
+ constructor(name: string, kind: string = "object", ofType: GraphQLType | null = null) {
947
+ this.name = name;
948
+ this.kind = kind;
949
+ this.ofType = ofType;
950
+ }
951
+
952
+ /**
953
+ * Parse a GraphQL type string like "String", "String!", "[Int!]!".
954
+ */
955
+ static parse(typeStr: string): GraphQLType {
956
+ const s = String(typeStr).trim();
957
+ if (s.endsWith("!")) {
958
+ const inner = GraphQLType.parse(s.slice(0, -1));
959
+ return new GraphQLType(s, "non_null", inner);
960
+ }
961
+ if (s.startsWith("[") && s.endsWith("]")) {
962
+ const inner = GraphQLType.parse(s.slice(1, -1));
963
+ return new GraphQLType(s, "list", inner);
964
+ }
965
+ if (GraphQLType.SCALARS.includes(s)) {
966
+ return new GraphQLType(s, "scalar");
967
+ }
968
+ return new GraphQLType(s, "object");
969
+ }
970
+ }
@@ -12,15 +12,15 @@ export type {
12
12
  WebSocketRouteDefinition,
13
13
  } from "./types.js";
14
14
 
15
- export { startServer, resolvePortAndHost, handle } from "./server.js";
15
+ export { startServer, resolvePortAndHost, handle, start, stop } from "./server.js";
16
16
  export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
17
17
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
18
18
  export type { RouteInfo } from "./router.js";
19
19
  export { discoverRoutes } from "./routeDiscovery.js";
20
20
  export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware } from "./middleware.js";
21
21
  export type { CorsConfig } from "./middleware.js";
22
- export { createRequest, parseBody } from "./request.js";
23
- export { createResponse, errorResponse, setDefaultTemplatesDir } from "./response.js";
22
+ export { createRequest } from "./request.js";
23
+ export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFrond, getFrameworkFrond } from "./response.js";
24
24
  export { tryServeStatic } from "./static.js";
25
25
  export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
26
26
  export { Log } from "./logger.js";
@@ -74,8 +74,8 @@ export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore,
74
74
  export { Messenger } from "./messenger.js";
75
75
  export type { SendResult, EmailMessage } from "./messenger.js";
76
76
  export { DevMailbox, createMessenger } from "./devMailbox.js";
77
- export { WSDLService, WSDLOp } from "./wsdl.js";
78
- export type { WSDLOperation } from "./wsdl.js";
77
+ export { WSDLService, WSDLOperation } from "./wsdl.js";
78
+ export type { WSDLOperationMeta } from "./wsdl.js";
79
79
  export { HtmlElement, htmlElement, addHtmlHelpers } from "./htmlElement.js";
80
80
  export { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
81
81
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
@@ -96,7 +96,7 @@ export { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
96
96
  export type { ValkeySessionConfig } from "./sessionHandlers/valkeyHandler.js";
97
97
  export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
98
98
  export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
99
- export { tests, assertEqual, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "./testing.js";
99
+ export { tests, assertEqual, assertRaises, assertTrue, assertFalse, runAll, reset } from "./testing.js";
100
100
  export { TestClient, TestResponse } from "./testClient.js";
101
101
  export { Container, container } from "./container.js";
102
102
  export { Validator } from "./validator.js";
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * const mail = new Messenger(); // reads from .env
23
23
  * const mail = new Messenger({ host: "smtp.office365.com", port: 587 }); // override
24
- * await mail.send({ to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!" });
24
+ * await mail.send("user@test.com", "Welcome", "<h1>Hello!</h1>", true, "Hello!");
25
25
  */
26
26
  import net from "node:net";
27
27
  import tls from "node:tls";
@@ -355,7 +355,19 @@ export class Messenger {
355
355
  /**
356
356
  * Send an email via SMTP.
357
357
  */
358
- async send(options: SendOptions): Promise<SendResult> {
358
+ async send(
359
+ to: string | string[],
360
+ subject: string,
361
+ body: string,
362
+ html: boolean = false,
363
+ text?: string,
364
+ cc?: string | string[],
365
+ bcc?: string | string[],
366
+ replyTo?: string,
367
+ attachments?: string[],
368
+ headers?: Record<string, string>,
369
+ ): Promise<SendResult> {
370
+ const options: SendOptions = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
359
371
  const toList = Array.isArray(options.to) ? options.to : [options.to];
360
372
  const ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
361
373
  const bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
@@ -655,7 +667,24 @@ export class Messenger {
655
667
  /**
656
668
  * Search messages using IMAP search criteria.
657
669
  */
658
- async search(query: string, folder: string = "INBOX"): Promise<ImapMessage[]> {
670
+ async search(
671
+ folder: string = "INBOX",
672
+ subject?: string,
673
+ sender?: string,
674
+ since?: string,
675
+ before?: string,
676
+ unseenOnly: boolean = false,
677
+ limit: number = 50,
678
+ ): Promise<ImapMessage[]> {
679
+ // Build IMAP SEARCH criteria from structured params
680
+ const criteria: string[] = ["ALL"];
681
+ if (subject) criteria.push(`SUBJECT "${subject}"`);
682
+ if (sender) criteria.push(`FROM "${sender}"`);
683
+ if (since) criteria.push(`SINCE ${since}`);
684
+ if (before) criteria.push(`BEFORE ${before}`);
685
+ if (unseenOnly) criteria.push("UNSEEN");
686
+
687
+ const query = criteria.join(" ");
659
688
  const socket = await this.imapConnect();
660
689
  try {
661
690
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
@@ -665,7 +694,7 @@ export class Messenger {
665
694
 
666
695
  uids.reverse();
667
696
  const messages: ImapMessage[] = [];
668
- for (const uid of uids.slice(0, 50)) {
697
+ for (const uid of uids.slice(0, limit)) {
669
698
  const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
670
699
  messages.push(parseHeaderResponse(uid, fetchResp));
671
700
  }
@@ -716,6 +745,25 @@ export class Messenger {
716
745
  }
717
746
  }
718
747
 
748
+ /**
749
+ * List available IMAP folders/mailboxes.
750
+ */
751
+ async folders(): Promise<string[]> {
752
+ const socket = await this.imapConnect();
753
+ try {
754
+ const resp = await imapCommand(socket, 'LIST "" "*"');
755
+ const result: string[] = [];
756
+ for (const line of resp.split("\r\n")) {
757
+ // Parse LIST response: * LIST (\flags) "/" "FolderName"
758
+ const m = line.match(/\* LIST \([^)]*\) "[^"]*" "?([^"\r\n]+)"?/i);
759
+ if (m) result.push(m[1]);
760
+ }
761
+ return result;
762
+ } finally {
763
+ await this.imapDisconnect(socket);
764
+ }
765
+ }
766
+
719
767
  /**
720
768
  * Test IMAP connectivity without reading.
721
769
  */
@@ -268,6 +268,13 @@ export class CorsMiddleware {
268
268
 
269
269
  return [req, res];
270
270
  }
271
+
272
+ /**
273
+ * Check if a request is an OPTIONS preflight.
274
+ */
275
+ static isPreflight(method: string): boolean {
276
+ return method?.toUpperCase() === "OPTIONS";
277
+ }
271
278
  }
272
279
 
273
280
  /**
@@ -354,6 +361,36 @@ export class RateLimiterMiddleware {
354
361
  entry.timestamps.push(now);
355
362
  return [req, res];
356
363
  }
364
+
365
+ /**
366
+ * Check if an IP is within rate limits without recording a request.
367
+ * Returns [allowed, info] matching Python/Ruby API.
368
+ */
369
+ static check(ip: string): [boolean, { limit: number; remaining: number; reset: number; window: number }] {
370
+ const limit = process.env.TINA4_RATE_LIMIT ? parseInt(process.env.TINA4_RATE_LIMIT, 10) : 100;
371
+ const windowSeconds = process.env.TINA4_RATE_WINDOW ? parseInt(process.env.TINA4_RATE_WINDOW, 10) : 60;
372
+ const windowMs = windowSeconds * 1000;
373
+ const now = Date.now();
374
+ const cutoff = now - windowMs;
375
+
376
+ let entry = RateLimiterMiddleware.store.get(ip);
377
+ if (!entry) {
378
+ entry = { timestamps: [] };
379
+ RateLimiterMiddleware.store.set(ip, entry);
380
+ }
381
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
382
+
383
+ const remaining = Math.max(0, limit - entry.timestamps.length);
384
+ const reset = entry.timestamps.length > 0
385
+ ? Math.ceil((entry.timestamps[0] + windowMs - now) / 1000)
386
+ : windowSeconds;
387
+
388
+ if (entry.timestamps.length >= limit) {
389
+ return [false, { limit, remaining: 0, reset, window: windowSeconds }];
390
+ }
391
+
392
+ return [true, { limit, remaining: remaining - 1, reset: windowSeconds, window: windowSeconds }];
393
+ }
357
394
  }
358
395
 
359
396
  /**
@@ -1,4 +1,4 @@
1
- import type { Middleware } from "./types.js";
1
+ import type { Middleware, Tina4Request, Tina4Response } from "./types.js";
2
2
 
3
3
  /** Per-IP sliding window entry */
4
4
  interface RateLimitEntry {
@@ -105,3 +105,90 @@ export function rateLimiter(config?: RateLimiterConfig): Middleware {
105
105
  next();
106
106
  };
107
107
  }
108
+
109
+ /** Rate limit check result */
110
+ export interface RateLimitResult {
111
+ allowed: boolean;
112
+ limit: number;
113
+ remaining: number;
114
+ reset: number;
115
+ retryAfter?: number;
116
+ }
117
+
118
+ /**
119
+ * Class-based rate limiter with check/reset/apply methods.
120
+ * Matches the Python/PHP/Ruby API surface.
121
+ */
122
+ export class RateLimiter {
123
+ readonly limit: number;
124
+ readonly window: number;
125
+ private store = new Map<string, number[]>();
126
+
127
+ constructor(config?: RateLimiterConfig) {
128
+ this.limit = config?.limit
129
+ ?? (process.env.TINA4_RATE_LIMIT ? parseInt(process.env.TINA4_RATE_LIMIT, 10) : 100);
130
+ this.window = config?.windowSeconds
131
+ ?? (process.env.TINA4_RATE_WINDOW ? parseInt(process.env.TINA4_RATE_WINDOW, 10) : 60);
132
+ }
133
+
134
+ /** Check if a request from the given IP is allowed. */
135
+ check(ip: string): RateLimitResult {
136
+ const now = Date.now();
137
+ const windowMs = this.window * 1000;
138
+ const cutoff = now - windowMs;
139
+
140
+ let timestamps = this.store.get(ip);
141
+ if (!timestamps) {
142
+ timestamps = [];
143
+ this.store.set(ip, timestamps);
144
+ }
145
+
146
+ // Prune expired
147
+ const filtered = timestamps.filter((t) => t > cutoff);
148
+ this.store.set(ip, filtered);
149
+
150
+ const resetTime = filtered.length > 0
151
+ ? Math.ceil((filtered[0] + windowMs) / 1000)
152
+ : Math.ceil((now + windowMs) / 1000);
153
+
154
+ if (filtered.length >= this.limit) {
155
+ const retryAfter = Math.max(1, resetTime - Math.ceil(now / 1000));
156
+ return { allowed: false, limit: this.limit, remaining: 0, reset: resetTime, retryAfter };
157
+ }
158
+
159
+ filtered.push(now);
160
+ return {
161
+ allowed: true,
162
+ limit: this.limit,
163
+ remaining: this.limit - filtered.length,
164
+ reset: resetTime,
165
+ };
166
+ }
167
+
168
+ /** Clear all tracked request data. */
169
+ reset(): void {
170
+ this.store.clear();
171
+ }
172
+
173
+ /** Apply rate limiting to a request/response pair. Sets headers and 429 if exceeded. */
174
+ apply(request: Tina4Request, response: Tina4Response): [Tina4Request, Tina4Response] {
175
+ const ip = (request as Record<string, unknown>).ip as string ?? "unknown";
176
+ const result = this.check(ip);
177
+
178
+ response.header("X-RateLimit-Limit", String(result.limit));
179
+ response.header("X-RateLimit-Remaining", String(result.remaining));
180
+ response.header("X-RateLimit-Reset", String(result.reset));
181
+
182
+ if (!result.allowed) {
183
+ response.header("Retry-After", String(result.retryAfter ?? 1));
184
+ response({ error: "Too Many Requests", retryAfter: result.retryAfter }, 429);
185
+ }
186
+
187
+ return [request, response];
188
+ }
189
+
190
+ /** Middleware hook — enforces rate limiting before the route handler. */
191
+ beforeRateLimit(request: Tina4Request, response: Tina4Response): [Tina4Request, Tina4Response] {
192
+ return this.apply(request, response);
193
+ }
194
+ }
@@ -36,6 +36,29 @@ export function createRequest(req: IncomingMessage): Tina4Request {
36
36
  tReq.ip = req.socket?.remoteAddress ?? "127.0.0.1";
37
37
  }
38
38
 
39
+ // Add convenience methods
40
+ tReq.header = function (name: string): string | undefined {
41
+ const val = req.headers[name.toLowerCase()];
42
+ if (Array.isArray(val)) return val[0];
43
+ return val;
44
+ };
45
+
46
+ tReq.bearerToken = function (): string | null {
47
+ const auth = tReq.header("authorization") ?? "";
48
+ if (auth.toLowerCase().startsWith("bearer ")) {
49
+ return auth.slice(7);
50
+ }
51
+ return null;
52
+ };
53
+
54
+ tReq.param = function (key: string, defaultValue?: string): string | undefined {
55
+ return tReq.params[key] ?? tReq.query[key] ?? defaultValue;
56
+ };
57
+
58
+ tReq.parseBody = function (): Promise<void> {
59
+ return parseBody(tReq);
60
+ };
61
+
39
62
  return tReq;
40
63
  }
41
64
 
@@ -50,7 +73,7 @@ export class PayloadTooLargeError extends Error {
50
73
  }
51
74
  }
52
75
 
53
- export async function parseBody(req: Tina4Request): Promise<void> {
76
+ async function parseBody(req: Tina4Request): Promise<void> {
54
77
  const method = req.method?.toUpperCase();
55
78
  if (method === "GET" || method === "HEAD" || method === "OPTIONS") return;
56
79
 
@@ -9,14 +9,67 @@ const _frondCache = new Map<string, InstanceType<any>>();
9
9
  /** Default templates directory — set via setDefaultTemplatesDir(). */
10
10
  let _defaultTemplatesDir: string | null = null;
11
11
 
12
+ /** Global user Frond engine — set via setFrond(). */
13
+ let _globalFrond: InstanceType<any> | null = null;
14
+
15
+ /** Singleton framework Frond engine for built-in templates. */
16
+ let _frameworkFrond: InstanceType<any> | null = null;
17
+
12
18
  /**
13
- * Set the default templates directory for render()/template().
19
+ * Set the default templates directory for render().
14
20
  * Called by server.ts during startup.
15
21
  */
16
22
  export function setDefaultTemplatesDir(dir: string): void {
17
23
  _defaultTemplatesDir = dir;
18
24
  }
19
25
 
26
+ /**
27
+ * Return the global Frond engine, creating a default if needed.
28
+ */
29
+ export async function getFrond(): Promise<InstanceType<any>> {
30
+ if (_globalFrond) return _globalFrond;
31
+ const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
32
+ let engine = _frondCache.get(dir);
33
+ if (!engine) {
34
+ const { Frond } = await import("@tina4/frond");
35
+ engine = new Frond(dir);
36
+ _frondCache.set(dir, engine);
37
+ }
38
+ _globalFrond = engine;
39
+ return engine;
40
+ }
41
+
42
+ /**
43
+ * Return the singleton Frond engine for built-in framework templates.
44
+ * Syncs custom filters/globals from the user engine.
45
+ */
46
+ export async function getFrameworkFrond(): Promise<InstanceType<any> | null> {
47
+ const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
48
+ if (!_frameworkFrond && fs.existsSync(frameworkDir)) {
49
+ try {
50
+ const { Frond } = await import("@tina4/frond");
51
+ _frameworkFrond = new Frond(frameworkDir);
52
+ } catch { return null; }
53
+ }
54
+ // Sync custom filters/globals from the user engine
55
+ if (_frameworkFrond && _globalFrond) {
56
+ if (typeof _globalFrond._filters === "object") {
57
+ Object.assign(_frameworkFrond._filters ??= {}, _globalFrond._filters);
58
+ }
59
+ if (typeof _globalFrond._globals === "object") {
60
+ Object.assign(_frameworkFrond._globals ??= {}, _globalFrond._globals);
61
+ }
62
+ }
63
+ return _frameworkFrond;
64
+ }
65
+
66
+ /**
67
+ * Register a pre-configured Frond engine for response.render().
68
+ */
69
+ export function setFrond(engine: InstanceType<any>): void {
70
+ _globalFrond = engine;
71
+ }
72
+
20
73
  /**
21
74
  * Creates a callable response object.
22
75
  *
@@ -233,15 +286,6 @@ export function createResponse(res: ServerResponse): Tina4Response {
233
286
  }
234
287
  };
235
288
 
236
- response.template = async function (
237
- name: string,
238
- data?: Record<string, unknown>,
239
- status?: number,
240
- templateDir?: string,
241
- ): Promise<Tina4Response> {
242
- return response.render(name, data, status, templateDir);
243
- };
244
-
245
289
  /**
246
290
  * Stream response from an async generator for Server-Sent Events (SSE).
247
291
  *