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,189 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { Tina4Request, UploadedFile } from "./types.js";
3
+
4
+ export function createRequest(req: IncomingMessage): Tina4Request {
5
+ const tReq = req as Tina4Request;
6
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
7
+ const query: Record<string, string> = {};
8
+ for (const [key, value] of url.searchParams) {
9
+ query[key] = value;
10
+ }
11
+
12
+ tReq.params = {};
13
+ tReq.query = query;
14
+ tReq.body = undefined;
15
+ tReq.files = [];
16
+
17
+ // Determine client IP with X-Forwarded-For support
18
+ const forwarded = req.headers["x-forwarded-for"];
19
+ if (typeof forwarded === "string") {
20
+ tReq.ip = forwarded.split(",")[0].trim();
21
+ } else if (Array.isArray(forwarded) && forwarded.length > 0) {
22
+ tReq.ip = forwarded[0].split(",")[0].trim();
23
+ } else {
24
+ tReq.ip = req.socket?.remoteAddress ?? "127.0.0.1";
25
+ }
26
+
27
+ return tReq;
28
+ }
29
+
30
+ export async function parseBody(req: Tina4Request): Promise<void> {
31
+ const method = req.method?.toUpperCase();
32
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") return;
33
+
34
+ const contentType = req.headers["content-type"] ?? "";
35
+ const chunks: Buffer[] = [];
36
+
37
+ await new Promise<void>((resolve, reject) => {
38
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
39
+ req.on("end", resolve);
40
+ req.on("error", reject);
41
+ });
42
+
43
+ const raw = Buffer.concat(chunks);
44
+ if (raw.length === 0) return;
45
+
46
+ if (contentType.includes("multipart/form-data")) {
47
+ const boundary = extractBoundary(contentType);
48
+ if (boundary) {
49
+ const { fields, files } = parseMultipart(raw, boundary);
50
+ req.body = fields;
51
+ req.files = files;
52
+ } else {
53
+ req.body = raw.toString("utf-8");
54
+ }
55
+ } else if (contentType.includes("application/json")) {
56
+ const str = raw.toString("utf-8");
57
+ try {
58
+ req.body = JSON.parse(str);
59
+ } catch {
60
+ req.body = str;
61
+ }
62
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
63
+ const str = raw.toString("utf-8");
64
+ const params = new URLSearchParams(str);
65
+ const obj: Record<string, string> = {};
66
+ for (const [key, value] of params) {
67
+ obj[key] = value;
68
+ }
69
+ req.body = obj;
70
+ } else {
71
+ req.body = raw.toString("utf-8");
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract the boundary string from a multipart content-type header.
77
+ */
78
+ function extractBoundary(contentType: string): string | null {
79
+ const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
80
+ return match ? (match[1] ?? match[2]) : null;
81
+ }
82
+
83
+ /**
84
+ * Parse multipart/form-data body into fields and files.
85
+ * Zero-dependency implementation.
86
+ */
87
+ function parseMultipart(
88
+ body: Buffer,
89
+ boundary: string,
90
+ ): { fields: Record<string, string>; files: UploadedFile[] } {
91
+ const fields: Record<string, string> = {};
92
+ const files: UploadedFile[] = [];
93
+
94
+ const delimiter = Buffer.from(`--${boundary}`);
95
+ const closeDelimiter = Buffer.from(`--${boundary}--`);
96
+ const crlf = Buffer.from("\r\n");
97
+ const doubleCrlf = Buffer.from("\r\n\r\n");
98
+
99
+ let offset = 0;
100
+
101
+ // Find first delimiter
102
+ const firstIdx = bufferIndexOf(body, delimiter, offset);
103
+ if (firstIdx === -1) return { fields, files };
104
+ offset = firstIdx + delimiter.length;
105
+
106
+ // Skip CRLF after delimiter
107
+ if (body[offset] === 0x0d && body[offset + 1] === 0x0a) {
108
+ offset += 2;
109
+ }
110
+
111
+ while (offset < body.length) {
112
+ // Find the end of headers (double CRLF)
113
+ const headersEnd = bufferIndexOf(body, doubleCrlf, offset);
114
+ if (headersEnd === -1) break;
115
+
116
+ const headersStr = body.subarray(offset, headersEnd).toString("utf-8");
117
+ offset = headersEnd + doubleCrlf.length;
118
+
119
+ // Find next delimiter
120
+ const nextDelimIdx = bufferIndexOf(body, delimiter, offset);
121
+ if (nextDelimIdx === -1) break;
122
+
123
+ // Content data is between current offset and nextDelimIdx - CRLF
124
+ const contentEnd = nextDelimIdx - crlf.length;
125
+ const content = body.subarray(offset, contentEnd);
126
+
127
+ // Parse headers
128
+ const disposition = parseDisposition(headersStr);
129
+ const partContentType = parsePartContentType(headersStr);
130
+
131
+ if (disposition.filename) {
132
+ // File upload
133
+ files.push({
134
+ fieldName: disposition.name,
135
+ filename: disposition.filename,
136
+ contentType: partContentType ?? "application/octet-stream",
137
+ data: Buffer.from(content),
138
+ size: content.length,
139
+ });
140
+ } else if (disposition.name) {
141
+ // Regular field
142
+ fields[disposition.name] = content.toString("utf-8");
143
+ }
144
+
145
+ // Move past the delimiter
146
+ offset = nextDelimIdx + delimiter.length;
147
+
148
+ // Check for close delimiter
149
+ if (body[offset] === 0x2d && body[offset + 1] === 0x2d) {
150
+ // "--" means end of multipart
151
+ break;
152
+ }
153
+
154
+ // Skip CRLF after delimiter
155
+ if (body[offset] === 0x0d && body[offset + 1] === 0x0a) {
156
+ offset += 2;
157
+ }
158
+ }
159
+
160
+ return { fields, files };
161
+ }
162
+
163
+ function bufferIndexOf(haystack: Buffer, needle: Buffer, offset: number): number {
164
+ for (let i = offset; i <= haystack.length - needle.length; i++) {
165
+ let found = true;
166
+ for (let j = 0; j < needle.length; j++) {
167
+ if (haystack[i + j] !== needle[j]) {
168
+ found = false;
169
+ break;
170
+ }
171
+ }
172
+ if (found) return i;
173
+ }
174
+ return -1;
175
+ }
176
+
177
+ function parseDisposition(headers: string): { name: string; filename?: string } {
178
+ const nameMatch = headers.match(/name="([^"]+)"/);
179
+ const filenameMatch = headers.match(/filename="([^"]+)"/);
180
+ return {
181
+ name: nameMatch?.[1] ?? "",
182
+ filename: filenameMatch?.[1],
183
+ };
184
+ }
185
+
186
+ function parsePartContentType(headers: string): string | null {
187
+ const match = headers.match(/Content-Type:\s*(.+?)(?:\r?\n|$)/i);
188
+ return match?.[1]?.trim() ?? null;
189
+ }
@@ -0,0 +1,146 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import type { Tina4Response, CookieOptions } from "./types.js";
3
+
4
+ /**
5
+ * Creates a callable response object.
6
+ *
7
+ * return response({ users: [] }); // Auto-JSON
8
+ * return response({ ok: true }, HTTP_CREATED); // JSON with status
9
+ * return response("<h1>Hi</h1>"); // Auto-HTML
10
+ * return response("Not found", HTTP_NOT_FOUND); // Plain text
11
+ * return response(data, HTTP_OK, APPLICATION_JSON); // Explicit
12
+ * return response.json(data, 201); // Method
13
+ * return response.redirect("/login"); // Special
14
+ */
15
+ export function createResponse(res: ServerResponse): Tina4Response {
16
+
17
+ // ── The callable: response(data, status, contentType) ──
18
+ const response = function (data?: unknown, statusCode?: number, contentType?: string): Tina4Response {
19
+ if (statusCode !== undefined) {
20
+ res.statusCode = statusCode;
21
+ }
22
+
23
+ if (contentType) {
24
+ // Explicit content type
25
+ res.setHeader("Content-Type", contentType);
26
+ if (typeof data === "object" && data !== null && !Buffer.isBuffer(data)) {
27
+ res.end(JSON.stringify(data));
28
+ } else {
29
+ res.end(data == null ? "" : String(data));
30
+ }
31
+ } else if (typeof data === "object" && data !== null && !Buffer.isBuffer(data)) {
32
+ // dict/array → auto JSON
33
+ res.setHeader("Content-Type", "application/json");
34
+ res.end(JSON.stringify(data));
35
+ } else if (typeof data === "string") {
36
+ const trimmed = data.trim();
37
+ if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
38
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
39
+ } else {
40
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
41
+ }
42
+ res.end(data);
43
+ } else if (Buffer.isBuffer(data)) {
44
+ if (!res.getHeader("Content-Type")) {
45
+ res.setHeader("Content-Type", "application/octet-stream");
46
+ }
47
+ res.end(data);
48
+ } else if (data == null) {
49
+ res.end("");
50
+ } else {
51
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
52
+ res.end(String(data));
53
+ }
54
+
55
+ return response;
56
+ } as Tina4Response;
57
+
58
+ // ── Attach the underlying ServerResponse ──
59
+ response.raw = res;
60
+
61
+ // ── Explicit methods ──
62
+
63
+ response.json = function (data: unknown, status?: number): Tina4Response {
64
+ if (status !== undefined) res.statusCode = status;
65
+ res.setHeader("Content-Type", "application/json");
66
+ res.end(JSON.stringify(data));
67
+ return response;
68
+ };
69
+
70
+ response.html = function (content: string, status?: number): Tina4Response {
71
+ if (status !== undefined) res.statusCode = status;
72
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
73
+ res.end(content);
74
+ return response;
75
+ };
76
+
77
+ response.text = function (content: string, status?: number): Tina4Response {
78
+ if (status !== undefined) res.statusCode = status;
79
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
80
+ res.end(content);
81
+ return response;
82
+ };
83
+
84
+ response.send = function (data: unknown, statusCode?: number, contentType?: string): Tina4Response {
85
+ return response(data, statusCode, contentType);
86
+ };
87
+
88
+ response.status = function (code: number): Tina4Response {
89
+ res.statusCode = code;
90
+ return response;
91
+ };
92
+
93
+ response.header = function (name: string, value: string | number | readonly string[]): Tina4Response {
94
+ res.setHeader(name, value);
95
+ return response;
96
+ };
97
+
98
+ response.redirect = function (url: string, code?: number): Tina4Response {
99
+ res.statusCode = code ?? 302;
100
+ res.setHeader("Location", url);
101
+ res.end();
102
+ return response;
103
+ };
104
+
105
+ response.cookie = function (name: string, value: string, options?: CookieOptions): Tina4Response {
106
+ const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
107
+ if (options?.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
108
+ if (options?.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
109
+ if (options?.path) parts.push(`Path=${options.path}`);
110
+ if (options?.domain) parts.push(`Domain=${options.domain}`);
111
+ if (options?.secure) parts.push("Secure");
112
+ if (options?.httpOnly) parts.push("HttpOnly");
113
+ if (options?.sameSite) parts.push(`SameSite=${options.sameSite}`);
114
+
115
+ const existing = res.getHeader("Set-Cookie");
116
+ const cookies: string[] = [];
117
+ if (Array.isArray(existing)) cookies.push(...(existing as string[]));
118
+ else if (typeof existing === "string") cookies.push(existing);
119
+ cookies.push(parts.join("; "));
120
+ res.setHeader("Set-Cookie", cookies);
121
+
122
+ return response;
123
+ };
124
+
125
+ response.clearCookie = function (name: string, options?: CookieOptions): Tina4Response {
126
+ return response.cookie(name, "", { ...options, maxAge: 0, expires: new Date(0) });
127
+ };
128
+
129
+ response.template = async function (name: string, data?: Record<string, unknown>): Promise<Tina4Response> {
130
+ try {
131
+ const twig = await import("@tina4/twig");
132
+ const html = await twig.renderTemplate(name, data);
133
+ response.html(html);
134
+ } catch (err) {
135
+ res.statusCode = 500;
136
+ response.json({
137
+ error: "Template rendering failed",
138
+ statusCode: 500,
139
+ message: String(err),
140
+ });
141
+ }
142
+ return response;
143
+ };
144
+
145
+ return response;
146
+ }
@@ -0,0 +1,87 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join, relative, basename, extname } from "node:path";
3
+ import type { RouteDefinition, RouteHandler, RouteMeta } from "./types.js";
4
+
5
+ const VALID_METHODS = new Set(["get", "post", "put", "delete", "patch"]);
6
+
7
+ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition[]> {
8
+ const definitions: RouteDefinition[] = [];
9
+ const files = walkDir(routesDir);
10
+
11
+ for (const filePath of files) {
12
+ const ext = extname(filePath);
13
+ if (ext !== ".ts" && ext !== ".js") continue;
14
+
15
+ const name = basename(filePath, ext).toLowerCase();
16
+ if (!VALID_METHODS.has(name)) continue;
17
+
18
+ const method = name.toUpperCase();
19
+ const relativePath = relative(routesDir, filePath);
20
+ const pattern = filePathToPattern(relativePath);
21
+
22
+ try {
23
+ // Cache-bust for hot-reload
24
+ const moduleUrl = `file://${filePath}?t=${Date.now()}`;
25
+ const mod = await import(moduleUrl);
26
+
27
+ const handler: RouteHandler = mod.default ?? mod.handler;
28
+ if (typeof handler !== "function") {
29
+ console.warn(` Warning: ${relativePath} does not export a handler function, skipping`);
30
+ continue;
31
+ }
32
+
33
+ const meta: RouteMeta | undefined = mod.meta;
34
+ const template: string | undefined = typeof mod.template === "string" ? mod.template : undefined;
35
+
36
+ definitions.push({ method, pattern, handler, filePath, meta, template });
37
+ } catch (err) {
38
+ console.error(` Error loading route ${relativePath}:`, err);
39
+ }
40
+ }
41
+
42
+ return definitions;
43
+ }
44
+
45
+ function filePathToPattern(relativePath: string): string {
46
+ // Remove the filename (get.ts, post.ts, etc.) to get the directory path
47
+ const parts = relativePath.split("/").slice(0, -1);
48
+
49
+ // Convert directory segments to URL pattern
50
+ // File system uses [id] notation, but URL patterns use {id} to match Python
51
+ const urlParts = parts.map((part) => {
52
+ if (part.startsWith("[...") && part.endsWith("]")) {
53
+ // Catch-all: [...slug] -> {...slug}
54
+ const name = part.slice(4, -1);
55
+ return `{...${name}}`;
56
+ }
57
+ if (part.startsWith("[") && part.endsWith("]")) {
58
+ // Dynamic param: [id] -> {id}
59
+ const name = part.slice(1, -1);
60
+ return `{${name}}`;
61
+ }
62
+ return part;
63
+ });
64
+
65
+ return "/" + urlParts.join("/");
66
+ }
67
+
68
+ function walkDir(dir: string): string[] {
69
+ const files: string[] = [];
70
+
71
+ try {
72
+ const entries = readdirSync(dir);
73
+ for (const entry of entries) {
74
+ const fullPath = join(dir, entry);
75
+ const stat = statSync(fullPath);
76
+ if (stat.isDirectory()) {
77
+ files.push(...walkDir(fullPath));
78
+ } else {
79
+ files.push(fullPath);
80
+ }
81
+ }
82
+ } catch {
83
+ // Directory doesn't exist yet, that's fine
84
+ }
85
+
86
+ return files;
87
+ }