tina4-nodejs 3.10.34 → 3.10.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.
@@ -3,6 +3,20 @@ import fs from "node:fs";
3
3
  import nodePath from "node:path";
4
4
  import type { Tina4Response, CookieOptions } from "./types.js";
5
5
 
6
+ /** Cache Frond instances by template directory to avoid repeated instantiation. */
7
+ const _frondCache = new Map<string, InstanceType<any>>();
8
+
9
+ /** Default templates directory — set via setDefaultTemplatesDir(). */
10
+ let _defaultTemplatesDir: string | null = null;
11
+
12
+ /**
13
+ * Set the default templates directory for render()/template().
14
+ * Called by server.ts during startup.
15
+ */
16
+ export function setDefaultTemplatesDir(dir: string): void {
17
+ _defaultTemplatesDir = dir;
18
+ }
19
+
6
20
  /**
7
21
  * Creates a callable response object.
8
22
  *
@@ -16,42 +30,52 @@ import type { Tina4Response, CookieOptions } from "./types.js";
16
30
  */
17
31
  export function createResponse(res: ServerResponse): Tina4Response {
18
32
 
33
+ // ── Guard: prevent writing after headers are sent ──
34
+ const safeEnd = (...args: Parameters<typeof res.end>) => {
35
+ if (!res.headersSent) (res.end as Function)(...args);
36
+ };
37
+ const safeSetHeader = (name: string, value: string | number | readonly string[]) => {
38
+ if (!res.headersSent) res.setHeader(name, value);
39
+ };
40
+
19
41
  // ── The callable: response(data, status, contentType) ──
20
42
  const response = function (data?: unknown, statusCode?: number, contentType?: string): Tina4Response {
43
+ if (res.headersSent) return response;
44
+
21
45
  if (statusCode !== undefined) {
22
46
  res.statusCode = statusCode;
23
47
  }
24
48
 
25
49
  if (contentType) {
26
50
  // Explicit content type
27
- res.setHeader("Content-Type", contentType);
51
+ safeSetHeader("Content-Type", contentType);
28
52
  if (typeof data === "object" && data !== null && !Buffer.isBuffer(data)) {
29
- res.end(JSON.stringify(data));
53
+ safeEnd(JSON.stringify(data));
30
54
  } else {
31
- res.end(data == null ? "" : String(data));
55
+ safeEnd(data == null ? "" : String(data));
32
56
  }
33
57
  } else if (typeof data === "object" && data !== null && !Buffer.isBuffer(data)) {
34
58
  // dict/array → auto JSON
35
- res.setHeader("Content-Type", "application/json");
36
- res.end(JSON.stringify(data));
59
+ safeSetHeader("Content-Type", "application/json");
60
+ safeEnd(JSON.stringify(data));
37
61
  } else if (typeof data === "string") {
38
62
  const trimmed = data.trim();
39
63
  if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
40
- res.setHeader("Content-Type", "text/html; charset=utf-8");
64
+ safeSetHeader("Content-Type", "text/html; charset=utf-8");
41
65
  } else {
42
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
66
+ safeSetHeader("Content-Type", "text/plain; charset=utf-8");
43
67
  }
44
- res.end(data);
68
+ safeEnd(data);
45
69
  } else if (Buffer.isBuffer(data)) {
46
70
  if (!res.getHeader("Content-Type")) {
47
- res.setHeader("Content-Type", "application/octet-stream");
71
+ safeSetHeader("Content-Type", "application/octet-stream");
48
72
  }
49
- res.end(data);
73
+ safeEnd(data);
50
74
  } else if (data == null) {
51
- res.end("");
75
+ safeEnd("");
52
76
  } else {
53
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
54
- res.end(String(data));
77
+ safeSetHeader("Content-Type", "text/plain; charset=utf-8");
78
+ safeEnd(String(data));
55
79
  }
56
80
 
57
81
  return response;
@@ -63,23 +87,26 @@ export function createResponse(res: ServerResponse): Tina4Response {
63
87
  // ── Explicit methods ──
64
88
 
65
89
  response.json = function (data: unknown, status?: number): Tina4Response {
90
+ if (res.headersSent) return response;
66
91
  if (status !== undefined) res.statusCode = status;
67
- res.setHeader("Content-Type", "application/json");
68
- res.end(JSON.stringify(data));
92
+ safeSetHeader("Content-Type", "application/json");
93
+ safeEnd(JSON.stringify(data));
69
94
  return response;
70
95
  };
71
96
 
72
97
  response.html = function (content: string, status?: number): Tina4Response {
98
+ if (res.headersSent) return response;
73
99
  if (status !== undefined) res.statusCode = status;
74
- res.setHeader("Content-Type", "text/html; charset=utf-8");
75
- res.end(content);
100
+ safeSetHeader("Content-Type", "text/html; charset=utf-8");
101
+ safeEnd(content);
76
102
  return response;
77
103
  };
78
104
 
79
105
  response.text = function (content: string, status?: number): Tina4Response {
106
+ if (res.headersSent) return response;
80
107
  if (status !== undefined) res.statusCode = status;
81
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
82
- res.end(content);
108
+ safeSetHeader("Content-Type", "text/plain; charset=utf-8");
109
+ safeEnd(content);
83
110
  return response;
84
111
  };
85
112
 
@@ -88,19 +115,20 @@ export function createResponse(res: ServerResponse): Tina4Response {
88
115
  };
89
116
 
90
117
  response.status = function (code: number): Tina4Response {
91
- res.statusCode = code;
118
+ if (!res.headersSent) res.statusCode = code;
92
119
  return response;
93
120
  };
94
121
 
95
122
  response.header = function (name: string, value: string | number | readonly string[]): Tina4Response {
96
- res.setHeader(name, value);
123
+ safeSetHeader(name, value);
97
124
  return response;
98
125
  };
99
126
 
100
127
  response.redirect = function (url: string, code?: number): Tina4Response {
128
+ if (res.headersSent) return response;
101
129
  res.statusCode = code ?? 302;
102
- res.setHeader("Location", url);
103
- res.end();
130
+ safeSetHeader("Location", url);
131
+ safeEnd();
104
132
  return response;
105
133
  };
106
134
 
@@ -119,7 +147,7 @@ export function createResponse(res: ServerResponse): Tina4Response {
119
147
  if (Array.isArray(existing)) cookies.push(...(existing as string[]));
120
148
  else if (typeof existing === "string") cookies.push(existing);
121
149
  cookies.push(parts.join("; "));
122
- res.setHeader("Set-Cookie", cookies);
150
+ safeSetHeader("Set-Cookie", cookies);
123
151
 
124
152
  return response;
125
153
  };
@@ -134,9 +162,11 @@ export function createResponse(res: ServerResponse): Tina4Response {
134
162
  };
135
163
 
136
164
  response.file = function (filePath: string, options?: { download?: boolean; contentType?: string }): Tina4Response {
165
+ if (res.headersSent) return response;
166
+
137
167
  if (!fs.existsSync(filePath)) {
138
168
  res.statusCode = 404;
139
- res.end("File not found");
169
+ safeEnd("File not found");
140
170
  return response;
141
171
  }
142
172
 
@@ -152,28 +182,56 @@ export function createResponse(res: ServerResponse): Tina4Response {
152
182
  ".txt": "text/plain", ".mp4": "video/mp4", ".mp3": "audio/mpeg",
153
183
  };
154
184
 
155
- res.setHeader("Content-Type", options?.contentType || mimeTypes[ext] || "application/octet-stream");
156
- res.setHeader("Content-Length", content.length);
185
+ safeSetHeader("Content-Type", options?.contentType || mimeTypes[ext] || "application/octet-stream");
186
+ safeSetHeader("Content-Length", content.length);
157
187
  if (options?.download) {
158
- res.setHeader("Content-Disposition", `attachment; filename="${nodePath.basename(filePath)}"`);
188
+ safeSetHeader("Content-Disposition", `attachment; filename="${nodePath.basename(filePath)}"`);
159
189
  }
160
- res.end(content);
190
+ safeEnd(content);
161
191
  return response;
162
192
  };
163
193
 
164
- // Default render/template stubs overwritten by server.ts when Frond is available
165
- response.render = async function (templateName: string, _data?: Record<string, unknown>): Promise<Tina4Response> {
166
- res.statusCode = 500;
167
- response.json({
168
- error: "Template engine not available",
169
- statusCode: 500,
170
- message: "Frond template engine is not initialized. Ensure @tina4/frond is installed.",
171
- });
172
- return response;
194
+ // ── Template rendering via Frond ──
195
+
196
+ response.render = async function (
197
+ templateName: string,
198
+ data?: Record<string, unknown>,
199
+ status?: number,
200
+ templateDir?: string,
201
+ ): Promise<Tina4Response> {
202
+ try {
203
+ const { Frond } = await import("@tina4/frond");
204
+ const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
205
+ let engine = _frondCache.get(dir);
206
+ if (!engine) {
207
+ engine = new Frond(dir);
208
+ _frondCache.set(dir, engine);
209
+ }
210
+ const html = engine.render(templateName, data ?? {});
211
+ if (res.headersSent) return response;
212
+ if (status !== undefined) res.statusCode = status;
213
+ else res.statusCode = 200;
214
+ safeSetHeader("Content-Type", "text/html; charset=utf-8");
215
+ safeEnd(html);
216
+ return response;
217
+ } catch (err) {
218
+ res.statusCode = 500;
219
+ response.json({
220
+ error: "Template engine error",
221
+ statusCode: 500,
222
+ message: err instanceof Error ? err.message : "Frond template engine is not available. Ensure @tina4/frond is installed.",
223
+ });
224
+ return response;
225
+ }
173
226
  };
174
227
 
175
- response.template = async function (name: string, data?: Record<string, unknown>): Promise<Tina4Response> {
176
- return response.render(name, data);
228
+ response.template = async function (
229
+ name: string,
230
+ data?: Record<string, unknown>,
231
+ status?: number,
232
+ templateDir?: string,
233
+ ): Promise<Tina4Response> {
234
+ return response.render(name, data, status, templateDir);
177
235
  };
178
236
 
179
237
  return response;
@@ -11,7 +11,7 @@ import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
11
11
  import { validToken } from "./auth.js";
12
12
  import { discoverRoutes } from "./routeDiscovery.js";
13
13
  import { createRequest, parseBody } from "./request.js";
14
- import { createResponse } from "./response.js";
14
+ import { createResponse, setDefaultTemplatesDir } from "./response.js";
15
15
  import { MiddlewareChain, cors, requestLogger } from "./middleware.js";
16
16
  import { tryServeStatic } from "./static.js";
17
17
  import { loadEnv, isTruthy } from "./dotenv.js";
@@ -461,6 +461,7 @@ ${reset}
461
461
 
462
462
  // Initialize Frond template engine
463
463
  let frondEngine: any = null;
464
+ setDefaultTemplatesDir(templatesDir);
464
465
  try {
465
466
  const { Frond } = await import("@tina4/frond");
466
467
  frondEngine = new Frond(templatesDir);
@@ -610,13 +611,7 @@ ${reset}
610
611
  } as typeof rawRes.end;
611
612
  }
612
613
 
613
- // Add res.render() if Frond is available
614
- if (frondEngine) {
615
- res.render = (template: string, data?: Record<string, unknown>, statusCode?: number) => {
616
- const html = frondEngine.render(template, data);
617
- return res.html(html, statusCode ?? 200);
618
- };
619
- }
614
+ // res.render() / res.template() are handled natively by response.ts via Frond
620
615
 
621
616
  try {
622
617
  // Run middleware chain
@@ -49,8 +49,8 @@ export interface Tina4ResponseMethods {
49
49
  clearCookie(name: string, options?: CookieOptions): Tina4Response;
50
50
  file(path: string, options?: { download?: boolean; contentType?: string }): Tina4Response;
51
51
  error(code: string, message: string, status?: number): Tina4Response;
52
- render(template: string, data?: Record<string, unknown>): Promise<Tina4Response>;
53
- template(name: string, data?: Record<string, unknown>): Promise<Tina4Response>;
52
+ render(template: string, data?: Record<string, unknown>, status?: number, templateDir?: string): Promise<Tina4Response>;
53
+ template(name: string, data?: Record<string, unknown>, status?: number, templateDir?: string): Promise<Tina4Response>;
54
54
  /** The underlying ServerResponse for advanced use */
55
55
  raw: ServerResponse;
56
56
  }