tina4-nodejs 3.10.47 → 3.10.50
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/commands/init.ts +25 -2
- package/packages/cli/src/commands/serve.ts +3 -3
- package/packages/core/src/request.ts +1 -1
- package/packages/core/src/router.ts +5 -0
- package/packages/core/src/server.ts +28 -11
- package/packages/core/src/session.ts +14 -6
- package/packages/orm/src/autoCrud.ts +1 -1
- package/packages/orm/src/databaseResult.ts +19 -10
- package/packages/orm/src/query.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.50",
|
|
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"],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { join, resolve, basename } from "node:path";
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve, basename, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { execSync } from "node:child_process";
|
|
4
5
|
|
|
5
6
|
export async function initProject(name: string): Promise<void> {
|
|
@@ -30,6 +31,28 @@ export async function initProject(name: string): Promise<void> {
|
|
|
30
31
|
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// Copy framework public assets into the project so they're visible
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
36
|
+
const __dirname = dirname(__filename);
|
|
37
|
+
const frameworkPublic = resolve(__dirname, "..", "..", "..", "core", "public");
|
|
38
|
+
const projectPublic = join(targetDir, "public");
|
|
39
|
+
const assetsToCopy = [
|
|
40
|
+
"css/tina4.css",
|
|
41
|
+
"css/tina4.min.css",
|
|
42
|
+
"js/tina4.min.js",
|
|
43
|
+
"js/frond.min.js",
|
|
44
|
+
"images/tina4-logo-icon.webp",
|
|
45
|
+
];
|
|
46
|
+
for (const asset of assetsToCopy) {
|
|
47
|
+
const src = join(frameworkPublic, ...asset.split("/"));
|
|
48
|
+
const dst = join(projectPublic, ...asset.split("/"));
|
|
49
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
50
|
+
if (existsSync(src) && !existsSync(dst)) {
|
|
51
|
+
copyFileSync(src, dst);
|
|
52
|
+
console.log(` Copied ${asset}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
// package.json
|
|
34
57
|
writeFileSync(
|
|
35
58
|
join(targetDir, "package.json"),
|
|
@@ -21,8 +21,8 @@ export async function serveProject(options: ServeOptions): Promise<void> {
|
|
|
21
21
|
process.exit(1);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const { startServer } = await import("
|
|
25
|
-
const { watchForChanges } = await import("
|
|
24
|
+
const { startServer } = await import("../../../core/src/index.js");
|
|
25
|
+
const { watchForChanges } = await import("../../../core/src/watcher.js");
|
|
26
26
|
|
|
27
27
|
const server = await startServer({
|
|
28
28
|
port,
|
|
@@ -36,7 +36,7 @@ export async function serveProject(options: ServeOptions): Promise<void> {
|
|
|
36
36
|
const watchDirs = [routesDir, ormDir, modelsDir, templatesDir].filter((d) => existsSync(d));
|
|
37
37
|
const watcher = watchForChanges(watchDirs, async () => {
|
|
38
38
|
try {
|
|
39
|
-
const { discoverRoutes } = await import("
|
|
39
|
+
const { discoverRoutes } = await import("../../../core/src/index.js");
|
|
40
40
|
// Clear routes BEFORE re-discovery to avoid stale duplicates
|
|
41
41
|
server.router.clear();
|
|
42
42
|
const routes = await discoverRoutes(routesDir);
|
|
@@ -106,7 +106,7 @@ function extractBoundary(contentType: string): string | null {
|
|
|
106
106
|
* Parse multipart/form-data body into fields and files.
|
|
107
107
|
* Zero-dependency implementation.
|
|
108
108
|
*/
|
|
109
|
-
function parseMultipart(
|
|
109
|
+
export function parseMultipart(
|
|
110
110
|
body: Buffer,
|
|
111
111
|
boundary: string,
|
|
112
112
|
): { fields: Record<string, string>; files: UploadedFile[] } {
|
|
@@ -413,6 +413,11 @@ export class Router {
|
|
|
413
413
|
paramNames.push(name);
|
|
414
414
|
return "([^/]+)";
|
|
415
415
|
}
|
|
416
|
+
// Wildcard: * (catch-all, param key is "*")
|
|
417
|
+
if (segment === "*") {
|
|
418
|
+
paramNames.push("*");
|
|
419
|
+
return "(.+)";
|
|
420
|
+
}
|
|
416
421
|
return segment;
|
|
417
422
|
})
|
|
418
423
|
.join("/");
|
|
@@ -29,7 +29,18 @@ const BUILTIN_ERROR_TEMPLATES_DIR = resolve(__dirname, "..", "templates");
|
|
|
29
29
|
/** Built-in public directory for framework-bundled static assets. */
|
|
30
30
|
const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** Read version from root package.json so the banner always matches the published version. */
|
|
33
|
+
function readPackageVersion(): string {
|
|
34
|
+
try {
|
|
35
|
+
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
|
|
36
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
37
|
+
return pkg.version ?? "0.0.0";
|
|
38
|
+
} catch {
|
|
39
|
+
return "0.0.0";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TINA4_VERSION = readPackageVersion();
|
|
33
44
|
|
|
34
45
|
/** Cache Frond instances by template directory to avoid repeated instantiation. */
|
|
35
46
|
const frondCache = new Map<string, InstanceType<any>>();
|
|
@@ -94,7 +105,7 @@ async function renderErrorPage(
|
|
|
94
105
|
templatesDir: string,
|
|
95
106
|
): Promise<string | null> {
|
|
96
107
|
try {
|
|
97
|
-
const { Frond } = await import("
|
|
108
|
+
const { Frond } = await import("../../frond/src/engine.js");
|
|
98
109
|
const templateFile = `errors/${code}.twig`;
|
|
99
110
|
|
|
100
111
|
// Helper: get-or-create a cached Frond instance for a directory
|
|
@@ -419,8 +430,9 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
419
430
|
}
|
|
420
431
|
|
|
421
432
|
// Cluster mode for production: fork workers based on CPU count
|
|
422
|
-
// Only when
|
|
423
|
-
|
|
433
|
+
// Only when --production is explicitly set (via TINA4_PRODUCTION env var)
|
|
434
|
+
const isProduction = (process.env.TINA4_PRODUCTION ?? "").toLowerCase() === "true";
|
|
435
|
+
if (cluster.isPrimary && isProduction) {
|
|
424
436
|
const numCPUs = os.cpus().length;
|
|
425
437
|
if (numCPUs > 1) {
|
|
426
438
|
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
@@ -493,7 +505,7 @@ ${reset}
|
|
|
493
505
|
let frondEngine: any = null;
|
|
494
506
|
setDefaultTemplatesDir(templatesDir);
|
|
495
507
|
try {
|
|
496
|
-
const { Frond } = await import("
|
|
508
|
+
const { Frond } = await import("../../frond/src/engine.js");
|
|
497
509
|
frondEngine = new Frond(templatesDir);
|
|
498
510
|
} catch {
|
|
499
511
|
// Frond not available
|
|
@@ -523,7 +535,7 @@ ${reset}
|
|
|
523
535
|
const hasModelsDir = existsSync(modelsDir);
|
|
524
536
|
if (hasOrmDir || hasModelsDir) {
|
|
525
537
|
try {
|
|
526
|
-
const orm = await import("
|
|
538
|
+
const orm = await import("../../orm/src/index.js");
|
|
527
539
|
const dbConfig = config?.database ?? {};
|
|
528
540
|
await orm.initDatabase({
|
|
529
541
|
type: dbConfig.type ?? "sqlite",
|
|
@@ -571,13 +583,13 @@ ${reset}
|
|
|
571
583
|
|
|
572
584
|
// Initialize Swagger
|
|
573
585
|
try {
|
|
574
|
-
const swagger = await import("
|
|
586
|
+
const swagger = await import("../../swagger/src/index.js");
|
|
575
587
|
const allRoutes = router.getRoutes();
|
|
576
588
|
|
|
577
589
|
// Collect model definitions for schema generation
|
|
578
590
|
let modelDefs: Array<{ tableName: string; fields: Record<string, unknown> }> = [];
|
|
579
591
|
try {
|
|
580
|
-
const orm = await import("
|
|
592
|
+
const orm = await import("../../orm/src/index.js");
|
|
581
593
|
const allModelDirs = [ormDir, modelsDir].filter((d) => existsSync(d));
|
|
582
594
|
const seenTables = new Set<string>();
|
|
583
595
|
for (const dir of allModelDirs) {
|
|
@@ -729,7 +741,9 @@ ${reset}
|
|
|
729
741
|
}
|
|
730
742
|
|
|
731
743
|
// Auth enforcement: secure routes require a valid Bearer token
|
|
732
|
-
|
|
744
|
+
// Dev admin routes (/__dev) are always public
|
|
745
|
+
const isDevAdmin = pathname.startsWith("/__dev");
|
|
746
|
+
if (match.secure === true && match.noAuth !== true && !isDevAdmin) {
|
|
733
747
|
const authHeader = req.headers.authorization ?? "";
|
|
734
748
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
735
749
|
const secret = process.env.SECRET || "";
|
|
@@ -874,12 +888,15 @@ ${reset}
|
|
|
874
888
|
Dashboard: http://localhost:${port}/__dev
|
|
875
889
|
Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})
|
|
876
890
|
`);
|
|
877
|
-
|
|
891
|
+
const noBrowser = isTruthy(process.env.TINA4_NO_BROWSER);
|
|
892
|
+
if (!noBrowser) {
|
|
893
|
+
openBrowser(`http://${displayHost}:${port}`);
|
|
894
|
+
}
|
|
878
895
|
resolvePromise({
|
|
879
896
|
close: () => {
|
|
880
897
|
server.close();
|
|
881
898
|
// Close database if ORM was initialized
|
|
882
|
-
import("
|
|
899
|
+
import("../../orm/src/index.js").then((orm) => orm.closeDatabase()).catch(() => {});
|
|
883
900
|
},
|
|
884
901
|
router,
|
|
885
902
|
port,
|
|
@@ -100,15 +100,23 @@ export class FileSessionHandler implements SessionHandler {
|
|
|
100
100
|
try {
|
|
101
101
|
if (!existsSync(filePath)) return null;
|
|
102
102
|
const raw = readFileSync(filePath, "utf-8");
|
|
103
|
-
|
|
103
|
+
const wrapper = JSON.parse(raw);
|
|
104
|
+
// Check expiry
|
|
105
|
+
if (wrapper._expires && wrapper._expires > 0 && Date.now() / 1000 > wrapper._expires) {
|
|
106
|
+
try { unlinkSync(filePath); } catch { /* ignore */ }
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return (wrapper._data ?? wrapper) as SessionData;
|
|
104
110
|
} catch {
|
|
105
111
|
return null;
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
write(sessionId: string, data: SessionData,
|
|
115
|
+
write(sessionId: string, data: SessionData, ttl: number): void {
|
|
110
116
|
this.ensureDir();
|
|
111
|
-
|
|
117
|
+
const expires = ttl > 0 ? Math.floor(Date.now() / 1000) + ttl : 0;
|
|
118
|
+
const wrapper = { _data: data, _expires: expires };
|
|
119
|
+
writeFileSync(this.filePath(sessionId), JSON.stringify(wrapper), "utf-8");
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
destroy(sessionId: string): void {
|
|
@@ -118,7 +126,7 @@ export class FileSessionHandler implements SessionHandler {
|
|
|
118
126
|
} catch { /* ignore */ }
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
gc(
|
|
129
|
+
gc(_maxLifetime: number): void {
|
|
122
130
|
if (!existsSync(this.storagePath)) return;
|
|
123
131
|
const now = Math.floor(Date.now() / 1000);
|
|
124
132
|
try {
|
|
@@ -128,8 +136,8 @@ export class FileSessionHandler implements SessionHandler {
|
|
|
128
136
|
const fullPath = join(this.storagePath, file);
|
|
129
137
|
try {
|
|
130
138
|
const raw = readFileSync(fullPath, "utf-8");
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
139
|
+
const wrapper = JSON.parse(raw);
|
|
140
|
+
if (wrapper._expires && wrapper._expires > 0 && now > wrapper._expires) {
|
|
133
141
|
unlinkSync(fullPath);
|
|
134
142
|
}
|
|
135
143
|
} catch {
|
|
@@ -45,7 +45,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
45
45
|
const items = adapter.query(sql, params);
|
|
46
46
|
const [{ total }] = adapter.query<{ total: number }>(countSql, countParams);
|
|
47
47
|
|
|
48
|
-
const limit = options.limit ??
|
|
48
|
+
const limit = options.limit ?? 100;
|
|
49
49
|
const page = options.page ?? 1;
|
|
50
50
|
|
|
51
51
|
res.json({
|
|
@@ -73,18 +73,27 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
|
|
|
73
73
|
return this.records;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/** Pagination envelope. */
|
|
77
|
-
toPaginate(): {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
/** Pagination envelope with slicing. */
|
|
77
|
+
toPaginate(page = 1, perPage = 10): {
|
|
78
|
+
data: Record<string, unknown>[];
|
|
79
|
+
page: number;
|
|
80
|
+
per_page: number;
|
|
81
|
+
total: number;
|
|
82
|
+
total_pages: number;
|
|
83
|
+
has_next: boolean;
|
|
84
|
+
has_prev: boolean;
|
|
82
85
|
} {
|
|
86
|
+
const totalPages = Math.max(1, Math.ceil(this.count / perPage));
|
|
87
|
+
const start = (page - 1) * perPage;
|
|
88
|
+
const end = start + perPage;
|
|
83
89
|
return {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
data: this.records.slice(start, end),
|
|
91
|
+
page,
|
|
92
|
+
per_page: perPage,
|
|
93
|
+
total: this.count,
|
|
94
|
+
total_pages: totalPages,
|
|
95
|
+
has_next: page < totalPages,
|
|
96
|
+
has_prev: page > 1,
|
|
88
97
|
};
|
|
89
98
|
}
|
|
90
99
|
|