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,610 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { resolve, dirname, join, relative } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { isatty } from "node:tty";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
|
|
7
|
+
import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
8
|
+
import { discoverRoutes } from "./routeDiscovery.js";
|
|
9
|
+
import { createRequest, parseBody } from "./request.js";
|
|
10
|
+
import { createResponse } from "./response.js";
|
|
11
|
+
import { MiddlewareChain, cors, requestLogger } from "./middleware.js";
|
|
12
|
+
import { tryServeStatic } from "./static.js";
|
|
13
|
+
import { loadEnv, isTruthy } from "./dotenv.js";
|
|
14
|
+
import { createHealthRoute } from "./health.js";
|
|
15
|
+
import { rateLimiter } from "./rateLimiter.js";
|
|
16
|
+
import { Log } from "./logger.js";
|
|
17
|
+
import { DevAdmin, RequestInspector } from "./devAdmin.js";
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = dirname(__filename);
|
|
21
|
+
|
|
22
|
+
/** Built-in error templates directory (ships with @tina4/core). */
|
|
23
|
+
const BUILTIN_ERROR_TEMPLATES_DIR = resolve(__dirname, "..", "templates");
|
|
24
|
+
|
|
25
|
+
/** Built-in public directory for framework-bundled static assets. */
|
|
26
|
+
const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
|
|
27
|
+
|
|
28
|
+
const TINA4_VERSION = "3.0.0";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve port and host with priority: explicit config > ENV var > default.
|
|
32
|
+
* Exported for testability.
|
|
33
|
+
*/
|
|
34
|
+
export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } {
|
|
35
|
+
const port = config?.port
|
|
36
|
+
?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined)
|
|
37
|
+
?? 7148;
|
|
38
|
+
const host = config?.host
|
|
39
|
+
?? process.env.HOST
|
|
40
|
+
?? "0.0.0.0";
|
|
41
|
+
return { port, host };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isDevMode(): boolean {
|
|
45
|
+
return isTruthy(process.env.TINA4_DEBUG);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render an error page using Twig templates via Frond.
|
|
50
|
+
* Priority: user override (src/templates/errors/{code}.twig) > built-in default > JSON fallback.
|
|
51
|
+
*/
|
|
52
|
+
async function renderErrorPage(
|
|
53
|
+
code: number,
|
|
54
|
+
data: Record<string, unknown>,
|
|
55
|
+
templatesDir: string,
|
|
56
|
+
): Promise<string | null> {
|
|
57
|
+
try {
|
|
58
|
+
const { Frond } = await import("@tina4/frond");
|
|
59
|
+
const templateFile = `errors/${code}.twig`;
|
|
60
|
+
|
|
61
|
+
// 1. Try user override in the project's templates directory
|
|
62
|
+
const userTemplatePath = join(templatesDir, templateFile);
|
|
63
|
+
if (existsSync(userTemplatePath)) {
|
|
64
|
+
const frond = new Frond(templatesDir);
|
|
65
|
+
return frond.render(templateFile, data);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Try built-in framework default
|
|
69
|
+
const builtinTemplatePath = join(BUILTIN_ERROR_TEMPLATES_DIR, templateFile);
|
|
70
|
+
if (existsSync(builtinTemplatePath)) {
|
|
71
|
+
const frond = new Frond(BUILTIN_ERROR_TEMPLATES_DIR);
|
|
72
|
+
return frond.render(templateFile, data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. No template found
|
|
76
|
+
return null;
|
|
77
|
+
} catch {
|
|
78
|
+
// Frond not available or template rendering failed — fall back to JSON
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DevToolbarContext {
|
|
84
|
+
version: string;
|
|
85
|
+
method: string;
|
|
86
|
+
path: string;
|
|
87
|
+
matchedPattern: string;
|
|
88
|
+
requestId: string;
|
|
89
|
+
routeCount: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function injectDevToolbar(html: string, ctx: DevToolbarContext): string {
|
|
93
|
+
const toolbar = DevAdmin.renderToolbarHtml(ctx);
|
|
94
|
+
if (html.includes("</body>")) {
|
|
95
|
+
return html.replace("</body>", toolbar + "\n</body>");
|
|
96
|
+
}
|
|
97
|
+
return html + toolbar;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkGalleryFiles(dir: string): string[] {
|
|
101
|
+
const results: string[] = [];
|
|
102
|
+
if (!existsSync(dir)) return results;
|
|
103
|
+
for (const f of readdirSync(dir)) {
|
|
104
|
+
const full = join(dir, f);
|
|
105
|
+
if (statSync(full).isDirectory()) results.push(...walkGalleryFiles(full));
|
|
106
|
+
else results.push(full);
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getGalleryDeployedState(): Record<string, boolean> {
|
|
112
|
+
const galleryDir = resolve(__dirname, "..", "gallery");
|
|
113
|
+
const state: Record<string, boolean> = {};
|
|
114
|
+
if (!existsSync(galleryDir)) return state;
|
|
115
|
+
try {
|
|
116
|
+
const entries = readdirSync(galleryDir).sort();
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const entryPath = join(galleryDir, entry);
|
|
119
|
+
const metaFile = join(entryPath, "meta.json");
|
|
120
|
+
if (statSync(entryPath).isDirectory() && existsSync(metaFile)) {
|
|
121
|
+
const srcDir = join(entryPath, "src");
|
|
122
|
+
if (existsSync(srcDir)) {
|
|
123
|
+
const files = walkGalleryFiles(srcDir);
|
|
124
|
+
const projectSrc = resolve(process.cwd(), "src");
|
|
125
|
+
state[entry] = files.every((f) => existsSync(join(projectSrc, relative(srcDir, f))));
|
|
126
|
+
} else {
|
|
127
|
+
state[entry] = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch { /* ignore */ }
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderLandingPage(routes: Array<{ method: string; pattern: string; flags?: string[] }>, port: number = 7148): string {
|
|
136
|
+
const version = TINA4_VERSION;
|
|
137
|
+
|
|
138
|
+
const galleryItems = [
|
|
139
|
+
{ id: "rest-api", icon: "🚀", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", accent: "accent-blue", tryUrl: "/api/gallery/hello" },
|
|
140
|
+
{ id: "orm", icon: "🗃", name: "ORM", desc: "Product model with CRUD endpoints", accent: "accent-green", tryUrl: "/api/gallery/products" },
|
|
141
|
+
{ id: "auth", icon: "🔒", name: "Auth", desc: "JWT login form with token display", accent: "accent-purple", tryUrl: "/gallery/auth" },
|
|
142
|
+
{ id: "queue", icon: "⚡", name: "Queue", desc: "Background job producer and consumer", accent: "accent-blue", tryUrl: "/api/gallery/queue/produce" },
|
|
143
|
+
{ id: "templates", icon: "📄", name: "Templates", desc: "Twig template with dynamic data", accent: "accent-green", tryUrl: "/gallery/page" },
|
|
144
|
+
{ id: "database", icon: "📡", name: "Database", desc: "Raw SQL queries with the Database class", accent: "accent-purple", tryUrl: "/api/gallery/db/tables" },
|
|
145
|
+
{ id: "error-overlay", icon: "💥", name: "Error Overlay", desc: "See the rich debug error page with stack trace", accent: "accent-blue", tryUrl: "/api/gallery/crash" },
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const deployed = getGalleryDeployedState();
|
|
149
|
+
|
|
150
|
+
const galleryCards = galleryItems.map((item) => {
|
|
151
|
+
const isDeployed = deployed[item.id] === true;
|
|
152
|
+
const tryBtn = isDeployed
|
|
153
|
+
? `<a href="${item.tryUrl}" class="gbtn gbtn-try" target="_blank">Try It</a>`
|
|
154
|
+
: "";
|
|
155
|
+
const viewBtn = isDeployed
|
|
156
|
+
? `<a href="${item.tryUrl}" class="gbtn gbtn-view" target="_blank">View</a>`
|
|
157
|
+
: "";
|
|
158
|
+
const deployBtn = isDeployed
|
|
159
|
+
? `<span class="gbtn gbtn-deployed">Deployed</span>`
|
|
160
|
+
: `<button class="gbtn gbtn-deploy" onclick="deployGallery('${item.id}')">Deploy</button>`;
|
|
161
|
+
return `<div class="gallery-card">
|
|
162
|
+
<div class="accent ${item.accent}"></div>
|
|
163
|
+
<div class="icon">${item.icon}</div>
|
|
164
|
+
<h3>${item.name}</h3>
|
|
165
|
+
<p>${item.desc}</p>
|
|
166
|
+
<div class="gallery-actions">${tryBtn}${viewBtn}${deployBtn}</div>
|
|
167
|
+
</div>`;
|
|
168
|
+
}).join("\n ");
|
|
169
|
+
|
|
170
|
+
return `<!DOCTYPE html>
|
|
171
|
+
<html lang="en">
|
|
172
|
+
<head>
|
|
173
|
+
<meta charset="utf-8">
|
|
174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
+
<title>Tina4NodeJs</title>
|
|
176
|
+
<style>
|
|
177
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
178
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
|
|
179
|
+
.bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
|
|
180
|
+
.hero{text-align:center;z-index:1;padding:3rem 2rem 2rem}
|
|
181
|
+
.logo{width:120px;height:120px;margin-bottom:1.5rem}
|
|
182
|
+
h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
|
|
183
|
+
.tagline{color:#64748b;font-size:1.1rem;margin-bottom:2rem}
|
|
184
|
+
.actions{display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap;margin-bottom:2.5rem}
|
|
185
|
+
.btn{padding:0.6rem 1.5rem;border-radius:0.5rem;font-size:0.9rem;font-weight:600;cursor:pointer;text-decoration:none;transition:all 0.15s;border:1px solid #334155;color:#94a3b8;background:transparent;min-width:140px;text-align:center;display:inline-block}
|
|
186
|
+
.btn:hover{border-color:#64748b;color:#e2e8f0}
|
|
187
|
+
.status{display:flex;gap:2rem;justify-content:center;align-items:center;color:#64748b;font-size:0.85rem;margin-bottom:1.5rem}
|
|
188
|
+
.status .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;margin-right:0.4rem}
|
|
189
|
+
.footer{color:#334155;font-size:0.8rem;letter-spacing:0.5px}
|
|
190
|
+
.section{z-index:1;width:100%;max-width:800px;padding:0 2rem;margin-bottom:2.5rem}
|
|
191
|
+
.card{background:#1e293b;border-radius:0.75rem;padding:2rem;border:1px solid #334155}
|
|
192
|
+
.card h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0}
|
|
193
|
+
.code-block{background:#0f172a;border-radius:0.5rem;padding:1.25rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:0.85rem;line-height:1.6;color:#4ade80;border:1px solid #1e293b}
|
|
194
|
+
.gallery{z-index:1;width:100%;max-width:800px;padding:0 2rem;margin-bottom:3rem}
|
|
195
|
+
.gallery h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0;text-align:center}
|
|
196
|
+
.gallery-card{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}
|
|
197
|
+
.gallery-card .accent{position:absolute;top:0;left:0;right:0;height:3px}
|
|
198
|
+
.gallery-card .accent-blue{background:#2e7d32}
|
|
199
|
+
.gallery-card .accent-green{background:#22c55e}
|
|
200
|
+
.gallery-card .accent-purple{background:#a78bfa}
|
|
201
|
+
.gallery-card .icon{font-size:1.5rem;margin-bottom:0.75rem}
|
|
202
|
+
.gallery-card h3{font-size:1rem;font-weight:600;margin-bottom:0.5rem;color:#e2e8f0}
|
|
203
|
+
.gallery-card p{font-size:0.85rem;color:#94a3b8;line-height:1.5;margin-bottom:0.75rem}
|
|
204
|
+
.gallery-actions{display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.5rem}
|
|
205
|
+
.gbtn{padding:0.35rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;font-weight:600;cursor:pointer;text-decoration:none;border:none;display:inline-block;text-align:center;transition:all 0.15s}
|
|
206
|
+
.gbtn-try{background:#22c55e;color:#0f172a}
|
|
207
|
+
.gbtn-try:hover{background:#16a34a}
|
|
208
|
+
.gbtn-view{background:transparent;border:1px solid #334155;color:#94a3b8}
|
|
209
|
+
.gbtn-view:hover{border-color:#64748b;color:#e2e8f0}
|
|
210
|
+
.gbtn-deploy{background:#3b82f6;color:#fff}
|
|
211
|
+
.gbtn-deploy:hover{background:#2563eb}
|
|
212
|
+
.gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<img src="/images/tina4-logo-icon.webp" class="bg-watermark" alt="">
|
|
217
|
+
<div class="hero">
|
|
218
|
+
<img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
|
|
219
|
+
<h1>Tina4NodeJs</h1>
|
|
220
|
+
<p class="tagline">This is not a framework</p>
|
|
221
|
+
<div class="actions">
|
|
222
|
+
<a href="https://tina4.com/nodejs" class="btn" target="_blank">Website</a>
|
|
223
|
+
<a href="/__dev" class="btn">Dev Admin</a>
|
|
224
|
+
<a href="#gallery" class="btn">Gallery</a>
|
|
225
|
+
<a href="https://github.com/tina4stack/tina4-nodejs" class="btn" target="_blank">GitHub</a>
|
|
226
|
+
<a href="https://github.com/tina4stack/tina4-nodejs/stargazers" class="btn" target="_blank">⭐ Star</a>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="status">
|
|
229
|
+
<span><span class="dot"></span>Server running</span>
|
|
230
|
+
<span>Port ${port}</span>
|
|
231
|
+
<span>v${version}</span>
|
|
232
|
+
</div>
|
|
233
|
+
<p class="footer">Zero dependencies · Convention over configuration</p>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="section">
|
|
236
|
+
<div class="card">
|
|
237
|
+
<h2>Getting Started</h2>
|
|
238
|
+
<pre class="code-block"><code><span style="color:#64748b">// app.ts</span>
|
|
239
|
+
<span style="color:#c084fc">import</span> { startServer, Router } <span style="color:#c084fc">from</span> <span style="color:#4ade80">"tina4-nodejs"</span>;
|
|
240
|
+
|
|
241
|
+
Router.get(<span style="color:#4ade80">"/hello"</span>, <span style="color:#c084fc">async</span> (<span style="color:#38bdf8">req</span>, <span style="color:#38bdf8">res</span>) => {
|
|
242
|
+
<span style="color:#c084fc">return</span> res.json({ message: <span style="color:#4ade80">"Hello World!"</span> });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
startServer({ port: 7148 }); <span style="color:#64748b">// starts on port 7148</span></code></pre>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="gallery">
|
|
249
|
+
<h2 id="gallery">What You Can Build</h2>
|
|
250
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
|
|
251
|
+
${galleryCards}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
<script>
|
|
255
|
+
function deployGallery(name) {
|
|
256
|
+
if (!confirm('Deploy the "' + name + '" gallery example into your project? This will copy files into src/.')) return;
|
|
257
|
+
fetch('/__dev/api/gallery/deploy', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({ name: name })
|
|
261
|
+
})
|
|
262
|
+
.then(function(r) { return r.json(); })
|
|
263
|
+
.then(function(d) {
|
|
264
|
+
if (d.error) {
|
|
265
|
+
alert('Deploy failed: ' + d.error);
|
|
266
|
+
} else {
|
|
267
|
+
alert('Deployed "' + d.deployed + '" (' + d.files.length + ' files). Reloading...');
|
|
268
|
+
window.location.reload();
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
.catch(function(e) { alert('Deploy error: ' + e.message); });
|
|
272
|
+
}
|
|
273
|
+
</script>
|
|
274
|
+
</body>
|
|
275
|
+
</html>`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function startServer(config?: Tina4Config): Promise<{
|
|
279
|
+
close: () => void;
|
|
280
|
+
router: Router;
|
|
281
|
+
port: number;
|
|
282
|
+
}> {
|
|
283
|
+
const { port, host } = resolvePortAndHost(config);
|
|
284
|
+
const routesDir = resolve(config?.routesDir ?? "src/routes");
|
|
285
|
+
const modelsDir = resolve(config?.modelsDir ?? "src/models");
|
|
286
|
+
const staticDir = resolve(config?.staticDir ?? "public");
|
|
287
|
+
const templatesDir = resolve(config?.templatesDir ?? "src/templates");
|
|
288
|
+
|
|
289
|
+
// Load .env file
|
|
290
|
+
loadEnv();
|
|
291
|
+
|
|
292
|
+
const router = new Router();
|
|
293
|
+
const middleware = new MiddlewareChain();
|
|
294
|
+
|
|
295
|
+
// Merge routes registered via top-level get(), post(), etc.
|
|
296
|
+
for (const route of defaultRouter.getRoutes()) {
|
|
297
|
+
router.addRoute(route);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Register health check endpoint
|
|
301
|
+
const healthRoute = createHealthRoute(TINA4_VERSION);
|
|
302
|
+
router.addRoute(healthRoute);
|
|
303
|
+
|
|
304
|
+
// Initialize Twig if available
|
|
305
|
+
let twigAvailable = false;
|
|
306
|
+
try {
|
|
307
|
+
const twig = await import("@tina4/twig");
|
|
308
|
+
twig.setTemplatesDir(templatesDir);
|
|
309
|
+
twigAvailable = true;
|
|
310
|
+
} catch {
|
|
311
|
+
// Twig not installed, res.render() won't be available
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Built-in middleware
|
|
315
|
+
middleware.use(cors());
|
|
316
|
+
middleware.use(requestLogger());
|
|
317
|
+
middleware.use(rateLimiter());
|
|
318
|
+
|
|
319
|
+
// Discover file-based routes
|
|
320
|
+
if (existsSync(routesDir)) {
|
|
321
|
+
const routes = await discoverRoutes(routesDir);
|
|
322
|
+
for (const route of routes) {
|
|
323
|
+
router.addRoute(route);
|
|
324
|
+
}
|
|
325
|
+
console.log(`\n Routes discovered:`);
|
|
326
|
+
for (const route of routes) {
|
|
327
|
+
console.log(` \x1b[36m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
console.log(`\n No routes directory found at ${routesDir}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Initialize ORM if models directory exists
|
|
334
|
+
if (existsSync(modelsDir)) {
|
|
335
|
+
try {
|
|
336
|
+
const orm = await import("@tina4/orm");
|
|
337
|
+
const dbConfig = config?.database ?? {};
|
|
338
|
+
await orm.initDatabase({
|
|
339
|
+
type: dbConfig.type ?? "sqlite",
|
|
340
|
+
path: dbConfig.path ?? "./data/tina4.db",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const models = await orm.discoverModels(modelsDir);
|
|
344
|
+
if (models.length > 0) {
|
|
345
|
+
console.log(`\n Models discovered:`);
|
|
346
|
+
orm.syncModels(models);
|
|
347
|
+
for (const { definition } of models) {
|
|
348
|
+
console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Generate auto-CRUD routes (file-based routes take precedence)
|
|
352
|
+
const crudRoutes = orm.generateCrudRoutes(models);
|
|
353
|
+
for (const route of crudRoutes) {
|
|
354
|
+
// Only add if no file-based route already handles this
|
|
355
|
+
const existing = router.match(route.method, route.pattern.replace(/\{(\w+)\}/g, "test").replace(/\[(\w+)\]/g, "test"));
|
|
356
|
+
if (!existing) {
|
|
357
|
+
router.addRoute(route);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.log(`\n Auto-CRUD endpoints:`);
|
|
362
|
+
for (const route of crudRoutes) {
|
|
363
|
+
console.log(` \x1b[33m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.warn(`\n ORM not available (install @tina4/orm to enable):`, err);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Initialize Swagger
|
|
372
|
+
try {
|
|
373
|
+
const swagger = await import("@tina4/swagger");
|
|
374
|
+
const allRoutes = router.getRoutes();
|
|
375
|
+
|
|
376
|
+
// Collect model definitions for schema generation
|
|
377
|
+
let modelDefs: Array<{ tableName: string; fields: Record<string, unknown> }> = [];
|
|
378
|
+
try {
|
|
379
|
+
const orm = await import("@tina4/orm");
|
|
380
|
+
if (existsSync(modelsDir)) {
|
|
381
|
+
const models = await orm.discoverModels(modelsDir);
|
|
382
|
+
modelDefs = models.map((m) => m.definition);
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
// ORM not available, swagger will work without model schemas
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const getSpec = () => swagger.generateOpenAPISpec(allRoutes, modelDefs as any);
|
|
389
|
+
const swaggerRoutes = swagger.createSwaggerRoutes(getSpec);
|
|
390
|
+
for (const route of swaggerRoutes) {
|
|
391
|
+
router.addRoute(route);
|
|
392
|
+
}
|
|
393
|
+
} catch {
|
|
394
|
+
// Swagger not available
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Register dev admin dashboard routes
|
|
398
|
+
if (DevAdmin.isEnabled()) {
|
|
399
|
+
DevAdmin.register(router);
|
|
400
|
+
console.log(` Dev dashboard at \x1b[36mhttp://localhost:${port}/__dev\x1b[0m`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const server = createServer(async (rawReq, rawRes) => {
|
|
404
|
+
const req = createRequest(rawReq);
|
|
405
|
+
const res = createResponse(rawRes);
|
|
406
|
+
|
|
407
|
+
// Add res.render() if Twig is available
|
|
408
|
+
if (twigAvailable) {
|
|
409
|
+
try {
|
|
410
|
+
const twig = await import("@tina4/twig");
|
|
411
|
+
twig.addRenderMethod(res);
|
|
412
|
+
} catch { /* ignore */ }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
// Run middleware chain
|
|
417
|
+
await middleware.run(req, res);
|
|
418
|
+
if (res.raw.writableEnded) return;
|
|
419
|
+
|
|
420
|
+
// Parse request body
|
|
421
|
+
await parseBody(req);
|
|
422
|
+
|
|
423
|
+
const pathname = (req.url ?? "/").split("?")[0];
|
|
424
|
+
|
|
425
|
+
// Track request start time for dev inspector
|
|
426
|
+
const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0;
|
|
427
|
+
|
|
428
|
+
// Mutable ref so wrappedEnd can read the matched pattern after route matching
|
|
429
|
+
let matchedPattern = "";
|
|
430
|
+
const requestId = Date.now().toString(36);
|
|
431
|
+
|
|
432
|
+
// Wrap res.raw.end to inject dev toolbar and capture requests
|
|
433
|
+
if (isDevMode() && !pathname.startsWith("/__dev")) {
|
|
434
|
+
const originalEnd = res.raw.end.bind(res.raw);
|
|
435
|
+
|
|
436
|
+
const wrappedEnd: typeof res.raw.end = function (
|
|
437
|
+
chunk?: unknown,
|
|
438
|
+
encodingOrCb?: BufferEncoding | (() => void),
|
|
439
|
+
cb?: () => void,
|
|
440
|
+
) {
|
|
441
|
+
// Capture request for dev inspector
|
|
442
|
+
if (reqStartTime > 0) {
|
|
443
|
+
const duration = Date.now() - reqStartTime;
|
|
444
|
+
const status = res.raw.statusCode ?? 200;
|
|
445
|
+
RequestInspector.capture(req.method ?? "GET", pathname, status, duration);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const contentType = res.raw.getHeader("content-type");
|
|
449
|
+
if (typeof contentType === "string" && contentType.includes("text/html")) {
|
|
450
|
+
const toolbarCtx: DevToolbarContext = {
|
|
451
|
+
version: TINA4_VERSION,
|
|
452
|
+
method: req.method ?? "GET",
|
|
453
|
+
path: pathname,
|
|
454
|
+
matchedPattern: matchedPattern || pathname,
|
|
455
|
+
requestId,
|
|
456
|
+
routeCount: router.getRoutes().length,
|
|
457
|
+
};
|
|
458
|
+
if (typeof chunk === "string") {
|
|
459
|
+
chunk = injectDevToolbar(chunk, toolbarCtx);
|
|
460
|
+
} else if (Buffer.isBuffer(chunk)) {
|
|
461
|
+
const html = chunk.toString("utf-8");
|
|
462
|
+
chunk = injectDevToolbar(html, toolbarCtx);
|
|
463
|
+
}
|
|
464
|
+
// Remove content-length since toolbar injection changes body size
|
|
465
|
+
if (!res.raw.headersSent) {
|
|
466
|
+
res.raw.removeHeader("content-length");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (typeof encodingOrCb === "function") {
|
|
470
|
+
return originalEnd(chunk, encodingOrCb);
|
|
471
|
+
}
|
|
472
|
+
if (encodingOrCb !== undefined) {
|
|
473
|
+
return originalEnd(chunk, encodingOrCb, cb);
|
|
474
|
+
}
|
|
475
|
+
return originalEnd(chunk, cb);
|
|
476
|
+
};
|
|
477
|
+
res.raw.end = wrappedEnd;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Try static files first (project public dir, then framework built-in public dir)
|
|
481
|
+
if (existsSync(staticDir) && tryServeStatic(staticDir, req, res)) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (tryServeStatic(BUILTIN_PUBLIC_DIR, req, res)) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Match route
|
|
489
|
+
const match = router.match(req.method ?? "GET", pathname);
|
|
490
|
+
if (match) {
|
|
491
|
+
req.params = match.params;
|
|
492
|
+
matchedPattern = match.pattern;
|
|
493
|
+
|
|
494
|
+
// Run per-route middlewares if any
|
|
495
|
+
if (match.middlewares && match.middlewares.length > 0) {
|
|
496
|
+
const proceed = await runRouteMiddlewares(match.middlewares, req, res);
|
|
497
|
+
if (!proceed || res.raw.writableEnded) return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const result = await match.handler(req, res);
|
|
501
|
+
|
|
502
|
+
// If the route exports a template and the handler returned a plain object,
|
|
503
|
+
// render it through the template engine instead of sending as JSON.
|
|
504
|
+
if (
|
|
505
|
+
!res.raw.writableEnded &&
|
|
506
|
+
match.template &&
|
|
507
|
+
result !== null &&
|
|
508
|
+
result !== undefined &&
|
|
509
|
+
typeof result === "object" &&
|
|
510
|
+
!Buffer.isBuffer(result)
|
|
511
|
+
) {
|
|
512
|
+
await res.template(match.template, result as Record<string, unknown>);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (!res.raw.writableEnded) {
|
|
516
|
+
res.raw.end();
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Show landing page on "/" if no route matched and no index template exists
|
|
522
|
+
if (pathname === "/" && (req.method ?? "GET") === "GET") {
|
|
523
|
+
const hasIndexHtml = existsSync(resolve(templatesDir, "index.html"));
|
|
524
|
+
const hasIndexTwig = existsSync(resolve(templatesDir, "index.twig"));
|
|
525
|
+
if (!hasIndexHtml && !hasIndexTwig) {
|
|
526
|
+
const allRoutes = router.getRoutes().map((r) => ({
|
|
527
|
+
method: r.method,
|
|
528
|
+
pattern: r.pattern,
|
|
529
|
+
flags: [] as string[],
|
|
530
|
+
}));
|
|
531
|
+
const html = renderLandingPage(allRoutes, port);
|
|
532
|
+
res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
533
|
+
res.raw.end(html);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 404
|
|
539
|
+
const html404 = await renderErrorPage(404, { path: pathname }, templatesDir);
|
|
540
|
+
if (html404) {
|
|
541
|
+
res.raw.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
542
|
+
res.raw.end(html404);
|
|
543
|
+
} else {
|
|
544
|
+
res({ error: "Not Found", statusCode: 404, message: `No route found for ${req.method} ${pathname}` }, 404);
|
|
545
|
+
}
|
|
546
|
+
} catch (err) {
|
|
547
|
+
console.error(" Error:", err);
|
|
548
|
+
if (!res.raw.writableEnded) {
|
|
549
|
+
if (isDevMode() && err instanceof Error) {
|
|
550
|
+
// Rich error overlay with stack trace, source context, and line numbers
|
|
551
|
+
const { renderErrorOverlay } = await import("./errorOverlay.js");
|
|
552
|
+
const overlayHtml = renderErrorOverlay(err, req);
|
|
553
|
+
res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
554
|
+
res.raw.end(overlayHtml);
|
|
555
|
+
} else {
|
|
556
|
+
const errorMessage = !isTruthy(process.env.TINA4_DEBUG) ? "Internal Server Error" : String(err);
|
|
557
|
+
const html500 = await renderErrorPage(500, {
|
|
558
|
+
error_message: errorMessage,
|
|
559
|
+
request_id: `${Date.now().toString(36)}`,
|
|
560
|
+
path: (req.url ?? "/").split("?")[0],
|
|
561
|
+
}, templatesDir);
|
|
562
|
+
if (html500) {
|
|
563
|
+
res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
564
|
+
res.raw.end(html500);
|
|
565
|
+
} else {
|
|
566
|
+
res({ error: "Internal Server Error", statusCode: 500, message: errorMessage }, 500);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
return new Promise((resolvePromise) => {
|
|
574
|
+
server.listen(port, host, () => {
|
|
575
|
+
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
576
|
+
const isDebug = isTruthy(process.env.TINA4_DEBUG);
|
|
577
|
+
const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
|
|
578
|
+
|
|
579
|
+
// Green color for Node.js, only when stdout is a TTY
|
|
580
|
+
const isTty = isatty(1);
|
|
581
|
+
const color = isTty ? "\x1b[32m" : "";
|
|
582
|
+
const reset = isTty ? "\x1b[0m" : "";
|
|
583
|
+
|
|
584
|
+
// Banner goes to stdout via console.log — NOT through the framework logger
|
|
585
|
+
console.log(`${color}
|
|
586
|
+
______ _ __ __
|
|
587
|
+
/_ __/(_)___ ____ _/ // /
|
|
588
|
+
/ / / / __ \\/ __ \`/ // /_
|
|
589
|
+
/ / / / / / / /_/ /__ __/
|
|
590
|
+
/_/ /_/_/ /_/\\__,_/ /_/
|
|
591
|
+
${reset}
|
|
592
|
+
Tina4 Node.js v${TINA4_VERSION} — This is not a framework
|
|
593
|
+
|
|
594
|
+
Server: http://${displayHost}:${port}
|
|
595
|
+
Swagger: http://localhost:${port}/swagger
|
|
596
|
+
Dashboard: http://localhost:${port}/__dev
|
|
597
|
+
Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})
|
|
598
|
+
`);
|
|
599
|
+
resolvePromise({
|
|
600
|
+
close: () => {
|
|
601
|
+
server.close();
|
|
602
|
+
// Close database if ORM was initialized
|
|
603
|
+
import("@tina4/orm").then((orm) => orm.closeDatabase()).catch(() => {});
|
|
604
|
+
},
|
|
605
|
+
router,
|
|
606
|
+
port,
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|