tina4-nodejs 3.10.34 → 3.10.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.
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +13 -26
- package/packages/cli/src/commands/seed.ts +72 -0
- package/packages/cli/src/commands/serve.ts +2 -1
- package/packages/core/src/ai.ts +241 -247
- package/packages/core/src/devAdmin.ts +289 -6
- package/packages/core/src/index.ts +3 -3
- package/packages/core/src/metrics.ts +800 -0
- package/packages/core/src/response.ts +98 -40
- package/packages/core/src/router.ts +5 -0
- package/packages/core/src/server.ts +3 -8
- package/packages/core/src/types.ts +2 -2
- package/packages/orm/src/baseModel.ts +25 -0
- package/packages/orm/src/database.ts +38 -0
|
@@ -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
|
-
|
|
51
|
+
safeSetHeader("Content-Type", contentType);
|
|
28
52
|
if (typeof data === "object" && data !== null && !Buffer.isBuffer(data)) {
|
|
29
|
-
|
|
53
|
+
safeEnd(JSON.stringify(data));
|
|
30
54
|
} else {
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
64
|
+
safeSetHeader("Content-Type", "text/html; charset=utf-8");
|
|
41
65
|
} else {
|
|
42
|
-
|
|
66
|
+
safeSetHeader("Content-Type", "text/plain; charset=utf-8");
|
|
43
67
|
}
|
|
44
|
-
|
|
68
|
+
safeEnd(data);
|
|
45
69
|
} else if (Buffer.isBuffer(data)) {
|
|
46
70
|
if (!res.getHeader("Content-Type")) {
|
|
47
|
-
|
|
71
|
+
safeSetHeader("Content-Type", "application/octet-stream");
|
|
48
72
|
}
|
|
49
|
-
|
|
73
|
+
safeEnd(data);
|
|
50
74
|
} else if (data == null) {
|
|
51
|
-
|
|
75
|
+
safeEnd("");
|
|
52
76
|
} else {
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
185
|
+
safeSetHeader("Content-Type", options?.contentType || mimeTypes[ext] || "application/octet-stream");
|
|
186
|
+
safeSetHeader("Content-Length", content.length);
|
|
157
187
|
if (options?.download) {
|
|
158
|
-
|
|
188
|
+
safeSetHeader("Content-Disposition", `attachment; filename="${nodePath.basename(filePath)}"`);
|
|
159
189
|
}
|
|
160
|
-
|
|
190
|
+
safeEnd(content);
|
|
161
191
|
return response;
|
|
162
192
|
};
|
|
163
193
|
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 (
|
|
176
|
-
|
|
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
|
-
//
|
|
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
|
|
53
|
-
template(name: string, data?: Record<string, unknown
|
|
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
|
}
|
|
@@ -188,6 +188,31 @@ export class BaseModel {
|
|
|
188
188
|
return instance;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Create a new instance from data, save it, and return the saved instance.
|
|
193
|
+
*
|
|
194
|
+
* Usage:
|
|
195
|
+
* const user = User.create({ name: "Alice", email: "alice@example.com" });
|
|
196
|
+
*/
|
|
197
|
+
static create<T extends BaseModel>(
|
|
198
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
199
|
+
data: Record<string, unknown>,
|
|
200
|
+
): T {
|
|
201
|
+
const instance = new this(data) as T;
|
|
202
|
+
instance.save();
|
|
203
|
+
return instance;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Alias for findById(). */
|
|
207
|
+
static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): T | null {
|
|
208
|
+
return (this as unknown as typeof BaseModel).findById.call(this, id, include) as T | null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Alias for findById(). */
|
|
212
|
+
static load<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): T | null {
|
|
213
|
+
return (this as unknown as typeof BaseModel).findById.call(this, id, include) as T | null;
|
|
214
|
+
}
|
|
215
|
+
|
|
191
216
|
/**
|
|
192
217
|
* Find all records, optionally with a where clause.
|
|
193
218
|
* @param where Optional WHERE clause.
|
|
@@ -393,6 +393,44 @@ export class Database {
|
|
|
393
393
|
return this.getNextAdapter().tables();
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Get column metadata for a table.
|
|
398
|
+
* Uses the adapter's columns() method which handles engine-specific introspection
|
|
399
|
+
* (PRAGMA table_info for SQLite, information_schema.columns for others).
|
|
400
|
+
*
|
|
401
|
+
* @param tableName - Name of the table to inspect.
|
|
402
|
+
* @returns Array of column info objects: { name, type, nullable, default, primaryKey }.
|
|
403
|
+
*/
|
|
404
|
+
getColumns(tableName: string): { name: string; type: string; nullable?: boolean; default?: unknown; primaryKey?: boolean }[] {
|
|
405
|
+
return this.getNextAdapter().columns(tableName);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Execute a SQL statement with multiple parameter sets (batch insert/update).
|
|
410
|
+
* Wraps all executions in a single transaction for atomicity and performance.
|
|
411
|
+
*
|
|
412
|
+
* @param sql - The SQL statement with parameter placeholders.
|
|
413
|
+
* @param paramSets - Array of parameter arrays, one per execution.
|
|
414
|
+
* @returns Array of results from each execution.
|
|
415
|
+
*/
|
|
416
|
+
executeMany(sql: string, paramSets: unknown[][]): unknown[] {
|
|
417
|
+
const adapter = this.getNextAdapter();
|
|
418
|
+
const results: unknown[] = [];
|
|
419
|
+
|
|
420
|
+
adapter.startTransaction();
|
|
421
|
+
try {
|
|
422
|
+
for (const params of paramSets) {
|
|
423
|
+
results.push(adapter.execute(sql, params));
|
|
424
|
+
}
|
|
425
|
+
adapter.commit();
|
|
426
|
+
} catch (e) {
|
|
427
|
+
adapter.rollback();
|
|
428
|
+
throw e;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return results;
|
|
432
|
+
}
|
|
433
|
+
|
|
396
434
|
/** Get the last auto-increment id. */
|
|
397
435
|
getLastId(): string | number {
|
|
398
436
|
const id = this.getNextAdapter().lastInsertId();
|