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,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Auth — Zero-dependency JWT, password hashing, and auth middleware.
|
|
3
|
+
*
|
|
4
|
+
* Uses only Node.js built-in `crypto` module. No external dependencies.
|
|
5
|
+
*
|
|
6
|
+
* import { createToken, validateToken, hashPassword, checkPassword } from "./auth.js";
|
|
7
|
+
*
|
|
8
|
+
* const token = createToken({ userId: 1 }, "my-secret");
|
|
9
|
+
* const payload = validateToken(token, "my-secret");
|
|
10
|
+
*
|
|
11
|
+
* const hash = hashPassword("secret123");
|
|
12
|
+
* checkPassword("secret123", hash); // true
|
|
13
|
+
*/
|
|
14
|
+
import { createHmac, createSign, createVerify, pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import type { Middleware, Tina4Request, Tina4Response } from "./types.js";
|
|
16
|
+
|
|
17
|
+
// ── Base64url helpers (RFC 7515) ──────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function base64urlEncode(data: Buffer): string {
|
|
20
|
+
return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function base64urlDecode(str: string): Buffer {
|
|
24
|
+
let s = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
25
|
+
const pad = 4 - (s.length % 4);
|
|
26
|
+
if (pad !== 4) s += "=".repeat(pad);
|
|
27
|
+
return Buffer.from(s, "base64");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── JWT ───────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a signed JWT token.
|
|
34
|
+
*
|
|
35
|
+
* @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
|
|
36
|
+
* @param secret - HMAC secret (HS256) or PEM private key (RS256)
|
|
37
|
+
* @param expiresIn - Lifetime in seconds (default 3600)
|
|
38
|
+
* @param algorithm - "HS256" or "RS256" (default "HS256")
|
|
39
|
+
* @returns Signed JWT string: header.payload.signature
|
|
40
|
+
*/
|
|
41
|
+
export function createToken(
|
|
42
|
+
payload: Record<string, unknown>,
|
|
43
|
+
secret: string,
|
|
44
|
+
expiresIn: number = 3600,
|
|
45
|
+
algorithm: string = "HS256",
|
|
46
|
+
): string {
|
|
47
|
+
const header = { alg: algorithm, typ: "JWT" };
|
|
48
|
+
const now = Math.floor(Date.now() / 1000);
|
|
49
|
+
|
|
50
|
+
const claims: Record<string, unknown> = { ...payload, iat: now };
|
|
51
|
+
if (expiresIn !== 0) {
|
|
52
|
+
claims.exp = now + expiresIn;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
|
|
56
|
+
const p = base64urlEncode(Buffer.from(JSON.stringify(claims)));
|
|
57
|
+
const signingInput = `${h}.${p}`;
|
|
58
|
+
const signature = sign(signingInput, secret, algorithm);
|
|
59
|
+
|
|
60
|
+
return `${h}.${p}.${signature}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a JWT token and return the decoded payload, or null if invalid/expired.
|
|
65
|
+
*/
|
|
66
|
+
export function validateToken(
|
|
67
|
+
token: string,
|
|
68
|
+
secret: string,
|
|
69
|
+
algorithm: string = "HS256",
|
|
70
|
+
): Record<string, unknown> | null {
|
|
71
|
+
try {
|
|
72
|
+
const parts = token.split(".");
|
|
73
|
+
if (parts.length !== 3) return null;
|
|
74
|
+
|
|
75
|
+
const [h, p, sig] = parts;
|
|
76
|
+
const signingInput = `${h}.${p}`;
|
|
77
|
+
|
|
78
|
+
if (!verifySignature(signingInput, sig, secret, algorithm)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const payload = JSON.parse(base64urlDecode(p).toString()) as Record<string, unknown>;
|
|
83
|
+
|
|
84
|
+
if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return payload;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the JWT payload WITHOUT verifying signature or expiration.
|
|
96
|
+
*/
|
|
97
|
+
export function getPayload(token: string): Record<string, unknown> | null {
|
|
98
|
+
try {
|
|
99
|
+
const parts = token.split(".");
|
|
100
|
+
if (parts.length !== 3) return null;
|
|
101
|
+
return JSON.parse(base64urlDecode(parts[1]).toString()) as Record<string, unknown>;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Signing helpers ───────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function sign(input: string, secret: string, algorithm: string): string {
|
|
110
|
+
if (algorithm === "HS256") {
|
|
111
|
+
const sig = createHmac("sha256", secret).update(input).digest();
|
|
112
|
+
return base64urlEncode(sig);
|
|
113
|
+
}
|
|
114
|
+
if (algorithm === "RS256") {
|
|
115
|
+
const signer = createSign("RSA-SHA256");
|
|
116
|
+
signer.update(input);
|
|
117
|
+
const sig = signer.sign(secret);
|
|
118
|
+
return base64urlEncode(sig);
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function verifySignature(input: string, sig: string, secret: string, algorithm: string): boolean {
|
|
124
|
+
if (algorithm === "HS256") {
|
|
125
|
+
const expected = sign(input, secret, algorithm);
|
|
126
|
+
// Constant-time comparison
|
|
127
|
+
const a = Buffer.from(sig);
|
|
128
|
+
const b = Buffer.from(expected);
|
|
129
|
+
if (a.length !== b.length) return false;
|
|
130
|
+
return timingSafeEqual(a, b);
|
|
131
|
+
}
|
|
132
|
+
if (algorithm === "RS256") {
|
|
133
|
+
const verifier = createVerify("RSA-SHA256");
|
|
134
|
+
verifier.update(input);
|
|
135
|
+
return verifier.verify(secret, base64urlDecode(sig));
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Password Hashing (PBKDF2) ────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Hash a password using PBKDF2-SHA256.
|
|
144
|
+
*
|
|
145
|
+
* @param password - Plaintext password
|
|
146
|
+
* @param salt - Hex-encoded salt (auto-generated if omitted)
|
|
147
|
+
* @param iterations - PBKDF2 iterations (default 100000)
|
|
148
|
+
* @returns Format: `pbkdf2_sha256:iterations:salt:hash` (all hex-encoded)
|
|
149
|
+
*/
|
|
150
|
+
export function hashPassword(
|
|
151
|
+
password: string,
|
|
152
|
+
salt?: string,
|
|
153
|
+
iterations: number = 100000,
|
|
154
|
+
): string {
|
|
155
|
+
const actualSalt = salt ?? randomBytes(16).toString("hex");
|
|
156
|
+
const dk = pbkdf2Sync(password, actualSalt, iterations, 32, "sha256");
|
|
157
|
+
return `pbkdf2_sha256:${iterations}:${actualSalt}:${dk.toString("hex")}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check a password against a PBKDF2 hash string.
|
|
162
|
+
*/
|
|
163
|
+
export function checkPassword(password: string, hash: string): boolean {
|
|
164
|
+
try {
|
|
165
|
+
const parts = hash.split(":");
|
|
166
|
+
if (parts.length !== 4 || parts[0] !== "pbkdf2_sha256") return false;
|
|
167
|
+
|
|
168
|
+
const iterations = parseInt(parts[1], 10);
|
|
169
|
+
const salt = parts[2];
|
|
170
|
+
const expected = parts[3];
|
|
171
|
+
|
|
172
|
+
const dk = pbkdf2Sync(password, salt, iterations, 32, "sha256");
|
|
173
|
+
const actual = dk.toString("hex");
|
|
174
|
+
|
|
175
|
+
// Constant-time comparison
|
|
176
|
+
const a = Buffer.from(actual);
|
|
177
|
+
const b = Buffer.from(expected);
|
|
178
|
+
if (a.length !== b.length) return false;
|
|
179
|
+
return timingSafeEqual(a, b);
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Auth Middleware ───────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Auth middleware that extracts and verifies a Bearer JWT from the
|
|
189
|
+
* Authorization header. On success, attaches the decoded payload to
|
|
190
|
+
* `(request as any).auth`. On failure, sends a 401 JSON response.
|
|
191
|
+
*/
|
|
192
|
+
export function authMiddleware(secret: string, algorithm: string = "HS256"): Middleware {
|
|
193
|
+
return (req: Tina4Request, res: Tina4Response, next: () => void): void => {
|
|
194
|
+
const authHeader = req.headers.authorization ?? "";
|
|
195
|
+
|
|
196
|
+
if (!authHeader.startsWith("Bearer ")) {
|
|
197
|
+
res({ error: "Unauthorized" }, 401);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const token = authHeader.slice(7);
|
|
202
|
+
const payload = validateToken(token, secret, algorithm);
|
|
203
|
+
|
|
204
|
+
if (payload === null) {
|
|
205
|
+
res({ error: "Unauthorized" }, 401);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
(req as any).auth = payload;
|
|
210
|
+
next();
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Token Refresh ────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Refresh a JWT token — validate the existing token then re-sign
|
|
218
|
+
* with a fresh expiry.
|
|
219
|
+
*
|
|
220
|
+
* @param token - Existing JWT to refresh
|
|
221
|
+
* @param secret - HMAC secret or PEM key
|
|
222
|
+
* @param expiresIn - New lifetime in seconds (default 3600)
|
|
223
|
+
* @param algorithm - "HS256" or "RS256" (default "HS256")
|
|
224
|
+
* @returns New signed JWT string, or null if the input token is invalid/expired
|
|
225
|
+
*/
|
|
226
|
+
export function refreshToken(
|
|
227
|
+
token: string,
|
|
228
|
+
secret: string,
|
|
229
|
+
expiresIn: number = 3600,
|
|
230
|
+
algorithm: string = "HS256",
|
|
231
|
+
): string | null {
|
|
232
|
+
const payload = validateToken(token, secret, algorithm);
|
|
233
|
+
if (payload === null) return null;
|
|
234
|
+
|
|
235
|
+
// Strip standard timing claims so createToken sets fresh ones
|
|
236
|
+
const { iat: _iat, exp: _exp, ...claims } = payload;
|
|
237
|
+
return createToken(claims, secret, expiresIn, algorithm);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Request Authentication ───────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Extract a Bearer token from request headers and validate it.
|
|
244
|
+
*
|
|
245
|
+
* @param headers - Object with header keys (e.g. `{ authorization: "Bearer ..." }`)
|
|
246
|
+
* @param secret - HMAC secret or PEM public key
|
|
247
|
+
* @param algorithm - "HS256" or "RS256" (default "HS256")
|
|
248
|
+
* @returns Decoded payload, or null if missing/invalid
|
|
249
|
+
*/
|
|
250
|
+
export function authenticateRequest(
|
|
251
|
+
headers: Record<string, string | string[] | undefined>,
|
|
252
|
+
secret: string,
|
|
253
|
+
algorithm: string = "HS256",
|
|
254
|
+
): Record<string, unknown> | null {
|
|
255
|
+
const authHeader =
|
|
256
|
+
(headers.authorization ?? headers.Authorization ?? "") as string;
|
|
257
|
+
|
|
258
|
+
if (!authHeader.startsWith("Bearer ")) return null;
|
|
259
|
+
|
|
260
|
+
const token = authHeader.slice(7);
|
|
261
|
+
return validateToken(token, secret, algorithm);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── API Key Validation ───────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Compare an API key against an expected value.
|
|
268
|
+
* If `expected` is omitted, falls back to the `TINA4_API_KEY` env var.
|
|
269
|
+
*
|
|
270
|
+
* Uses constant-time comparison to prevent timing attacks.
|
|
271
|
+
*
|
|
272
|
+
* @param provided - The API key provided by the caller
|
|
273
|
+
* @param expected - The correct API key (defaults to `process.env.TINA4_API_KEY`)
|
|
274
|
+
* @returns true if the keys match
|
|
275
|
+
*/
|
|
276
|
+
export function validateApiKey(
|
|
277
|
+
provided: string,
|
|
278
|
+
expected?: string,
|
|
279
|
+
): boolean {
|
|
280
|
+
const key = expected ?? process.env.TINA4_API_KEY;
|
|
281
|
+
if (!key || !provided) return false;
|
|
282
|
+
|
|
283
|
+
const a = Buffer.from(provided);
|
|
284
|
+
const b = Buffer.from(key);
|
|
285
|
+
if (a.length !== b.length) return false;
|
|
286
|
+
return timingSafeEqual(a, b);
|
|
287
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory response cache for GET requests.
|
|
3
|
+
* Caches serialized responses by URL + query string.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { responseCache } from "./cache.js";
|
|
7
|
+
*
|
|
8
|
+
* // As middleware — caches GET responses for ttl seconds
|
|
9
|
+
* middleware.use(responseCache({ ttl: 60 }));
|
|
10
|
+
*
|
|
11
|
+
* // Per-route cache via meta
|
|
12
|
+
* export const meta = { cache: 30 }; // cache 30 seconds
|
|
13
|
+
*
|
|
14
|
+
* Environment:
|
|
15
|
+
* TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Middleware } from "./types.js";
|
|
19
|
+
|
|
20
|
+
interface CacheEntry {
|
|
21
|
+
body: string;
|
|
22
|
+
contentType: string;
|
|
23
|
+
statusCode: number;
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResponseCacheConfig {
|
|
28
|
+
/** Default TTL in seconds. 0 = disabled. Default: 60 */
|
|
29
|
+
ttl?: number;
|
|
30
|
+
/** Maximum cache entries. Default: 1000 */
|
|
31
|
+
maxEntries?: number;
|
|
32
|
+
/** Only cache these status codes. Default: [200] */
|
|
33
|
+
statusCodes?: number[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const store = new Map<string, CacheEntry>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Response cache middleware for GET requests.
|
|
40
|
+
* Caches the full response body, content-type, and status code.
|
|
41
|
+
* Cache key is method + url (including query string).
|
|
42
|
+
*/
|
|
43
|
+
export function responseCache(config?: ResponseCacheConfig): Middleware {
|
|
44
|
+
const ttl = config?.ttl
|
|
45
|
+
?? (process.env.TINA4_CACHE_TTL ? parseInt(process.env.TINA4_CACHE_TTL, 10) : 60);
|
|
46
|
+
const maxEntries = config?.maxEntries ?? 1000;
|
|
47
|
+
const allowedCodes = new Set(config?.statusCodes ?? [200]);
|
|
48
|
+
|
|
49
|
+
if (ttl <= 0) {
|
|
50
|
+
// Cache disabled — pass through
|
|
51
|
+
return (_req, _res, next) => next();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Periodic cleanup
|
|
55
|
+
const cleanupTimer = setInterval(() => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const [key, entry] of store) {
|
|
58
|
+
if (now > entry.expiresAt) store.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}, 30_000);
|
|
61
|
+
if (cleanupTimer.unref) cleanupTimer.unref();
|
|
62
|
+
|
|
63
|
+
return (req, res, next) => {
|
|
64
|
+
// Only cache GET requests
|
|
65
|
+
if (req.method !== "GET") {
|
|
66
|
+
next();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cacheKey = `GET:${req.url}`;
|
|
71
|
+
const cached = store.get(cacheKey);
|
|
72
|
+
|
|
73
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
74
|
+
// Cache HIT
|
|
75
|
+
res.header("X-Cache", "HIT");
|
|
76
|
+
res.header("Content-Type", cached.contentType);
|
|
77
|
+
res(cached.body, cached.statusCode, cached.contentType);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Cache MISS — intercept the response to capture it
|
|
82
|
+
const originalEnd = res.raw.end.bind(res.raw);
|
|
83
|
+
let captured = false;
|
|
84
|
+
|
|
85
|
+
res.raw.end = function (chunk?: any, ...args: any[]) {
|
|
86
|
+
if (!captured && allowedCodes.has(res.raw.statusCode)) {
|
|
87
|
+
captured = true;
|
|
88
|
+
const body = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
89
|
+
const contentType = String(res.raw.getHeader("Content-Type") ?? "application/octet-stream");
|
|
90
|
+
|
|
91
|
+
// Evict oldest if at capacity
|
|
92
|
+
if (store.size >= maxEntries) {
|
|
93
|
+
const firstKey = store.keys().next().value;
|
|
94
|
+
if (firstKey) store.delete(firstKey);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
store.set(cacheKey, {
|
|
98
|
+
body,
|
|
99
|
+
contentType,
|
|
100
|
+
statusCode: res.raw.statusCode,
|
|
101
|
+
expiresAt: Date.now() + ttl * 1000,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
res.header("X-Cache", "MISS");
|
|
106
|
+
return originalEnd(chunk, ...args);
|
|
107
|
+
} as any;
|
|
108
|
+
|
|
109
|
+
next();
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Clear all cached responses */
|
|
114
|
+
export function clearCache(): void {
|
|
115
|
+
store.clear();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get cache stats */
|
|
119
|
+
export function cacheStats(): { size: number; keys: string[] } {
|
|
120
|
+
return { size: store.size, keys: [...store.keys()] };
|
|
121
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Constants — HTTP status codes and content types.
|
|
3
|
+
*
|
|
4
|
+
* Standard constants for use in route handlers across all Tina4 frameworks.
|
|
5
|
+
*
|
|
6
|
+
* import { HTTP_OK, HTTP_CREATED, APPLICATION_JSON } from "@tina4/core";
|
|
7
|
+
*
|
|
8
|
+
* get("/api/users", async (request, response) => {
|
|
9
|
+
* return response(users, HTTP_OK);
|
|
10
|
+
* });
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── HTTP Status Codes ──
|
|
14
|
+
|
|
15
|
+
export const HTTP_OK = 200;
|
|
16
|
+
export const HTTP_CREATED = 201;
|
|
17
|
+
export const HTTP_ACCEPTED = 202;
|
|
18
|
+
export const HTTP_NO_CONTENT = 204;
|
|
19
|
+
|
|
20
|
+
export const HTTP_MOVED = 301;
|
|
21
|
+
export const HTTP_REDIRECT = 302;
|
|
22
|
+
export const HTTP_NOT_MODIFIED = 304;
|
|
23
|
+
|
|
24
|
+
export const HTTP_BAD_REQUEST = 400;
|
|
25
|
+
export const HTTP_UNAUTHORIZED = 401;
|
|
26
|
+
export const HTTP_FORBIDDEN = 403;
|
|
27
|
+
export const HTTP_NOT_FOUND = 404;
|
|
28
|
+
export const HTTP_METHOD_NOT_ALLOWED = 405;
|
|
29
|
+
export const HTTP_CONFLICT = 409;
|
|
30
|
+
export const HTTP_GONE = 410;
|
|
31
|
+
export const HTTP_UNPROCESSABLE = 422;
|
|
32
|
+
export const HTTP_TOO_MANY = 429;
|
|
33
|
+
|
|
34
|
+
export const HTTP_SERVER_ERROR = 500;
|
|
35
|
+
export const HTTP_BAD_GATEWAY = 502;
|
|
36
|
+
export const HTTP_UNAVAILABLE = 503;
|
|
37
|
+
|
|
38
|
+
// ── Content Types ──
|
|
39
|
+
|
|
40
|
+
export const APPLICATION_JSON = "application/json";
|
|
41
|
+
export const APPLICATION_XML = "application/xml";
|
|
42
|
+
export const APPLICATION_FORM = "application/x-www-form-urlencoded";
|
|
43
|
+
export const APPLICATION_OCTET = "application/octet-stream";
|
|
44
|
+
|
|
45
|
+
export const TEXT_HTML = "text/html; charset=utf-8";
|
|
46
|
+
export const TEXT_PLAIN = "text/plain; charset=utf-8";
|
|
47
|
+
export const TEXT_CSV = "text/csv";
|
|
48
|
+
export const TEXT_XML = "text/xml";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight dependency injection container.
|
|
3
|
+
*
|
|
4
|
+
* Matches the Python tina4_python.container.Container API.
|
|
5
|
+
*
|
|
6
|
+
* import { Container, container } from "@tina4/core";
|
|
7
|
+
*
|
|
8
|
+
* container.register("mailer", () => new MailService());
|
|
9
|
+
* container.singleton("db", () => new Database("sqlite:///app.db"));
|
|
10
|
+
*
|
|
11
|
+
* const mailer = container.get<MailService>("mailer"); // new instance each call
|
|
12
|
+
* const db = container.get<Database>("db"); // same instance every call
|
|
13
|
+
*
|
|
14
|
+
* Node.js is single-threaded so no locking is needed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class Container {
|
|
18
|
+
private transients = new Map<string, () => unknown>();
|
|
19
|
+
private singletons = new Map<string, () => unknown>();
|
|
20
|
+
private instances = new Map<string, unknown>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register a transient factory — a new instance is created on every `get()` call.
|
|
24
|
+
*/
|
|
25
|
+
register(name: string, factory: () => unknown): void {
|
|
26
|
+
if (typeof factory !== "function") {
|
|
27
|
+
throw new TypeError(`factory for '${name}' must be callable`);
|
|
28
|
+
}
|
|
29
|
+
// Remove from singletons/instances if previously registered there
|
|
30
|
+
this.singletons.delete(name);
|
|
31
|
+
this.instances.delete(name);
|
|
32
|
+
this.transients.set(name, factory);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register a singleton factory — created once on first `get()`, then memoised.
|
|
37
|
+
*/
|
|
38
|
+
singleton(name: string, factory: () => unknown): void {
|
|
39
|
+
if (typeof factory !== "function") {
|
|
40
|
+
throw new TypeError(`factory for '${name}' must be callable`);
|
|
41
|
+
}
|
|
42
|
+
// Remove from transients if previously registered there
|
|
43
|
+
this.transients.delete(name);
|
|
44
|
+
this.instances.delete(name);
|
|
45
|
+
this.singletons.set(name, factory);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a dependency by name.
|
|
50
|
+
*
|
|
51
|
+
* Throws an `Error` if the name has not been registered.
|
|
52
|
+
*/
|
|
53
|
+
get<T = unknown>(name: string): T {
|
|
54
|
+
// Check transient first
|
|
55
|
+
const transientFactory = this.transients.get(name);
|
|
56
|
+
if (transientFactory) {
|
|
57
|
+
return transientFactory() as T;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check singleton
|
|
61
|
+
const singletonFactory = this.singletons.get(name);
|
|
62
|
+
if (singletonFactory) {
|
|
63
|
+
if (!this.instances.has(name)) {
|
|
64
|
+
this.instances.set(name, singletonFactory());
|
|
65
|
+
}
|
|
66
|
+
return this.instances.get(name) as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new Error(`service not registered: ${name}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return `true` if *name* has been registered (transient or singleton).
|
|
74
|
+
*/
|
|
75
|
+
has(name: string): boolean {
|
|
76
|
+
return this.transients.has(name) || this.singletons.has(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Clear all registrations and cached instances.
|
|
81
|
+
*/
|
|
82
|
+
reset(): void {
|
|
83
|
+
this.transients.clear();
|
|
84
|
+
this.singletons.clear();
|
|
85
|
+
this.instances.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Default container instance. */
|
|
90
|
+
export const container = new Container();
|