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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Debug — Rich error overlay for development mode.
|
|
3
|
+
*
|
|
4
|
+
* Renders a professional, syntax-highlighted HTML error page when an unhandled
|
|
5
|
+
* exception occurs in a route handler.
|
|
6
|
+
*
|
|
7
|
+
* import { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
|
|
8
|
+
*
|
|
9
|
+
* try {
|
|
10
|
+
* await handler(req, res);
|
|
11
|
+
* } catch (err) {
|
|
12
|
+
* const html = isDebugMode()
|
|
13
|
+
* ? renderErrorOverlay(err as Error, req)
|
|
14
|
+
* : renderProductionError();
|
|
15
|
+
* res.html(html, 500);
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Only activate when TINA4_DEBUG is true.
|
|
19
|
+
* In production, call renderProductionError() instead.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { resolve } from "node:path";
|
|
24
|
+
import { isTruthy } from "./dotenv.js";
|
|
25
|
+
|
|
26
|
+
// ── Colour palette (Catppuccin Mocha) ────────────────────────────────────
|
|
27
|
+
const BG = "#1e1e2e";
|
|
28
|
+
const SURFACE = "#313244";
|
|
29
|
+
const OVERLAY = "#45475a";
|
|
30
|
+
const TEXT = "#cdd6f4";
|
|
31
|
+
const SUBTEXT = "#a6adc8";
|
|
32
|
+
const RED = "#f38ba8";
|
|
33
|
+
const YELLOW = "#f9e2af";
|
|
34
|
+
const BLUE = "#89b4fa";
|
|
35
|
+
const GREEN = "#a6e3a1";
|
|
36
|
+
const LAVENDER = "#b4befe";
|
|
37
|
+
const PEACH = "#fab387";
|
|
38
|
+
const ERROR_LINE_BG = "rgba(243,139,168,0.15)";
|
|
39
|
+
|
|
40
|
+
const CONTEXT_LINES = 7;
|
|
41
|
+
|
|
42
|
+
interface StackFrame {
|
|
43
|
+
file: string;
|
|
44
|
+
line: number;
|
|
45
|
+
column: number;
|
|
46
|
+
func: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function esc(text: string): string {
|
|
50
|
+
return text
|
|
51
|
+
.replace(/&/g, "&")
|
|
52
|
+
.replace(/</g, "<")
|
|
53
|
+
.replace(/>/g, ">")
|
|
54
|
+
.replace(/"/g, """)
|
|
55
|
+
.replace(/'/g, "'");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseStack(stack: string): StackFrame[] {
|
|
59
|
+
const frames: StackFrame[] = [];
|
|
60
|
+
const lines = stack.split("\n");
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
// Match: " at functionName (file:line:col)"
|
|
63
|
+
let match = line.match(/^\s*at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
|
|
64
|
+
if (match) {
|
|
65
|
+
frames.push({ func: match[1], file: match[2], line: parseInt(match[3], 10), column: parseInt(match[4], 10) });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Match: " at file:line:col"
|
|
69
|
+
match = line.match(/^\s*at\s+(.+?):(\d+):(\d+)/);
|
|
70
|
+
if (match) {
|
|
71
|
+
frames.push({ func: "{anonymous}", file: match[1], line: parseInt(match[2], 10), column: parseInt(match[3], 10) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return frames;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readSourceLines(filename: string, lineno: number): Array<[number, string, boolean]> {
|
|
78
|
+
try {
|
|
79
|
+
const absPath = resolve(filename);
|
|
80
|
+
const content = readFileSync(absPath, "utf-8");
|
|
81
|
+
const allLines = content.split("\n");
|
|
82
|
+
const start = Math.max(0, lineno - CONTEXT_LINES - 1);
|
|
83
|
+
const end = Math.min(allLines.length, lineno + CONTEXT_LINES);
|
|
84
|
+
const result: Array<[number, string, boolean]> = [];
|
|
85
|
+
for (let i = start; i < end; i++) {
|
|
86
|
+
const num = i + 1;
|
|
87
|
+
result.push([num, allLines[i] ?? "", num === lineno]);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatSourceBlock(filename: string, lineno: number): string {
|
|
96
|
+
const lines = readSourceLines(filename, lineno);
|
|
97
|
+
if (lines.length === 0) return "";
|
|
98
|
+
|
|
99
|
+
const rows = lines.map(([num, text, isError]) => {
|
|
100
|
+
const bg = isError ? `background:${ERROR_LINE_BG};` : "";
|
|
101
|
+
const marker = isError ? "▶" : " ";
|
|
102
|
+
return `<div style="${bg}display:flex;padding:1px 0;">`
|
|
103
|
+
+ `<span style="color:${YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;">${num}</span>`
|
|
104
|
+
+ `<span style="color:${RED};width:1.2em;user-select:none;">${marker}</span>`
|
|
105
|
+
+ `<span style="color:${TEXT};white-space:pre-wrap;tab-size:4;">${esc(text)}</span>`
|
|
106
|
+
+ `</div>`;
|
|
107
|
+
}).join("\n");
|
|
108
|
+
|
|
109
|
+
return `<div style="background:${SURFACE};border-radius:6px;padding:12px;overflow-x:auto;`
|
|
110
|
+
+ `font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;">`
|
|
111
|
+
+ rows + `</div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatFrame(frame: StackFrame): string {
|
|
115
|
+
const source = frame.file && frame.line > 0 ? formatSourceBlock(frame.file, frame.line) : "";
|
|
116
|
+
return `<div style="margin-bottom:16px;">`
|
|
117
|
+
+ `<div style="margin-bottom:4px;">`
|
|
118
|
+
+ `<span style="color:${BLUE};">${esc(frame.file)}</span>`
|
|
119
|
+
+ `<span style="color:${SUBTEXT};"> : </span>`
|
|
120
|
+
+ `<span style="color:${YELLOW};">${frame.line}</span>`
|
|
121
|
+
+ `<span style="color:${SUBTEXT};"> in </span>`
|
|
122
|
+
+ `<span style="color:${GREEN};">${esc(frame.func)}</span>`
|
|
123
|
+
+ `</div>`
|
|
124
|
+
+ source
|
|
125
|
+
+ `</div>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function collapsible(title: string, content: string, openByDefault = false): string {
|
|
129
|
+
const open = openByDefault ? " open" : "";
|
|
130
|
+
return `<details style="margin-top:16px;"${open}>`
|
|
131
|
+
+ `<summary style="cursor:pointer;color:${LAVENDER};font-weight:600;font-size:15px;`
|
|
132
|
+
+ `padding:8px 0;user-select:none;">${esc(title)}</summary>`
|
|
133
|
+
+ `<div style="padding:8px 0;">${content}</div>`
|
|
134
|
+
+ `</details>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function table(pairs: Array<[string, string]>): string {
|
|
138
|
+
if (pairs.length === 0) return `<span style="color:${SUBTEXT};">None</span>`;
|
|
139
|
+
const rows = pairs.map(([key, val]) =>
|
|
140
|
+
`<tr>`
|
|
141
|
+
+ `<td style="color:${PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;">${esc(key)}</td>`
|
|
142
|
+
+ `<td style="color:${TEXT};padding:4px 0;word-break:break-all;">${esc(val)}</td>`
|
|
143
|
+
+ `</tr>`
|
|
144
|
+
).join("");
|
|
145
|
+
return `<table style="border-collapse:collapse;width:100%;">${rows}</table>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render a rich HTML error overlay.
|
|
150
|
+
*
|
|
151
|
+
* @param error - The caught error.
|
|
152
|
+
* @param request - Optional request object with method, url, headers, etc.
|
|
153
|
+
* @returns Complete HTML page string.
|
|
154
|
+
*/
|
|
155
|
+
export function renderErrorOverlay(error: Error, request?: any): string {
|
|
156
|
+
const excType = error.constructor?.name ?? "Error";
|
|
157
|
+
const excMsg = error.message ?? String(error);
|
|
158
|
+
const frames = error.stack ? parseStack(error.stack) : [];
|
|
159
|
+
|
|
160
|
+
// ── Stack trace ──
|
|
161
|
+
let framesHtml = "";
|
|
162
|
+
for (const frame of frames) {
|
|
163
|
+
framesHtml += formatFrame(frame);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Request info ──
|
|
167
|
+
const requestPairs: Array<[string, string]> = [];
|
|
168
|
+
if (request != null) {
|
|
169
|
+
for (const attr of ["method", "url", "path"]) {
|
|
170
|
+
if (request[attr] != null) requestPairs.push([attr, String(request[attr])]);
|
|
171
|
+
}
|
|
172
|
+
if (request.headers && typeof request.headers === "object") {
|
|
173
|
+
for (const [hk, hv] of Object.entries(request.headers)) {
|
|
174
|
+
requestPairs.push([`headers.${hk}`, String(hv)]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (request.params && typeof request.params === "object") {
|
|
178
|
+
for (const [pk, pv] of Object.entries(request.params)) {
|
|
179
|
+
requestPairs.push([`params.${pk}`, String(pv)]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (request.query && typeof request.query === "object") {
|
|
183
|
+
for (const [qk, qv] of Object.entries(request.query)) {
|
|
184
|
+
requestPairs.push([`query.${qk}`, String(qv)]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const requestSection = requestPairs.length > 0
|
|
189
|
+
? collapsible("Request Details", table(requestPairs))
|
|
190
|
+
: "";
|
|
191
|
+
|
|
192
|
+
// ── Environment ──
|
|
193
|
+
const envPairs: Array<[string, string]> = [
|
|
194
|
+
["Framework", "Tina4 Node.js"],
|
|
195
|
+
["Node.js", process.version],
|
|
196
|
+
["Platform", process.platform],
|
|
197
|
+
["Arch", process.arch],
|
|
198
|
+
["Debug", process.env.TINA4_DEBUG ?? "false"],
|
|
199
|
+
["Log Level", process.env.TINA4_LOG_LEVEL ?? "ERROR"],
|
|
200
|
+
];
|
|
201
|
+
const envSection = collapsible("Environment", table(envPairs));
|
|
202
|
+
const stackSection = collapsible("Stack Trace", framesHtml, true);
|
|
203
|
+
|
|
204
|
+
return `<!DOCTYPE html>
|
|
205
|
+
<html lang="en">
|
|
206
|
+
<head>
|
|
207
|
+
<meta charset="utf-8">
|
|
208
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
209
|
+
<title>Tina4 Error — ${esc(excType)}</title>
|
|
210
|
+
<style>
|
|
211
|
+
*{margin:0;padding:0;box-sizing:border-box;}
|
|
212
|
+
body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<div style="max-width:960px;margin:0 auto;">
|
|
217
|
+
<div style="margin-bottom:24px;">
|
|
218
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
|
219
|
+
<span style="background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
|
|
220
|
+
<span style="color:${SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
|
|
221
|
+
</div>
|
|
222
|
+
<h1 style="color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;">${esc(excType)}</h1>
|
|
223
|
+
<p style="color:${TEXT};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:${SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid ${RED};">${esc(excMsg)}</p>
|
|
224
|
+
</div>
|
|
225
|
+
${stackSection}
|
|
226
|
+
${requestSection}
|
|
227
|
+
${envSection}
|
|
228
|
+
<div style="margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;">
|
|
229
|
+
Tina4 Debug Overlay — This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</body>
|
|
233
|
+
</html>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Render a safe, generic error page for production.
|
|
238
|
+
*/
|
|
239
|
+
export function renderProductionError(statusCode = 500, message = "Internal Server Error", path = ""): string {
|
|
240
|
+
const codeColor = statusCode === 403 ? "#f59e0b" : statusCode === 404 ? "#3b82f6" : "#ef4444";
|
|
241
|
+
const pathHtml = path ? `<div class="error-path">${esc(path)}</div><br>` : "";
|
|
242
|
+
return `<!DOCTYPE html>
|
|
243
|
+
<html lang="en">
|
|
244
|
+
<head>
|
|
245
|
+
<meta charset="utf-8">
|
|
246
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
247
|
+
<title>${statusCode} — ${esc(message)}</title>
|
|
248
|
+
<style>
|
|
249
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
250
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
251
|
+
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
252
|
+
.error-code { font-size: 8rem; font-weight: 900; color: ${codeColor}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
|
|
253
|
+
.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
|
|
254
|
+
.error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
255
|
+
.error-path { font-family: 'SF Mono', monospace; background: #0f172a; color: ${codeColor}; padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; margin-bottom: 1.5rem; display: inline-block; }
|
|
256
|
+
.error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
|
|
257
|
+
.error-home:hover { opacity: 0.9; }
|
|
258
|
+
.logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
|
|
259
|
+
</style>
|
|
260
|
+
</head>
|
|
261
|
+
<body>
|
|
262
|
+
<div class="error-card">
|
|
263
|
+
<div class="error-code">${statusCode}</div>
|
|
264
|
+
<div class="error-title">${esc(message)}</div>
|
|
265
|
+
<div class="error-msg">Something went wrong while processing your request.</div>
|
|
266
|
+
${pathHtml}
|
|
267
|
+
<a href="/" class="error-home">Go Home</a>
|
|
268
|
+
</div>
|
|
269
|
+
</body>
|
|
270
|
+
</html>`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if TINA4_DEBUG is enabled.
|
|
275
|
+
*/
|
|
276
|
+
export function isDebugMode(): boolean {
|
|
277
|
+
return isTruthy(process.env.TINA4_DEBUG);
|
|
278
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Events — Simple observer pattern for decoupled communication.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency event system. Fire events, register listeners.
|
|
5
|
+
*
|
|
6
|
+
* Events.on("user.created", (user) => console.log(`Welcome ${user.name}!`));
|
|
7
|
+
* Events.emit("user.created", { name: "Alice", email: "alice@example.com" });
|
|
8
|
+
*
|
|
9
|
+
* One-time listeners:
|
|
10
|
+
*
|
|
11
|
+
* Events.once("app.ready", () => console.log("App started!"));
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface ListenerEntry {
|
|
15
|
+
priority: number;
|
|
16
|
+
callback: (...args: unknown[]) => void;
|
|
17
|
+
once: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const _listeners: Map<string, ListenerEntry[]> = new Map();
|
|
21
|
+
|
|
22
|
+
function getEntries(event: string): ListenerEntry[] {
|
|
23
|
+
let entries = _listeners.get(event);
|
|
24
|
+
if (!entries) {
|
|
25
|
+
entries = [];
|
|
26
|
+
_listeners.set(event, entries);
|
|
27
|
+
}
|
|
28
|
+
return entries;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Events {
|
|
32
|
+
/**
|
|
33
|
+
* Register a listener for an event.
|
|
34
|
+
* Higher priority runs first.
|
|
35
|
+
*/
|
|
36
|
+
static on(event: string, callback: (...args: unknown[]) => void, priority: number = 0): void {
|
|
37
|
+
const entries = getEntries(event);
|
|
38
|
+
entries.push({ priority, callback, once: false });
|
|
39
|
+
entries.sort((a, b) => b.priority - a.priority);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register a listener that fires only once then auto-removes.
|
|
44
|
+
*/
|
|
45
|
+
static once(event: string, callback: (...args: unknown[]) => void, priority: number = 0): void {
|
|
46
|
+
const entries = getEntries(event);
|
|
47
|
+
entries.push({ priority, callback, once: true });
|
|
48
|
+
entries.sort((a, b) => b.priority - a.priority);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove a specific listener, or all listeners for an event.
|
|
53
|
+
*
|
|
54
|
+
* Events.off("user.created", handler) // remove specific
|
|
55
|
+
* Events.off("user.created") // remove all for event
|
|
56
|
+
*/
|
|
57
|
+
static off(event: string, callback?: (...args: unknown[]) => void): void {
|
|
58
|
+
if (callback === undefined) {
|
|
59
|
+
_listeners.delete(event);
|
|
60
|
+
} else {
|
|
61
|
+
const entries = _listeners.get(event);
|
|
62
|
+
if (entries) {
|
|
63
|
+
const filtered = entries.filter((e) => e.callback !== callback);
|
|
64
|
+
_listeners.set(event, filtered);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fire an event synchronously. Returns array of listener results.
|
|
71
|
+
*/
|
|
72
|
+
static emit(event: string, ...args: unknown[]): unknown[] {
|
|
73
|
+
const entries = _listeners.get(event);
|
|
74
|
+
if (!entries) return [];
|
|
75
|
+
|
|
76
|
+
const snapshot = [...entries];
|
|
77
|
+
const results: unknown[] = [];
|
|
78
|
+
|
|
79
|
+
for (const entry of snapshot) {
|
|
80
|
+
if (entry.once) {
|
|
81
|
+
const idx = entries.indexOf(entry);
|
|
82
|
+
if (idx !== -1) entries.splice(idx, 1);
|
|
83
|
+
}
|
|
84
|
+
results.push(entry.callback(...args));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get all listener callbacks for an event (in priority order).
|
|
92
|
+
*/
|
|
93
|
+
static listeners(event: string): Array<(...args: unknown[]) => void> {
|
|
94
|
+
const entries = _listeners.get(event);
|
|
95
|
+
if (!entries) return [];
|
|
96
|
+
return entries.map((e) => e.callback);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* List all registered event names.
|
|
101
|
+
*/
|
|
102
|
+
static events(): string[] {
|
|
103
|
+
return [..._listeners.keys()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove all listeners for all events.
|
|
108
|
+
*/
|
|
109
|
+
static clear(): void {
|
|
110
|
+
_listeners.clear();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Tina4 FakeData — Fake data generation and database seeding, zero dependencies.
|
|
2
|
+
// Instance-based with optional seeded PRNG for deterministic output.
|
|
3
|
+
|
|
4
|
+
import { randomInt, randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { resolve, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
// ── Word Banks ───────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const FIRST_NAMES = [
|
|
11
|
+
"Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Henry",
|
|
12
|
+
"Ivy", "Jack", "Kate", "Leo", "Mia", "Noah", "Olivia", "Pete",
|
|
13
|
+
"Quinn", "Rose", "Sam", "Tina", "Uma", "Vince", "Wendy", "Xander",
|
|
14
|
+
"Yara", "Zane", "Anna", "Ben", "Chloe", "Dan", "Emma", "Felix",
|
|
15
|
+
"Gina", "Hugo", "Iris", "Jake", "Lily", "Max", "Nora", "Oscar",
|
|
16
|
+
"Penny", "Ray", "Sara", "Tom", "Vera", "Will", "Xena", "Yves",
|
|
17
|
+
"Zara", "Amber", "Blake", "Clara",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const LAST_NAMES = [
|
|
21
|
+
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
|
|
22
|
+
"Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Wilson",
|
|
23
|
+
"Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee",
|
|
24
|
+
"Perez", "Thompson", "White", "Harris", "Clark", "Lewis", "Young",
|
|
25
|
+
"Walker", "Hall", "Allen", "King", "Wright", "Scott", "Green",
|
|
26
|
+
"Adams", "Baker", "Nelson", "Carter", "Mitchell", "Roberts", "Turner",
|
|
27
|
+
"Phillips", "Campbell", "Parker", "Evans", "Edwards", "Collins",
|
|
28
|
+
"Stewart", "Morris", "Murphy", "Cook",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const DOMAINS = ["example.com", "test.org", "demo.net", "mail.dev", "inbox.io"];
|
|
32
|
+
|
|
33
|
+
const WORDS = [
|
|
34
|
+
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog",
|
|
35
|
+
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing",
|
|
36
|
+
"elit", "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore",
|
|
37
|
+
"magna", "aliqua", "enim", "minim", "veniam", "quis", "nostrud",
|
|
38
|
+
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "commodo",
|
|
39
|
+
"consequat", "duis", "aute", "irure", "reprehenderit", "voluptate",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const CITIES = [
|
|
43
|
+
"New York", "London", "Tokyo", "Paris", "Sydney", "Berlin", "Toronto",
|
|
44
|
+
"Cape Town", "Mumbai", "Singapore", "Dubai", "Amsterdam", "Seoul",
|
|
45
|
+
"Barcelona", "Melbourne", "Stockholm", "Vienna", "Zurich", "Oslo",
|
|
46
|
+
"Helsinki", "Prague", "Warsaw", "Dublin", "Brussels", "Lisbon",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const COUNTRIES = [
|
|
50
|
+
"United States", "United Kingdom", "Japan", "France", "Australia",
|
|
51
|
+
"Germany", "Canada", "South Africa", "India", "Singapore", "UAE",
|
|
52
|
+
"Netherlands", "South Korea", "Spain", "Brazil", "Italy", "Mexico",
|
|
53
|
+
"Sweden", "Switzerland", "Norway",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const STREETS = [
|
|
57
|
+
"Main St", "Oak Ave", "Park Rd", "Cedar Ln", "Elm St", "Pine Dr",
|
|
58
|
+
"Maple Way", "River Rd", "Lake Blvd", "Hill Ct", "Valley View",
|
|
59
|
+
"Sunset Blvd", "Broadway", "Church St", "Mill Rd",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const JOB_TITLES = [
|
|
63
|
+
"Software Engineer", "Product Manager", "Data Analyst", "Designer",
|
|
64
|
+
"DevOps Engineer", "QA Engineer", "Project Manager", "CTO",
|
|
65
|
+
"Marketing Manager", "Sales Director", "HR Manager", "Accountant",
|
|
66
|
+
"Consultant", "Architect", "Team Lead", "VP Engineering",
|
|
67
|
+
"Frontend Developer", "Backend Developer", "Full Stack Developer",
|
|
68
|
+
"Systems Administrator",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const COLORS = [
|
|
72
|
+
"red", "blue", "green", "yellow", "purple", "orange", "pink",
|
|
73
|
+
"cyan", "magenta", "teal", "indigo", "violet", "coral", "salmon",
|
|
74
|
+
"turquoise", "maroon", "navy", "olive", "silver", "gold",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const CURRENCIES = [
|
|
78
|
+
"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "CNY",
|
|
79
|
+
"SEK", "NZD", "MXN", "SGD", "HKD", "NOK", "ZAR", "INR",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// ── Seeded PRNG (mulberry32) ─────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mulberry32: a simple 32-bit seeded PRNG.
|
|
86
|
+
* Returns a function that produces values in [0, 1).
|
|
87
|
+
*/
|
|
88
|
+
function mulberry32(seed: number): () => number {
|
|
89
|
+
let s = seed | 0;
|
|
90
|
+
return () => {
|
|
91
|
+
s = (s + 0x6D2B79F5) | 0;
|
|
92
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
93
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
94
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── FakeData Class ───────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export class FakeData {
|
|
101
|
+
private rng: () => number;
|
|
102
|
+
private seeded: boolean;
|
|
103
|
+
|
|
104
|
+
constructor(seed?: number) {
|
|
105
|
+
if (seed !== undefined) {
|
|
106
|
+
this.rng = mulberry32(seed);
|
|
107
|
+
this.seeded = true;
|
|
108
|
+
} else {
|
|
109
|
+
this.rng = Math.random;
|
|
110
|
+
this.seeded = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Static factory — create a seeded FakeData instance. */
|
|
115
|
+
static seed(seed: number): FakeData {
|
|
116
|
+
return new FakeData(seed);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Returns a random integer in [min, max) using the instance PRNG. */
|
|
120
|
+
private randInt(min: number, max: number): number {
|
|
121
|
+
if (this.seeded) {
|
|
122
|
+
return min + Math.floor(this.rng() * (max - min));
|
|
123
|
+
}
|
|
124
|
+
return randomInt(min, max);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Pick a random element from an array. */
|
|
128
|
+
private pick<T>(arr: readonly T[]): T {
|
|
129
|
+
return arr[this.randInt(0, arr.length)];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
firstName(): string {
|
|
133
|
+
return this.pick(FIRST_NAMES);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
lastName(): string {
|
|
137
|
+
return this.pick(LAST_NAMES);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fullName(): string {
|
|
141
|
+
return `${this.firstName()} ${this.lastName()}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
email(): string {
|
|
145
|
+
const first = this.firstName().toLowerCase();
|
|
146
|
+
const last = this.lastName().toLowerCase();
|
|
147
|
+
const domain = this.pick(DOMAINS);
|
|
148
|
+
return `${first}.${last}@${domain}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
phone(): string {
|
|
152
|
+
const area = this.randInt(200, 1000);
|
|
153
|
+
const mid = this.randInt(100, 1000);
|
|
154
|
+
const end = this.randInt(1000, 10000);
|
|
155
|
+
return `+1 (${area}) ${mid}-${end}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
address(): string {
|
|
159
|
+
const num = this.randInt(1, 1000);
|
|
160
|
+
const street = this.pick(STREETS);
|
|
161
|
+
const city = this.pick(CITIES);
|
|
162
|
+
return `${num} ${street}, ${city}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
city(): string {
|
|
166
|
+
return this.pick(CITIES);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
country(): string {
|
|
170
|
+
return this.pick(COUNTRIES);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
zipCode(): string {
|
|
174
|
+
return String(this.randInt(10000, 100000));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
company(): string {
|
|
178
|
+
const last = this.pick(LAST_NAMES);
|
|
179
|
+
const suffixes = ["Inc", "LLC", "Corp", "Ltd", "Group", "Solutions", "Tech"];
|
|
180
|
+
return `${last} ${this.pick(suffixes)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
jobTitle(): string {
|
|
184
|
+
return this.pick(JOB_TITLES);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
paragraph(sentences = 4): string {
|
|
188
|
+
const parts: string[] = [];
|
|
189
|
+
for (let i = 0; i < sentences; i++) {
|
|
190
|
+
parts.push(this.sentence(this.randInt(5, 13)));
|
|
191
|
+
}
|
|
192
|
+
return parts.join(" ");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
sentence(words = 8): string {
|
|
196
|
+
const parts: string[] = [];
|
|
197
|
+
for (let i = 0; i < words; i++) {
|
|
198
|
+
parts.push(this.pick(WORDS));
|
|
199
|
+
}
|
|
200
|
+
const s = parts.join(" ");
|
|
201
|
+
return s.charAt(0).toUpperCase() + s.slice(1) + ".";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
word(): string {
|
|
205
|
+
return this.pick(WORDS);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
integer(min = 0, max = 10000): number {
|
|
209
|
+
return this.randInt(min, max + 1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
float(min = 0, max = 1000, decimals = 2): number {
|
|
213
|
+
const raw = min + this.rng() * (max - min);
|
|
214
|
+
return Number(raw.toFixed(decimals));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
boolean(): boolean {
|
|
218
|
+
return this.randInt(0, 2) === 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
date(start?: string, end?: string): string {
|
|
222
|
+
const startDate = start ? new Date(start) : new Date("2020-01-01");
|
|
223
|
+
const endDate = end ? new Date(end) : new Date("2025-12-31");
|
|
224
|
+
const diffDays = Math.floor((endDate.getTime() - startDate.getTime()) / 86400000);
|
|
225
|
+
const offset = this.randInt(0, diffDays + 1);
|
|
226
|
+
const d = new Date(startDate.getTime() + offset * 86400000);
|
|
227
|
+
const yyyy = d.getFullYear();
|
|
228
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
229
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
230
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
uuid(): string {
|
|
234
|
+
if (this.seeded) {
|
|
235
|
+
// Generate a UUID-like string from seeded PRNG
|
|
236
|
+
const hex = () => this.randInt(0, 16).toString(16);
|
|
237
|
+
const block = (n: number) => Array.from({ length: n }, hex).join("");
|
|
238
|
+
return `${block(8)}-${block(4)}-${block(4)}-${block(4)}-${block(12)}`;
|
|
239
|
+
}
|
|
240
|
+
return randomUUID();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
url(): string {
|
|
244
|
+
const domain = this.pick(DOMAINS);
|
|
245
|
+
const p1 = this.pick(WORDS);
|
|
246
|
+
const p2 = this.pick(WORDS);
|
|
247
|
+
return `https://${domain}/${p1}/${p2}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ipAddress(): string {
|
|
251
|
+
return `${this.randInt(1, 256)}.${this.randInt(0, 256)}.${this.randInt(0, 256)}.${this.randInt(1, 256)}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
color(): string {
|
|
255
|
+
return this.pick(COLORS);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
hexColor(): string {
|
|
259
|
+
const hex = this.randInt(0, 0x1000000).toString(16).padStart(6, "0");
|
|
260
|
+
return `#${hex}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Returns fake test credit card numbers (Luhn-valid test patterns). */
|
|
264
|
+
creditCard(): string {
|
|
265
|
+
const prefixes = ["4111111111111111", "5500000000000004", "340000000000009", "30000000000004"];
|
|
266
|
+
return this.pick(prefixes);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
currency(): string {
|
|
270
|
+
return this.pick(CURRENCIES);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Run seed files from a directory. Each file should export a default async function.
|
|
275
|
+
* Returns an array of executed file paths.
|
|
276
|
+
*/
|
|
277
|
+
async runSeeds(seedDir?: string): Promise<string[]> {
|
|
278
|
+
const dir = resolve(seedDir ?? "src/seeds");
|
|
279
|
+
if (!existsSync(dir)) return [];
|
|
280
|
+
const files = readdirSync(dir)
|
|
281
|
+
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
|
|
282
|
+
.sort();
|
|
283
|
+
const executed: string[] = [];
|
|
284
|
+
for (const file of files) {
|
|
285
|
+
const fullPath = join(dir, file);
|
|
286
|
+
try {
|
|
287
|
+
const mod = await import(fullPath);
|
|
288
|
+
if (typeof mod.default === "function") {
|
|
289
|
+
await mod.default();
|
|
290
|
+
}
|
|
291
|
+
executed.push(fullPath);
|
|
292
|
+
} catch {
|
|
293
|
+
// skip failed seed files
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return executed;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Run a generator function `count` times and return the results.
|
|
301
|
+
*/
|
|
302
|
+
run(fn: () => Record<string, unknown>, count = 1): Record<string, unknown>[] {
|
|
303
|
+
const results: Record<string, unknown>[] = [];
|
|
304
|
+
for (let i = 0; i < count; i++) {
|
|
305
|
+
results.push(fn());
|
|
306
|
+
}
|
|
307
|
+
return results;
|
|
308
|
+
}
|
|
309
|
+
}
|