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.
- package/BENCHMARK_REPORT.md +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- 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
|
+
}
|