tina4-nodejs 3.10.48 → 3.10.55

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.48",
3
+ "version": "3.10.55",
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"],
@@ -46,6 +46,7 @@ const HELP = `
46
46
  Options:
47
47
  --port <number> Server port (default: 7148)
48
48
  --no-browser Don't open the browser on serve
49
+ --no-reload Disable file watcher / live-reload on serve
49
50
  --all Install AI context for all tools (with ai command)
50
51
  --force Overwrite existing AI context files (with ai command)
51
52
  --help Show this help message
@@ -85,11 +86,12 @@ async function main(): Promise<void> {
85
86
  const portIndex = args.indexOf("--port");
86
87
  const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : 7148;
87
88
  const noBrowser = args.includes("--no-browser");
89
+ const noReload = args.includes("--no-reload");
88
90
 
89
91
  // Kill any existing process on the port
90
92
  killProcessOnPort(port);
91
93
 
92
- await serveProject({ port, noBrowser });
94
+ await serveProject({ port, noBrowser, noReload });
93
95
  break;
94
96
  }
95
97
  case "migrate": {
@@ -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"),
@@ -4,9 +4,14 @@ import { existsSync } from "node:fs";
4
4
  export interface ServeOptions {
5
5
  port?: number;
6
6
  noBrowser?: boolean;
7
+ noReload?: boolean;
7
8
  }
8
9
 
9
10
  export async function serveProject(options: ServeOptions): Promise<void> {
11
+ if (options.noReload) {
12
+ process.env.TINA4_NO_RELOAD = "true";
13
+ }
14
+
10
15
  const port = options.port ?? 7148;
11
16
  const cwd = process.cwd();
12
17
 
@@ -21,8 +26,8 @@ export async function serveProject(options: ServeOptions): Promise<void> {
21
26
  process.exit(1);
22
27
  }
23
28
 
24
- const { startServer } = await import("@tina4/core");
25
- const { watchForChanges } = await import("@tina4/core/src/watcher.js");
29
+ const { startServer } = await import("../../../core/src/index.js");
30
+ const { watchForChanges } = await import("../../../core/src/watcher.js");
26
31
 
27
32
  const server = await startServer({
28
33
  port,
@@ -33,26 +38,30 @@ export async function serveProject(options: ServeOptions): Promise<void> {
33
38
  });
34
39
 
35
40
  // Watch for file changes and reload routes
41
+ const noReload = ["true", "1", "yes"].includes((process.env.TINA4_NO_RELOAD ?? "").toLowerCase());
36
42
  const watchDirs = [routesDir, ormDir, modelsDir, templatesDir].filter((d) => existsSync(d));
37
- const watcher = watchForChanges(watchDirs, async () => {
38
- try {
39
- const { discoverRoutes } = await import("@tina4/core");
40
- // Clear routes BEFORE re-discovery to avoid stale duplicates
41
- server.router.clear();
42
- const routes = await discoverRoutes(routesDir);
43
- for (const route of routes) {
44
- server.router.addRoute(route);
43
+ let watcher: { close: () => void } | null = null;
44
+ if (!noReload) {
45
+ watcher = watchForChanges(watchDirs, async () => {
46
+ try {
47
+ const { discoverRoutes } = await import("../../../core/src/index.js");
48
+ // Clear routes BEFORE re-discovery to avoid stale duplicates
49
+ server.router.clear();
50
+ const routes = await discoverRoutes(routesDir);
51
+ for (const route of routes) {
52
+ server.router.addRoute(route);
53
+ }
54
+ console.log(` Reloaded ${routes.length} route(s)`);
55
+ } catch (err) {
56
+ console.error(" Error reloading routes:", err);
45
57
  }
46
- console.log(` Reloaded ${routes.length} route(s)`);
47
- } catch (err) {
48
- console.error(" Error reloading routes:", err);
49
- }
50
- });
58
+ });
59
+ }
51
60
 
52
61
  // Graceful shutdown
53
62
  const shutdown = () => {
54
63
  console.log("\n Shutting down...");
55
- watcher.close();
64
+ watcher?.close();
56
65
  server.close();
57
66
  process.exit(0);
58
67
  };
@@ -2467,7 +2467,7 @@ function renderBubbleChart(files,depGraph,scanMode){
2467
2467
  bubbles.forEach(function(b,i){if(b.f.path===src)srcIdx=i;});
2468
2468
  if(srcIdx===null)return;
2469
2469
  (depGraph[src]||[]).forEach(function(tgt){
2470
- var tgtName=tgt.split('.').pop().toLowerCase();
2470
+ var tgtName=basename(tgt);
2471
2471
  var tgtIdx=nameIdx[tgtName];
2472
2472
  if(tgtIdx!==undefined&&srcIdx!==tgtIdx)edges.push([srcIdx,tgtIdx]);
2473
2473
  });
@@ -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
- const TINA4_VERSION = "3.10.30";
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("@tina4/frond");
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
@@ -494,7 +505,7 @@ ${reset}
494
505
  let frondEngine: any = null;
495
506
  setDefaultTemplatesDir(templatesDir);
496
507
  try {
497
- const { Frond } = await import("@tina4/frond");
508
+ const { Frond } = await import("../../frond/src/engine.js");
498
509
  frondEngine = new Frond(templatesDir);
499
510
  } catch {
500
511
  // Frond not available
@@ -524,7 +535,7 @@ ${reset}
524
535
  const hasModelsDir = existsSync(modelsDir);
525
536
  if (hasOrmDir || hasModelsDir) {
526
537
  try {
527
- const orm = await import("@tina4/orm");
538
+ const orm = await import("../../orm/src/index.js");
528
539
  const dbConfig = config?.database ?? {};
529
540
  await orm.initDatabase({
530
541
  type: dbConfig.type ?? "sqlite",
@@ -572,13 +583,13 @@ ${reset}
572
583
 
573
584
  // Initialize Swagger
574
585
  try {
575
- const swagger = await import("@tina4/swagger");
586
+ const swagger = await import("../../swagger/src/index.js");
576
587
  const allRoutes = router.getRoutes();
577
588
 
578
589
  // Collect model definitions for schema generation
579
590
  let modelDefs: Array<{ tableName: string; fields: Record<string, unknown> }> = [];
580
591
  try {
581
- const orm = await import("@tina4/orm");
592
+ const orm = await import("../../orm/src/index.js");
582
593
  const allModelDirs = [ormDir, modelsDir].filter((d) => existsSync(d));
583
594
  const seenTables = new Set<string>();
584
595
  for (const dir of allModelDirs) {
@@ -662,7 +673,17 @@ ${reset}
662
673
  const requestId = Date.now().toString(36);
663
674
 
664
675
  // Wrap res.raw.end to inject dev toolbar and capture requests
665
- if (isDevMode() && !pathname.startsWith("/__dev")) {
676
+ // Skip toolbar injection on the AI port (no-reload behaviour)
677
+ const isAiPortRequest = !!(rawReq as any)._tina4AiPort;
678
+
679
+ // AI port: block /__dev_reload so AI tools never trigger a browser reload
680
+ if (isAiPortRequest && pathname === "/__dev_reload") {
681
+ res.raw.writeHead(404, { "Content-Type": "application/json" });
682
+ res.raw.end(JSON.stringify({ error: "Not available on AI port" }));
683
+ return;
684
+ }
685
+
686
+ if (isDevMode() && !pathname.startsWith("/__dev") && !isAiPortRequest) {
666
687
  const originalEnd = res.raw.end.bind(res.raw);
667
688
 
668
689
  const wrappedEnd: typeof res.raw.end = function (
@@ -730,7 +751,9 @@ ${reset}
730
751
  }
731
752
 
732
753
  // Auth enforcement: secure routes require a valid Bearer token
733
- if (match.secure === true && match.noAuth !== true) {
754
+ // Dev admin routes (/__dev) are always public
755
+ const isDevAdmin = pathname.startsWith("/__dev");
756
+ if (match.secure === true && match.noAuth !== true && !isDevAdmin) {
734
757
  const authHeader = req.headers.authorization ?? "";
735
758
  const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
736
759
  const secret = process.env.SECRET || "";
@@ -860,7 +883,35 @@ ${reset}
860
883
  // Determine server mode label
861
884
  const serverMode = isDebug ? "single" : (cluster.isWorker ? "cluster-worker" : "single");
862
885
 
886
+ // AI dual-port: start a second HTTP server on port+1 when in debug mode
887
+ // and TINA4_NO_AI_PORT is not set. This port serves requests without
888
+ // dev-reload or toolbar injection so AI coding tools get stable responses.
889
+ const noAiPort = isTruthy(process.env.TINA4_NO_AI_PORT ?? "");
890
+ let aiServer: ReturnType<typeof createServer> | null = null;
891
+ let aiPort = port + 1;
892
+
893
+ if (isDebug && !noAiPort) {
894
+ aiServer = createServer(async (req, res) => {
895
+ // Tag the request so dispatch knows it came from the AI port
896
+ (req as any)._tina4AiPort = true;
897
+ await dispatch(req, res);
898
+ });
899
+
900
+ aiServer.on("error", (err: any) => {
901
+ if (err.code === "EADDRINUSE") {
902
+ Log.warn(`AI port ${aiPort} in use — skipping`);
903
+ aiServer = null;
904
+ }
905
+ });
906
+
907
+ aiServer.listen(aiPort, host);
908
+ }
909
+
863
910
  // Banner goes to stdout via console.log — NOT through the framework logger
911
+ const aiPortLine = (isDebug && !noAiPort)
912
+ ? `\n AI Port: http://localhost:${aiPort} (no-reload)`
913
+ : "";
914
+
864
915
  console.log(`${color}
865
916
  ______ _ __ __
866
917
  /_ __/(_)___ ____ _/ // /
@@ -873,14 +924,18 @@ ${reset}
873
924
  Server: http://${displayHost}:${port} (${serverMode})
874
925
  Swagger: http://localhost:${port}/swagger
875
926
  Dashboard: http://localhost:${port}/__dev
876
- Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})
927
+ Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})${aiPortLine}
877
928
  `);
878
- openBrowser(`http://${displayHost}:${port}`);
929
+ const noBrowser = isTruthy(process.env.TINA4_NO_BROWSER);
930
+ if (!noBrowser) {
931
+ openBrowser(`http://${displayHost}:${port}`);
932
+ }
879
933
  resolvePromise({
880
934
  close: () => {
935
+ if (aiServer) aiServer.close();
881
936
  server.close();
882
937
  // Close database if ORM was initialized
883
- import("@tina4/orm").then((orm) => orm.closeDatabase()).catch(() => {});
938
+ import("../../orm/src/index.js").then((orm) => orm.closeDatabase()).catch(() => {});
884
939
  },
885
940
  router,
886
941
  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
- return JSON.parse(raw) as SessionData;
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, _ttl: number): void {
115
+ write(sessionId: string, data: SessionData, ttl: number): void {
110
116
  this.ensureDir();
111
- writeFileSync(this.filePath(sessionId), JSON.stringify(data), "utf-8");
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(maxLifetime: number): void {
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 data = JSON.parse(raw) as SessionData;
132
- if (data._accessed && (now - data._accessed) > maxLifetime) {
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 {