tina4-nodejs 3.0.0-rc.2 → 3.1.0

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.
Files changed (31) hide show
  1. package/BENCHMARK_REPORT.md +248 -86
  2. package/CARBONAH.md +4 -4
  3. package/CLAUDE.md +16 -1
  4. package/COMPARISON.md +58 -46
  5. package/README.md +60 -6
  6. package/package.json +2 -1
  7. package/packages/cli/src/bin.ts +8 -0
  8. package/packages/cli/src/commands/generate.ts +237 -0
  9. package/packages/core/gallery/queue/meta.json +1 -1
  10. package/packages/core/gallery/queue/src/lib/queueDb.ts +32 -0
  11. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +28 -0
  12. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +28 -0
  13. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +20 -10
  14. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +25 -0
  15. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +36 -6
  16. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +160 -0
  17. package/packages/core/src/cache.ts +402 -10
  18. package/packages/core/src/index.ts +5 -2
  19. package/packages/core/src/messenger.ts +118 -36
  20. package/packages/core/src/queue.ts +172 -92
  21. package/packages/core/src/response.ts +46 -0
  22. package/packages/core/src/router.ts +94 -1
  23. package/packages/core/src/server.ts +66 -7
  24. package/packages/core/src/types.ts +20 -1
  25. package/packages/core/src/websocketConnection.ts +16 -0
  26. package/packages/frond/src/engine.ts +184 -6
  27. package/packages/orm/src/baseModel.ts +274 -20
  28. package/packages/orm/src/cachedDatabase.ts +180 -0
  29. package/packages/orm/src/index.ts +4 -0
  30. package/packages/orm/src/model.ts +1 -0
  31. package/packages/orm/src/types.ts +75 -0
@@ -3,6 +3,8 @@ import { resolve, dirname, join, relative } from "node:path";
3
3
  import { existsSync, readdirSync, statSync } from "node:fs";
4
4
  import { isatty } from "node:tty";
5
5
  import { fileURLToPath } from "node:url";
6
+ import cluster from "node:cluster";
7
+ import os from "node:os";
6
8
  import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
7
9
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
8
10
  import { discoverRoutes } from "./routeDiscovery.js";
