tina4-nodejs 3.0.0-rc.2

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 (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,31 @@
1
+ import type { RouteDefinition } from "./types.js";
2
+
3
+ /** Server start time, set when health route is created */
4
+ let startTime: number;
5
+
6
+ /**
7
+ * Create the /health route definition.
8
+ * Returns a RouteDefinition that can be added to the router.
9
+ */
10
+ export function createHealthRoute(version: string = "3.0.0"): RouteDefinition {
11
+ startTime = Date.now();
12
+
13
+ return {
14
+ method: "GET",
15
+ pattern: "/health",
16
+ handler: (_req, res) => {
17
+ const uptimeSeconds = (Date.now() - startTime) / 1000;
18
+ res.json({
19
+ status: "ok",
20
+ version,
21
+ uptime: Math.round(uptimeSeconds * 100) / 100,
22
+ framework: "tina4-nodejs",
23
+ });
24
+ },
25
+ meta: {
26
+ summary: "Health check",
27
+ description: "Returns server health status, version, and uptime.",
28
+ tags: ["System"],
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Programmatic HTML builder — avoids string concatenation.
3
+ *
4
+ * Usage:
5
+ * const el = new HtmlElement("div", { class: "card" }, ["Hello"]);
6
+ * el.toString(); // '<div class="card">Hello</div>'
7
+ *
8
+ * // Builder pattern
9
+ * const el = htmlElement("div")(htmlElement("p")("Text"));
10
+ *
11
+ * // Helper functions
12
+ * const h: Record<string, any> = {};
13
+ * addHtmlHelpers(h);
14
+ * const html = h._div({ class: "card" }, h._p("Hello"));
15
+ */
16
+
17
+ const VOID_TAGS: ReadonlySet<string> = new Set([
18
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
19
+ "link", "meta", "param", "source", "track", "wbr",
20
+ ]);
21
+
22
+ const HTML_TAGS: readonly string[] = [
23
+ "a", "abbr", "address", "area", "article", "aside", "audio",
24
+ "b", "base", "bdi", "bdo", "blockquote", "body", "br", "button",
25
+ "canvas", "caption", "cite", "code", "col", "colgroup",
26
+ "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt",
27
+ "em", "embed",
28
+ "fieldset", "figcaption", "figure", "footer", "form",
29
+ "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html",
30
+ "i", "iframe", "img", "input", "ins",
31
+ "kbd",
32
+ "label", "legend", "li", "link",
33
+ "main", "map", "mark", "menu", "meta", "meter",
34
+ "nav", "noscript",
35
+ "object", "ol", "optgroup", "option", "output",
36
+ "p", "param", "picture", "pre", "progress",
37
+ "q",
38
+ "rp", "rt", "ruby",
39
+ "s", "samp", "script", "section", "select", "slot", "small", "source", "span",
40
+ "strong", "style", "sub", "summary", "sup",
41
+ "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time",
42
+ "title", "tr", "track",
43
+ "u", "ul",
44
+ "var", "video",
45
+ "wbr",
46
+ ];
47
+
48
+ type Attrs = Record<string, string | number | boolean | null | undefined>;
49
+ type Child = string | number | HtmlElement;
50
+
51
+ function escapeAttr(value: string): string {
52
+ return value
53
+ .replace(/&/g, "&amp;")
54
+ .replace(/"/g, "&quot;")
55
+ .replace(/</g, "&lt;")
56
+ .replace(/>/g, "&gt;");
57
+ }
58
+
59
+ function isAttrs(arg: unknown): arg is Attrs {
60
+ return typeof arg === "object" && arg !== null && !(arg instanceof HtmlElement) && !Array.isArray(arg);
61
+ }
62
+
63
+ /**
64
+ * HtmlElement — a single HTML tag with attributes and children.
65
+ */
66
+ export class HtmlElement {
67
+ readonly tag: string;
68
+ readonly attrs: Attrs;
69
+ readonly children: Child[];
70
+
71
+ constructor(tag: string, attrs: Attrs = {}, children: Child[] = []) {
72
+ this.tag = tag.toLowerCase();
73
+ this.attrs = { ...attrs };
74
+ this.children = [...children];
75
+ }
76
+
77
+ /**
78
+ * Render to HTML string.
79
+ */
80
+ toString(): string {
81
+ let html = `<${this.tag}`;
82
+
83
+ for (const [key, value] of Object.entries(this.attrs)) {
84
+ if (value === true) {
85
+ html += ` ${key}`;
86
+ } else if (value !== false && value !== null && value !== undefined) {
87
+ html += ` ${key}="${escapeAttr(String(value))}"`;
88
+ }
89
+ }
90
+
91
+ if (VOID_TAGS.has(this.tag)) {
92
+ return html + ">";
93
+ }
94
+
95
+ html += ">";
96
+
97
+ for (const child of this.children) {
98
+ html += String(child);
99
+ }
100
+
101
+ html += `</${this.tag}>`;
102
+ return html;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Create a callable HTML element builder.
108
+ * Returns a function that, when called with children/attrs, produces a new HtmlElement.
109
+ *
110
+ * Usage:
111
+ * const div = htmlElement("div");
112
+ * const el = div({ class: "card" }, "Hello");
113
+ * console.log(el.toString()); // '<div class="card">Hello</div>'
114
+ */
115
+ export function htmlElement(tag: string, attrs: Attrs = {}, children: Child[] = []) {
116
+ const el = new HtmlElement(tag, attrs, children);
117
+
118
+ const builder = (...args: (Child | Attrs | Child[])[]) => {
119
+ const newAttrs: Attrs = { ...el.attrs };
120
+ const newChildren: Child[] = [...el.children];
121
+
122
+ for (const arg of args) {
123
+ if (isAttrs(arg)) {
124
+ Object.assign(newAttrs, arg);
125
+ } else if (Array.isArray(arg)) {
126
+ newChildren.push(...arg);
127
+ } else {
128
+ newChildren.push(arg as Child);
129
+ }
130
+ }
131
+
132
+ return htmlElement(tag, newAttrs, newChildren);
133
+ };
134
+
135
+ // Expose HtmlElement properties and toString on the builder function
136
+ builder.tag = el.tag;
137
+ builder.attrs = el.attrs;
138
+ builder.children = el.children;
139
+ builder.toString = () => el.toString();
140
+ builder._isHtmlElement = true;
141
+
142
+ return builder;
143
+ }
144
+
145
+ /**
146
+ * Injects helper functions (_div, _p, _a, _span, etc.) into the target object.
147
+ *
148
+ * Usage:
149
+ * const h: Record<string, any> = {};
150
+ * addHtmlHelpers(h);
151
+ * const html = h._div({ class: "card" }, h._p("Hello"));
152
+ */
153
+ export function addHtmlHelpers(target: Record<string, unknown>): void {
154
+ for (const tag of HTML_TAGS) {
155
+ target[`_${tag}`] = (...args: (Child | Attrs | Child[])[]) => {
156
+ const attrs: Attrs = {};
157
+ const children: Child[] = [];
158
+
159
+ for (const arg of args) {
160
+ if (isAttrs(arg)) {
161
+ Object.assign(attrs, arg);
162
+ } else if (Array.isArray(arg)) {
163
+ children.push(...arg);
164
+ } else {
165
+ children.push(arg as Child);
166
+ }
167
+ }
168
+
169
+ return new HtmlElement(tag, attrs, children);
170
+ };
171
+ }
172
+ }
@@ -0,0 +1,136 @@
1
+ // Tina4 i18n — Internationalization and localization, zero dependencies.
2
+ // Load JSON translation files, support nested keys, parameter substitution, locale fallback.
3
+
4
+ import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+
7
+ export class I18n {
8
+ private _localeDir: string;
9
+ private _defaultLocale: string;
10
+ private _currentLocale: string;
11
+ private _translations: Map<string, Record<string, string>> = new Map();
12
+
13
+ constructor(localeDir?: string, defaultLocale?: string) {
14
+ this._localeDir = resolve(
15
+ localeDir ?? process.env.TINA4_LOCALE_DIR ?? "src/locales"
16
+ );
17
+ this._defaultLocale = defaultLocale ?? process.env.TINA4_LOCALE ?? "en";
18
+ this._currentLocale = this._defaultLocale;
19
+ this._loadLocale(this._defaultLocale);
20
+ }
21
+
22
+ setLocale(locale: string): void {
23
+ this._currentLocale = locale;
24
+ this._loadLocale(locale);
25
+ }
26
+
27
+ getLocale(): string {
28
+ return this._currentLocale;
29
+ }
30
+
31
+ /**
32
+ * Translate a key. Supports {placeholder} interpolation.
33
+ * Falls back to default locale, then returns the key itself.
34
+ */
35
+ t(key: string, params?: Record<string, string>, locale?: string): string {
36
+ const effectiveLocale = locale ?? this._currentLocale;
37
+
38
+ // Ensure locale is loaded
39
+ if (locale) {
40
+ this._loadLocale(locale);
41
+ }
42
+
43
+ // Try effective locale
44
+ const translations = this._translations.get(effectiveLocale) ?? {};
45
+ let value: string | undefined = translations[key];
46
+
47
+ // Fallback to default locale
48
+ if (value === undefined && effectiveLocale !== this._defaultLocale) {
49
+ const fallback = this._translations.get(this._defaultLocale) ?? {};
50
+ value = fallback[key];
51
+ }
52
+
53
+ // Fallback to key itself
54
+ if (value === undefined) {
55
+ value = key;
56
+ }
57
+
58
+ // Interpolate parameters: {name} → value
59
+ if (params) {
60
+ for (const [pKey, pVal] of Object.entries(params)) {
61
+ value = value.replace(new RegExp(`\\{${pKey}\\}`, "g"), pVal);
62
+ }
63
+ }
64
+
65
+ return value;
66
+ }
67
+
68
+ /** Alias for t() */
69
+ translate(key: string, params?: Record<string, string>, locale?: string): string {
70
+ return this.t(key, params, locale);
71
+ }
72
+
73
+ /** Load and return translations for a locale. */
74
+ loadTranslations(locale: string): Record<string, string> {
75
+ this._loadLocale(locale);
76
+ return { ...(this._translations.get(locale) ?? {}) };
77
+ }
78
+
79
+ /** Add a single translation key/value to a locale (in-memory only). */
80
+ addTranslation(locale: string, key: string, value: string): void {
81
+ if (!this._translations.has(locale)) {
82
+ this._translations.set(locale, {});
83
+ }
84
+ this._translations.get(locale)![key] = value;
85
+ }
86
+
87
+ /** List available locale codes based on JSON files in the locale directory. */
88
+ getAvailableLocales(): string[] {
89
+ if (!existsSync(this._localeDir)) {
90
+ return [this._defaultLocale];
91
+ }
92
+ try {
93
+ const files = readdirSync(this._localeDir);
94
+ const locales = files
95
+ .filter((f) => f.endsWith(".json"))
96
+ .map((f) => f.replace(/\.json$/, ""))
97
+ .sort();
98
+ return locales.length > 0 ? locales : [this._defaultLocale];
99
+ } catch {
100
+ return [this._defaultLocale];
101
+ }
102
+ }
103
+
104
+ /** Load a locale file if not already loaded. */
105
+ private _loadLocale(locale: string): void {
106
+ if (this._translations.has(locale)) {
107
+ return;
108
+ }
109
+ const filePath = join(this._localeDir, `${locale}.json`);
110
+ if (existsSync(filePath)) {
111
+ try {
112
+ const raw = readFileSync(filePath, "utf-8");
113
+ const data = JSON.parse(raw);
114
+ this._translations.set(locale, I18n._flatten(data));
115
+ } catch {
116
+ this._translations.set(locale, {});
117
+ }
118
+ } else {
119
+ this._translations.set(locale, {});
120
+ }
121
+ }
122
+
123
+ /** Flatten nested objects: {"a": {"b": "c"}} → {"a.b": "c"} */
124
+ private static _flatten(data: Record<string, unknown>, prefix = ""): Record<string, string> {
125
+ const result: Record<string, string> = {};
126
+ for (const [key, value] of Object.entries(data)) {
127
+ const fullKey = prefix ? `${prefix}.${key}` : key;
128
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
129
+ Object.assign(result, I18n._flatten(value as Record<string, unknown>, fullKey));
130
+ } else {
131
+ result[fullKey] = String(value);
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+ }
@@ -0,0 +1,88 @@
1
+ export type {
2
+ Tina4Request,
3
+ Tina4Response,
4
+ RouteHandler,
5
+ RouteDefinition,
6
+ RouteMeta,
7
+ Tina4Config,
8
+ Middleware,
9
+ UploadedFile,
10
+ CookieOptions,
11
+ } from "./types.js";
12
+
13
+ export { startServer, resolvePortAndHost } from "./server.js";
14
+ export { Router, RouteGroup, defaultRouter, runRouteMiddlewares } from "./router.js";
15
+ export { get, post, put, patch, del, any, del as delete } from "./router.js";
16
+ export type { RouteInfo } from "./router.js";
17
+ export { discoverRoutes } from "./routeDiscovery.js";
18
+ export { MiddlewareChain, cors, requestLogger } from "./middleware.js";
19
+ export type { CorsConfig } from "./middleware.js";
20
+ export { createRequest, parseBody } from "./request.js";
21
+ export { createResponse } from "./response.js";
22
+ export { tryServeStatic } from "./static.js";
23
+ export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
24
+ export { Log } from "./logger.js";
25
+ export { createHealthRoute } from "./health.js";
26
+ export { rateLimiter } from "./rateLimiter.js";
27
+ export type { RateLimiterConfig } from "./rateLimiter.js";
28
+ export {
29
+ HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED, HTTP_NO_CONTENT,
30
+ HTTP_MOVED, HTTP_REDIRECT, HTTP_NOT_MODIFIED,
31
+ HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, HTTP_FORBIDDEN,
32
+ HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_CONFLICT,
33
+ HTTP_GONE, HTTP_UNPROCESSABLE, HTTP_TOO_MANY,
34
+ HTTP_SERVER_ERROR, HTTP_BAD_GATEWAY, HTTP_UNAVAILABLE,
35
+ APPLICATION_JSON, APPLICATION_XML, APPLICATION_FORM,
36
+ APPLICATION_OCTET, TEXT_HTML, TEXT_PLAIN, TEXT_CSV, TEXT_XML,
37
+ } from "./constants.js";
38
+ export {
39
+ createToken, validateToken, getPayload,
40
+ hashPassword, checkPassword,
41
+ authMiddleware,
42
+ refreshToken, authenticateRequest, validateApiKey,
43
+ } from "./auth.js";
44
+ export { Session, FileSessionHandler, RedisSessionHandler } from "./session.js";
45
+ export type { SessionConfig, SessionHandler } from "./session.js";
46
+ export { I18n } from "./i18n.js";
47
+ export { FakeData } from "./fakeData.js";
48
+ export { ScssCompiler } from "./scss.js";
49
+ export type { ScssConfig } from "./scss.js";
50
+ export { Queue } from "./queue.js";
51
+ export type { QueueConfig, QueueJob, ProcessOptions } from "./queue.js";
52
+ export { GraphQL, ParseError } from "./graphql.js";
53
+ export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
54
+ export {
55
+ WebSocketServer,
56
+ computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame,
57
+ OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
58
+ CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR,
59
+ } from "./websocket.js";
60
+ export type { WebSocketClient } from "./websocket.js";
61
+ export { ServiceRunner, matchCronField, matchesCron } from "./service.js";
62
+ export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from "./service.js";
63
+ export { responseCache, clearCache, cacheStats } from "./cache.js";
64
+ export type { ResponseCacheConfig } from "./cache.js";
65
+ export { Api } from "./api.js";
66
+ export type { ApiResult } from "./api.js";
67
+ export { Events } from "./events.js";
68
+ export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker } from "./devAdmin.js";
69
+ export { Messenger } from "./messenger.js";
70
+ export type { SendResult, EmailMessage } from "./messenger.js";
71
+ export { DevMailbox, createMessenger } from "./devMailbox.js";
72
+ export { WSDLService, WSDLOp } from "./wsdl.js";
73
+ export type { WSDLOperation } from "./wsdl.js";
74
+ export { HtmlElement, htmlElement, addHtmlHelpers } from "./htmlElement.js";
75
+ export { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
76
+ export { detectAi, detectAiNames, generateContext, installAiContext, installAllAiContext, aiStatusReport } from "./ai.js";
77
+ export type { AiTool, AiDetection } from "./ai.js";
78
+ export type { ImapMessage, ImapFullMessage } from "./messenger.js";
79
+ export { RabbitMQBackend } from "./queueBackends/rabbitmqBackend.js";
80
+ export type { RabbitMQConfig } from "./queueBackends/rabbitmqBackend.js";
81
+ export { KafkaBackend } from "./queueBackends/kafkaBackend.js";
82
+ export type { KafkaConfig } from "./queueBackends/kafkaBackend.js";
83
+ export { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
84
+ export type { MongoSessionConfig } from "./sessionHandlers/mongoHandler.js";
85
+ export { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
86
+ export type { ValkeySessionConfig } from "./sessionHandlers/valkeyHandler.js";
87
+ export { tests, assertEqual, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "./testing.js";
88
+ export { Container, container } from "./container.js";
@@ -0,0 +1,226 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ renameSync,
6
+ statSync,
7
+ unlinkSync,
8
+ } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { isTruthy } from "./dotenv.js";
11
+
12
+ /** Log level severity */
13
+ type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR";
14
+
15
+ /** Log level priority for filtering */
16
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
17
+ DEBUG: 0,
18
+ INFO: 1,
19
+ WARNING: 2,
20
+ ERROR: 3,
21
+ };
22
+
23
+ /** Structured log entry for JSON output */
24
+ interface LogEntry {
25
+ timestamp: string;
26
+ level: LogLevel;
27
+ message: string;
28
+ request_id?: string;
29
+ context?: unknown;
30
+ }
31
+
32
+ /** ANSI color codes for terminal output */
33
+ const COLORS: Record<LogLevel, string> = {
34
+ DEBUG: "\x1b[36m", // cyan
35
+ INFO: "\x1b[32m", // green
36
+ WARNING: "\x1b[33m", // yellow
37
+ ERROR: "\x1b[31m", // red
38
+ };
39
+ const RESET = "\x1b[0m";
40
+
41
+ /** Regex to strip ANSI escape codes */
42
+ const ANSI_RE = /\033\[[0-9;]*m/g;
43
+
44
+ /** Default log directory */
45
+ const DEFAULT_LOG_DIR = "logs";
46
+
47
+ /** Default log filename */
48
+ const DEFAULT_LOG_FILE = "tina4.log";
49
+
50
+ /** Strip ANSI escape codes from a string */
51
+ function stripAnsi(text: string): string {
52
+ return text.replace(ANSI_RE, "");
53
+ }
54
+
55
+ /**
56
+ * Structured logger for Tina4.
57
+ *
58
+ * Production (TINA4_DEBUG not truthy): JSON lines to logs/tina4.log
59
+ * Development (TINA4_DEBUG=true): Colorized human-readable to stdout + file
60
+ *
61
+ * Log rotation: numbered scheme (tina4.log -> tina4.log.1 -> ... -> tina4.log.{keep})
62
+ * Raw log file always writes ALL levels (no filtering), plain text (no ANSI codes).
63
+ * Console output respects TINA4_LOG_LEVEL.
64
+ */
65
+ export class Log {
66
+ private static requestId: string | undefined;
67
+ private static logDir: string = DEFAULT_LOG_DIR;
68
+ private static logFile: string = DEFAULT_LOG_FILE;
69
+ private static maxFileSize: number = 10 * 1024 * 1024;
70
+ private static keepFiles: number = 5;
71
+ private static minLevel: number = 0;
72
+
73
+ /**
74
+ * Set the current request ID for log correlation.
75
+ */
76
+ static setRequestId(id: string | undefined): void {
77
+ Log.requestId = id;
78
+ }
79
+
80
+ /**
81
+ * Get the current request ID.
82
+ */
83
+ static getRequestId(): string | undefined {
84
+ return Log.requestId;
85
+ }
86
+
87
+ /**
88
+ * Configure the log directory, filename, and rotation settings.
89
+ */
90
+ static configure(options: { logDir?: string; logFile?: string }): void {
91
+ if (options.logDir) Log.logDir = options.logDir;
92
+ if (options.logFile) Log.logFile = options.logFile;
93
+
94
+ // Read rotation config from env (with defaults)
95
+ const maxSizeMb = parseInt(process.env.TINA4_LOG_MAX_SIZE ?? "10", 10);
96
+ Log.maxFileSize = (isNaN(maxSizeMb) ? 10 : maxSizeMb) * 1024 * 1024;
97
+ const keep = parseInt(process.env.TINA4_LOG_KEEP ?? "5", 10);
98
+ Log.keepFiles = isNaN(keep) ? 5 : keep;
99
+
100
+ // Resolve minimum console log level
101
+ const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
102
+ Log.minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
103
+ }
104
+
105
+ /** Log an informational message. */
106
+ static info(message: string, data?: unknown): void {
107
+ Log.log("INFO", message, data);
108
+ }
109
+
110
+ /** Log a debug message. */
111
+ static debug(message: string, data?: unknown): void {
112
+ Log.log("DEBUG", message, data);
113
+ }
114
+
115
+ /** Log a warning message. */
116
+ static warning(message: string, data?: unknown): void {
117
+ Log.log("WARNING", message, data);
118
+ }
119
+
120
+ /** Log an error message. */
121
+ static error(message: string, data?: unknown): void {
122
+ Log.log("ERROR", message, data);
123
+ }
124
+
125
+ /** Check if running in production mode (TINA4_DEBUG is not truthy). */
126
+ private static isProduction(): boolean {
127
+ return !isTruthy(process.env.TINA4_DEBUG);
128
+ }
129
+
130
+ /** Get current ISO timestamp */
131
+ private static timestamp(): string {
132
+ return new Date().toISOString();
133
+ }
134
+
135
+ /** Get the full path to the current log file */
136
+ private static logFilePath(): string {
137
+ return join(Log.logDir, Log.logFile);
138
+ }
139
+
140
+ /** Ensure the log directory exists */
141
+ private static ensureLogDir(): void {
142
+ const dir = dirname(Log.logFilePath());
143
+ if (!existsSync(dir)) {
144
+ mkdirSync(dir, { recursive: true });
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Rotate using numbered scheme: tina4.log.{keep} is deleted, all others shift up by 1.
150
+ */
151
+ private static rotateIfNeeded(): void {
152
+ const filePath = Log.logFilePath();
153
+ if (!existsSync(filePath)) return;
154
+
155
+ try {
156
+ const stats = statSync(filePath);
157
+ if (stats.size < Log.maxFileSize) return;
158
+ } catch {
159
+ return;
160
+ }
161
+
162
+ const keep = Log.keepFiles;
163
+
164
+ // Delete the oldest rotated file if it exists
165
+ const oldest = `${filePath}.${keep}`;
166
+ if (existsSync(oldest)) {
167
+ try { unlinkSync(oldest); } catch { /* ignore */ }
168
+ }
169
+
170
+ // Shift existing rotated files: .{n} -> .{n+1}
171
+ for (let n = keep - 1; n >= 1; n--) {
172
+ const src = `${filePath}.${n}`;
173
+ const dst = `${filePath}.${n + 1}`;
174
+ if (existsSync(src)) {
175
+ try { renameSync(src, dst); } catch { /* ignore */ }
176
+ }
177
+ }
178
+
179
+ // Rename current log to .1
180
+ try { renameSync(filePath, `${filePath}.1`); } catch { /* ignore */ }
181
+ }
182
+
183
+ /** Write a line to the log file, stripping ANSI codes */
184
+ private static writeToFile(line: string): void {
185
+ try {
186
+ Log.ensureLogDir();
187
+ Log.rotateIfNeeded();
188
+ appendFileSync(Log.logFilePath(), stripAnsi(line) + "\n", "utf-8");
189
+ } catch {
190
+ // Silently fail — logging should never crash the app
191
+ }
192
+ }
193
+
194
+ /** Core log method */
195
+ private static log(level: LogLevel, message: string, data?: unknown): void {
196
+ const entry: LogEntry = {
197
+ timestamp: Log.timestamp(),
198
+ level,
199
+ message,
200
+ };
201
+
202
+ if (Log.requestId) {
203
+ entry.request_id = Log.requestId;
204
+ }
205
+
206
+ if (data !== undefined) {
207
+ entry.context = data;
208
+ }
209
+
210
+ // Build human-readable line for file/console
211
+ const paddedLevel = level.padEnd(7);
212
+ const reqPart = Log.requestId ? ` [${Log.requestId}]` : "";
213
+ const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
214
+ const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart} ${message}${dataPart}`;
215
+
216
+ // Console output respects TINA4_LOG_LEVEL
217
+ const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= Log.minLevel;
218
+ if (!Log.isProduction() && shouldLog) {
219
+ const color = COLORS[level];
220
+ console.log(`${color}${humanLine}${RESET}`);
221
+ }
222
+
223
+ // File always gets ALL levels (raw log, no filtering), plain text (no ANSI)
224
+ Log.writeToFile(humanLine);
225
+ }
226
+ }