@@ -280,14 +282,68 @@ export async function startServer(config?: Tina4Config): Promise<{
280
282
  router: Router;
281
283
  port: number;
282
284
  }> {
285
+ // Load .env early so TINA4_DEBUG is available for cluster decision
286
+ loadEnv();
287
+
283
288
  const { port, host } = resolvePortAndHost(config);
284
- const routesDir = resolve(config?.routesDir ?? "src/routes");
285
- const modelsDir = resolve(config?.modelsDir ?? "src/models");
286
- const staticDir = resolve(config?.staticDir ?? "public");
287
- const templatesDir = resolve(config?.templatesDir ?? "src/templates");
288
289
 
289
- // Load .env file
290
- loadEnv();
290
+ // Cluster mode for production: fork workers based on CPU count
291
+ // Only when not in dev mode and running as primary process
292
+ if (cluster.isPrimary && !isDevMode()) {
293
+ const numCPUs = os.cpus().length;
294
+ if (numCPUs > 1) {
295
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
296
+ const isTty = isatty(1);
297
+ const color = isTty ? "\x1b[32m" : "";
298
+ const reset = isTty ? "\x1b[0m" : "";
299
+ const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
300
+
301
+ console.log(`${color}
302
+ ______ _ __ __
303
+ /_ __/(_)___ ____ _/ // /
304
+ / / / / __ \\/ __ \`/ // /_
305
+ / / / / / / / /_/ /__ __/
306
+ /_/ /_/_/ /_/\\__,_/ /_/
307
+ ${reset}
308
+ Tina4 Node.js v${TINA4_VERSION} — This is not a framework
309
+
310
+ Server: http://${displayHost}:${port} (cluster, ${numCPUs} workers)
311
+ Swagger: http://localhost:${port}/swagger
312
+ Dashboard: http://localhost:${port}/__dev
313
+ Debug: OFF (Log level: ${logLevel})
314
+ `);
315
+
316
+ for (let i = 0; i < numCPUs; i++) {
317
+ cluster.fork();
318
+ }
319
+
320
+ cluster.on("exit", (worker, code, _signal) => {
321
+ if (code !== 0) {
322
+ console.log(` Worker ${worker.process.pid} exited (code ${code}), restarting...`);
323
+ cluster.fork();
324
+ }
325
+ });
326
+
327
+ // Return a handle that kills all workers
328
+ return {
329
+ close: () => {
330
+ for (const id in cluster.workers) {
331
+ cluster.workers[id]?.kill();
332
+ }
333
+ },
334
+ router: new Router(),
335
+ port,
336
+ };
337
+ }
338
+ }
339
+
340
+ const base = config?.basePath ? resolve(config.basePath) : process.cwd();
341
+ const routesDir = resolve(base, config?.routesDir ?? "src/routes");
342
+ const modelsDir = resolve(base, config?.modelsDir ?? "src/models");
343
+ const staticDir = resolve(base, config?.staticDir ?? "public");
344
+ const templatesDir = resolve(base, config?.templatesDir ?? "src/templates");
345
+
346
+ // .env already loaded above for cluster decision
291
347
 
292
348
  const router = new Router();
293
349
  const middleware = new MiddlewareChain();
@@ -581,6 +637,9 @@ export async function startServer(config?: Tina4Config): Promise<{
581
637
  const color = isTty ? "\x1b[32m" : "";
582
638
  const reset = isTty ? "\x1b[0m" : "";
583
639
 
640
+ // Determine server mode label
641
+ const serverMode = isDebug ? "single" : (cluster.isWorker ? "cluster-worker" : "single");
642
+
584
643
  // Banner goes to stdout via console.log — NOT through the framework logger
585
644
  console.log(`${color}
586
645
  ______ _ __ __
@@ -591,7 +650,7 @@ export async function startServer(config?: Tina4Config): Promise<{
591
650
  ${reset}
592
651
  Tina4 Node.js v${TINA4_VERSION} — This is not a framework
593
652
 
594
- Server: http://${displayHost}:${port}
653
+ Server: http://${displayHost}:${port} (${serverMode})
595
654
  Swagger: http://localhost:${port}/swagger
596
655
  Dashboard: http://localhost:${port}/__dev
597
656
  Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})
@@ -36,7 +36,8 @@ export interface Tina4ResponseMethods {
36
36
  redirect(url: string, code?: number): Tina4Response;
37
37
  cookie(name: string, value: string, options?: CookieOptions): Tina4Response;
38
38
  clearCookie(name: string, options?: CookieOptions): Tina4Response;
39
- render?(template: string, data?: Record<string, unknown>): void;
39
+ file(path: string, options?: { download?: boolean; contentType?: string }): Tina4Response;
40
+ render(template: string, data?: Record<string, unknown>): Promise<Tina4Response>;
40
41
  template(name: string, data?: Record<string, unknown>): Promise<Tina4Response>;
41
42
  /** The underlying ServerResponse for advanced use */
42
43
  raw: ServerResponse;
@@ -80,6 +81,9 @@ export interface RouteMeta {
80
81
  export interface Tina4Config {
81
82
  port?: number;
82
83
  host?: string;
84
+ /** Base directory for the project. When set, routesDir, modelsDir, templatesDir,
85
+ * and staticDir are resolved relative to this path instead of process.cwd(). */
86
+ basePath?: string;
83
87
  routesDir?: string;
84
88
  modelsDir?: string;
85
89
  templatesDir?: string;
@@ -96,3 +100,18 @@ export type Middleware = (
96
100
  res: Tina4Response,
97
101
  next: () => void
98
102
  ) => void | Promise<void>;
103
+
104
+ /**
105
+ * Handler for WebSocket routes.
106
+ * conn — a connection-like object with send/close methods.
107
+ * message — the incoming text or binary message.
108
+ */
109
+ export type WebSocketRouteHandler = (
110
+ conn: { id: string; send: (data: string) => void; close: () => void },
111
+ message: string | Buffer,
112
+ ) => void | Promise<void>;
113
+
114
+ export interface WebSocketRouteDefinition {
115
+ pattern: string;
116
+ handler: WebSocketRouteHandler;
117
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Represents a WebSocket connection with send/broadcast/close capabilities.
3
+ * Used by Router.websocket() route handlers.
4
+ */
5
+ export interface WebSocketConnection {
6
+ /** Unique connection identifier */
7
+ id: string;
8
+ /** Send a message to this connection */
9
+ send(message: string): void;
10
+ /** Broadcast a message to all other connections on the same path */
11
+ broadcast(message: string): void;
12
+ /** Close this connection */
13
+ close(): void;
14
+ /** The WebSocket route path this connection is on */
15
+ path: string;
16
+ }
@@ -5,7 +5,7 @@
5
5
  * extends/block, include, macro, set, comments, whitespace control, tests.
6
6
  */
7
7
  import { createHash, createHmac } from "node:crypto";
8
- import { readFileSync, existsSync } from "node:fs";
8
+ import { readFileSync, existsSync, statSync } from "node:fs";
9
9
  import { join, resolve } from "node:path";
10
10
 
11
11
  // ── Types ──────────────────────────────────────────────────────
@@ -183,6 +183,13 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
183
183
  }
184
184
  }
185
185
 
186
+ // Jinja2-style inline if: value if condition else other_value
187
+ const inlineIfMatch = expr.match(/^(.+?)\s+if\s+(.+?)\s+else\s+(.+)$/);
188
+ if (inlineIfMatch) {
189
+ const cond = evalExpr(inlineIfMatch[2], context);
190
+ return cond ? evalExpr(inlineIfMatch[1], context) : evalExpr(inlineIfMatch[3], context);
191
+ }
192
+
186
193
  // Null coalescing: value ?? "default"
187
194
  const qqIdx = expr.indexOf("??");
188
195
  if (qqIdx !== -1) {
@@ -825,6 +832,11 @@ export class Frond {
825
832
  private _allowedTags: Set<string> | null;
826
833
  private _allowedVars: Set<string> | null;
827
834
  private fragmentCache: Map<string, [string, number]>;
835
+ private _autoEscape: boolean;
836
+ /** Token pre-compilation cache for file templates */
837
+ private compiled = new Map<string, { tokens: Token[]; mtime: number }>();
838
+ /** Token pre-compilation cache for string templates */
839
+ private compiledStrings = new Map<string, Token[]>();
828
840
 
829
841
  constructor(templateDir: string = "src/templates") {
830
842
  this.templateDir = resolve(templateDir);
@@ -836,6 +848,7 @@ export class Frond {
836
848
  this._allowedTags = null;
837
849
  this._allowedVars = null;
838
850
  this.fragmentCache = new Map();
851
+ this._autoEscape = true;
839
852
 
840
853
  // Built-in global functions
841
854
  this.globals.formToken = (descriptor?: string) => _generateFormToken(descriptor || "");
@@ -872,13 +885,54 @@ export class Frond {
872
885
 
873
886
  render(template: string, data?: Record<string, unknown>): string {
874
887
  const context = { ...this.globals, ...(data || {}) };
875
- const source = this.load(template);
876
- return this.execute(source, context);
888
+ const filePath = join(this.templateDir, template);
889
+
890
+ if (!existsSync(filePath)) {
891
+ throw new Error(`Template not found: ${filePath}`);
892
+ }
893
+
894
+ const debugMode = (process.env.TINA4_DEBUG || "").toLowerCase() === "true";
895
+ const cached = this.compiled.get(template);
896
+
897
+ if (cached) {
898
+ if (debugMode) {
899
+ // Dev mode: check if file changed
900
+ const mtime = statSync(filePath).mtimeMs;
901
+ if (cached.mtime === mtime) {
902
+ return this.executeCached(cached.tokens, context);
903
+ }
904
+ } else {
905
+ // Production: skip mtime check, cache is permanent
906
+ return this.executeCached(cached.tokens, context);
907
+ }
908
+ }
909
+
910
+ // Cache miss — load, tokenize, cache
911
+ const source = readFileSync(filePath, "utf-8");
912
+ const mtime = statSync(filePath).mtimeMs;
913
+ const tokens = tokenize(source);
914
+ this.compiled.set(template, { tokens, mtime });
915
+ return this.executeWithSource(source, tokens, context);
877
916
  }
878
917
 
879
918
  renderString(source: string, data?: Record<string, unknown>): string {
880
919
  const context = { ...this.globals, ...(data || {}) };
881
- return this.execute(source, context);
920
+
921
+ const key = createHash("md5").update(source).digest("hex");
922
+ const cachedTokens = this.compiledStrings.get(key);
923
+ if (cachedTokens) {
924
+ return this.executeCached(cachedTokens, context);
925
+ }
926
+
927
+ const tokens = tokenize(source);
928
+ this.compiledStrings.set(key, tokens);
929
+ return this.executeCached(tokens, context);
930
+ }
931
+
932
+ /** Clear all compiled template caches. */
933
+ clearCache(): void {
934
+ this.compiled.clear();
935
+ this.compiledStrings.clear();
882
936
  }
883
937
 
884
938
  private load(name: string): string {
@@ -889,6 +943,48 @@ export class Frond {
889
943
  return readFileSync(filePath, "utf-8");
890
944
  }
891
945
 
946
+ /** Execute pre-tokenized template against context. */
947
+ private executeCached(tokens: Token[], context: Record<string, unknown>): string {
948
+ if (Object.keys(this.tests).length > 0) {
949
+ context.__frond_tests__ = this.tests;
950
+ }
951
+
952
+ // Check if first non-text token is an extends block
953
+ for (const [ttype, raw] of tokens) {
954
+ if (ttype === "TEXT") {
955
+ if (raw.trim()) break;
956
+ continue;
957
+ }
958
+ if (ttype === "BLOCK") {
959
+ const [content] = stripTag(raw);
960
+ if (content.startsWith("extends ")) {
961
+ // Extends requires source-based execution for block extraction
962
+ const source = tokens.map(([, v]) => v).join("");
963
+ return this.execute(source, context);
964
+ }
965
+ }
966
+ break;
967
+ }
968
+ return this.renderTokens(tokens, context);
969
+ }
970
+
971
+ /** Execute with both source and pre-tokenized tokens available. */
972
+ private executeWithSource(source: string, tokens: Token[], context: Record<string, unknown>): string {
973
+ if (Object.keys(this.tests).length > 0) {
974
+ context.__frond_tests__ = this.tests;
975
+ }
976
+
977
+ const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
978
+ if (extendsMatch) {
979
+ const parentName = extendsMatch[1];
980
+ const parentSource = this.load(parentName);
981
+ const childBlocks = this.extractBlocks(source);
982
+ return this.renderWithBlocks(parentSource, context, childBlocks);
983
+ }
984
+
985
+ return this.renderTokens(tokens, context);
986
+ }
987
+
892
988
  private execute(source: string, context: Record<string, unknown>): string {
893
989
  // Inject custom tests into context for evalTest to find
894
990
  if (Object.keys(this.tests).length > 0) {
@@ -1015,6 +1111,14 @@ export class Frond {
1015
1111
  const [result, skip] = this.handleCache(tokens, i, context);
1016
1112
  output.push(result);
1017
1113
  i = skip;
1114
+ } else if (tag === "spaceless") {
1115
+ const [result, skip] = this.handleSpaceless(tokens, i, context);
1116
+ output.push(result);
1117
+ i = skip;
1118
+ } else if (tag === "autoescape") {
1119
+ const [result, skip] = this.handleAutoescape(tokens, i, context);
1120
+ output.push(result);
1121
+ i = skip;
1018
1122
  } else if (tag === "block" || tag === "endblock" || tag === "extends") {
1019
1123
  i++; // Already handled
1020
1124
  } else {
@@ -1092,8 +1196,8 @@ export class Frond {
1092
1196
  return value.value;
1093
1197
  }
1094
1198
 
1095
- // Auto-escape HTML unless marked safe
1096
- if (!isSafe && typeof value === "string") {
1199
+ // Auto-escape HTML unless marked safe or auto-escape is disabled
1200
+ if (!isSafe && this._autoEscape && typeof value === "string") {
1097
1201
  value = htmlEscape(value);
1098
1202
  }
1099
1203
 
@@ -1472,4 +1576,78 @@ export class Frond {
1472
1576
  this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1000]);
1473
1577
  return [rendered, i];
1474
1578
  }
1579
+
1580
+ private handleSpaceless(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
1581
+ const bodyTokens: Token[] = [];
1582
+ let i = start + 1;
1583
+ let depth = 0;
1584
+ while (i < tokens.length) {
1585
+ if (tokens[i][0] === "BLOCK") {
1586
+ const [tagContent] = stripTag(tokens[i][1]);
1587
+ const tag = tagContent.split(/\s+/)[0] || "";
1588
+ if (tag === "spaceless") {
1589
+ depth++;
1590
+ bodyTokens.push(tokens[i]);
1591
+ } else if (tag === "endspaceless") {
1592
+ if (depth === 0) {
1593
+ i++;
1594
+ break;
1595
+ }
1596
+ depth--;
1597
+ bodyTokens.push(tokens[i]);
1598
+ } else {
1599
+ bodyTokens.push(tokens[i]);
1600
+ }
1601
+ } else {
1602
+ bodyTokens.push(tokens[i]);
1603
+ }
1604
+ i++;
1605
+ }
1606
+
1607
+ let rendered = this.renderTokens([...bodyTokens], context);
1608
+ rendered = rendered.replace(/>\s+</g, "><");
1609
+ return [rendered, i];
1610
+ }
1611
+
1612
+ private handleAutoescape(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
1613
+ const [content] = stripTag(tokens[start][1]);
1614
+ const modeMatch = content.match(/^autoescape\s+(false|true)/);
1615
+ const autoEscapeOn = !(modeMatch && modeMatch[1] === "false");
1616
+
1617
+ const bodyTokens: Token[] = [];
1618
+ let i = start + 1;
1619
+ let depth = 0;
1620
+ while (i < tokens.length) {
1621
+ if (tokens[i][0] === "BLOCK") {
1622
+ const [tagContent] = stripTag(tokens[i][1]);
1623
+ const tag = tagContent.split(/\s+/)[0] || "";
1624
+ if (tag === "autoescape") {
1625
+ depth++;
1626
+ bodyTokens.push(tokens[i]);
1627
+ } else if (tag === "endautoescape") {
1628
+ if (depth === 0) {
1629
+ i++;
1630
+ break;
1631
+ }
1632
+ depth--;
1633
+ bodyTokens.push(tokens[i]);
1634
+ } else {
1635
+ bodyTokens.push(tokens[i]);
1636
+ }
1637
+ } else {
1638
+ bodyTokens.push(tokens[i]);
1639
+ }
1640
+ i++;
1641
+ }
1642
+
1643
+ if (!autoEscapeOn) {
1644
+ const oldAutoEscape = this._autoEscape;
1645
+ this._autoEscape = false;
1646
+ const rendered = this.renderTokens([...bodyTokens], context);
1647
+ this._autoEscape = oldAutoEscape;
1648
+ return [rendered, i];
1649
+ }
1650
+
1651
+ return [this.renderTokens([...bodyTokens], context), i];
1652
+ }
1475
1653
  }