tina4-nodejs 3.10.91 → 3.10.92
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/package.json +1 -1
- package/packages/core/src/ai.ts +1 -1
- package/packages/core/src/devAdmin.ts +85 -7
- package/packages/core/src/devMailbox.ts +21 -21
- package/packages/core/src/graphql.ts +36 -0
- package/packages/core/src/index.ts +6 -6
- package/packages/core/src/messenger.ts +52 -4
- package/packages/core/src/middleware.ts +37 -0
- package/packages/core/src/rateLimiter.ts +88 -1
- package/packages/core/src/request.ts +24 -1
- package/packages/core/src/response.ts +54 -10
- package/packages/core/src/scss.ts +44 -2
- package/packages/core/src/server.ts +26 -4
- package/packages/core/src/service.ts +7 -0
- package/packages/core/src/testClient.ts +2 -2
- package/packages/core/src/testing.ts +6 -6
- package/packages/core/src/types.ts +8 -1
- package/packages/core/src/watcher.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketConnection.ts +4 -0
- package/packages/core/src/wsdl.ts +12 -12
- package/packages/orm/src/autoCrud.ts +117 -74
- package/packages/orm/src/baseModel.ts +1 -1
- package/packages/orm/src/databaseResult.ts +5 -0
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/migration.ts +5 -2
- package/packages/orm/src/queryBuilder.ts +2 -2
- package/packages/swagger/src/generator.ts +2 -2
- package/packages/swagger/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.92",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
package/packages/core/src/ai.ts
CHANGED
|
@@ -608,7 +608,7 @@ npx tina4nodejs test # Run all tests
|
|
|
608
608
|
|
|
609
609
|
Add test files in \`test/\` directory. Use built-in inline testing:
|
|
610
610
|
\`\`\`typescript
|
|
611
|
-
import { tests, assertEqual,
|
|
611
|
+
import { tests, assertEqual, runAll } from "@tina4/core";
|
|
612
612
|
\`\`\`
|
|
613
613
|
|
|
614
614
|
## Important
|
|
@@ -191,15 +191,40 @@ export class RequestInspector {
|
|
|
191
191
|
|
|
192
192
|
export class ErrorTracker {
|
|
193
193
|
private static errors: ErrorEntry[] = [];
|
|
194
|
+
private static maxErrors = 200;
|
|
195
|
+
private static registered = false;
|
|
194
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Capture an error with dedup (matches PHP/Ruby/Python signature).
|
|
199
|
+
* Duplicate errors (same message) increment count and update last_seen.
|
|
200
|
+
*/
|
|
201
|
+
static capture(errorType: string, message: string, traceback = "", file = "", line = 0): void {
|
|
202
|
+
const fingerprint = `${errorType}|${message}|${file}|${line}`;
|
|
203
|
+
const existing = this.errors.find((e) => (e as any).fingerprint === fingerprint);
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
|
|
206
|
+
if (existing) {
|
|
207
|
+
(existing as any).count = ((existing as any).count || 1) + 1;
|
|
208
|
+
(existing as any).last_seen = now;
|
|
209
|
+
existing.resolved = false; // re-open resolved duplicates
|
|
210
|
+
} else {
|
|
211
|
+
this.errors.push({
|
|
212
|
+
id: `err_${Date.now()}_${this.errors.length}`,
|
|
213
|
+
timestamp: now,
|
|
214
|
+
message,
|
|
215
|
+
stack: traceback || undefined,
|
|
216
|
+
resolved: false,
|
|
217
|
+
...({ fingerprint, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now } as any),
|
|
218
|
+
});
|
|
219
|
+
if (this.errors.length > this.maxErrors) {
|
|
220
|
+
this.errors = this.errors.slice(-this.maxErrors);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Legacy alias for capture (backward compatibility). */
|
|
195
226
|
static track(message: string, stack?: string): void {
|
|
196
|
-
this.
|
|
197
|
-
id: `err_${Date.now()}_${this.errors.length}`,
|
|
198
|
-
timestamp: new Date().toISOString(),
|
|
199
|
-
message,
|
|
200
|
-
stack,
|
|
201
|
-
resolved: false,
|
|
202
|
-
});
|
|
227
|
+
this.capture("Error", message, stack || "");
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
static get(): ErrorEntry[] {
|
|
@@ -218,6 +243,56 @@ export class ErrorTracker {
|
|
|
218
243
|
static clearResolved(): void {
|
|
219
244
|
this.errors = this.errors.filter((e) => !e.resolved);
|
|
220
245
|
}
|
|
246
|
+
|
|
247
|
+
/** Remove ALL tracked errors. */
|
|
248
|
+
static clearAll(): void {
|
|
249
|
+
this.errors = [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Health summary — are there unresolved errors? */
|
|
253
|
+
static health(): { healthy: boolean; total: number; unresolved: number; resolved: number } {
|
|
254
|
+
const total = this.errors.length;
|
|
255
|
+
const resolved = this.errors.filter((e) => e.resolved).length;
|
|
256
|
+
const unresolved = total - resolved;
|
|
257
|
+
return { healthy: unresolved === 0, total, unresolved, resolved };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Count of unresolved errors. */
|
|
261
|
+
static unresolvedCount(): number {
|
|
262
|
+
return this.errors.filter((e) => !e.resolved).length;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Reset all state (for testing). */
|
|
266
|
+
static reset(): void {
|
|
267
|
+
this.errors = [];
|
|
268
|
+
this.registered = false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Register global error handlers to feed the tracker.
|
|
273
|
+
* Safe to call multiple times — only registers once.
|
|
274
|
+
*/
|
|
275
|
+
static register(): void {
|
|
276
|
+
if (this.registered) return;
|
|
277
|
+
this.registered = true;
|
|
278
|
+
|
|
279
|
+
process.on("uncaughtException", (err: Error) => {
|
|
280
|
+
this.capture(
|
|
281
|
+
err.constructor.name,
|
|
282
|
+
err.message,
|
|
283
|
+
err.stack || "",
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
process.on("unhandledRejection", (reason: unknown) => {
|
|
288
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
289
|
+
this.capture(
|
|
290
|
+
err.constructor.name,
|
|
291
|
+
err.message,
|
|
292
|
+
err.stack || "",
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
221
296
|
}
|
|
222
297
|
|
|
223
298
|
// ---------------------------------------------------------------------------
|
|
@@ -355,6 +430,9 @@ export class DevAdmin {
|
|
|
355
430
|
* Register all /__dev routes on the given router.
|
|
356
431
|
*/
|
|
357
432
|
static register(router: Router): void {
|
|
433
|
+
// Register error handlers to feed the ErrorTracker
|
|
434
|
+
ErrorTracker.register();
|
|
435
|
+
|
|
358
436
|
const routes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
|
|
359
437
|
// Dashboard
|
|
360
438
|
{ method: "GET", pattern: "/__dev", handler: handleDashboard },
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* import { DevMailbox, createMessenger } from "@tina4/core";
|
|
8
8
|
*
|
|
9
9
|
* const mailbox = new DevMailbox();
|
|
10
|
-
* mailbox.capture(
|
|
10
|
+
* mailbox.capture("alice@test.com", "Hello", "Hi!");
|
|
11
11
|
* const messages = mailbox.inbox();
|
|
12
12
|
*/
|
|
13
13
|
import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
|
|
@@ -39,33 +39,33 @@ export class DevMailbox {
|
|
|
39
39
|
/**
|
|
40
40
|
* Capture an email to the dev mailbox instead of sending it.
|
|
41
41
|
*/
|
|
42
|
-
capture(
|
|
43
|
-
to: string | string[]
|
|
44
|
-
subject: string
|
|
45
|
-
body: string
|
|
46
|
-
html
|
|
47
|
-
cc
|
|
48
|
-
bcc
|
|
49
|
-
replyTo?: string
|
|
50
|
-
attachments
|
|
51
|
-
from?: string
|
|
52
|
-
|
|
42
|
+
capture(
|
|
43
|
+
to: string | string[],
|
|
44
|
+
subject: string,
|
|
45
|
+
body: string,
|
|
46
|
+
html: boolean = false,
|
|
47
|
+
cc: string[] = [],
|
|
48
|
+
bcc: string[] = [],
|
|
49
|
+
replyTo?: string,
|
|
50
|
+
attachments: string[] = [],
|
|
51
|
+
from?: string,
|
|
52
|
+
): SendResult {
|
|
53
53
|
const id = randomUUID();
|
|
54
|
-
const toList = Array.isArray(
|
|
54
|
+
const toList = Array.isArray(to) ? to : [to];
|
|
55
55
|
const now = new Date().toISOString();
|
|
56
56
|
|
|
57
57
|
const message: EmailMessage = {
|
|
58
58
|
id,
|
|
59
59
|
type: "outbox",
|
|
60
|
-
from:
|
|
60
|
+
from: from ?? process.env.SMTP_FROM ?? "dev@localhost",
|
|
61
61
|
to: toList,
|
|
62
|
-
cc
|
|
63
|
-
bcc
|
|
64
|
-
reply_to:
|
|
65
|
-
subject
|
|
66
|
-
body
|
|
67
|
-
html
|
|
68
|
-
attachments
|
|
62
|
+
cc,
|
|
63
|
+
bcc,
|
|
64
|
+
reply_to: replyTo,
|
|
65
|
+
subject,
|
|
66
|
+
body,
|
|
67
|
+
html,
|
|
68
|
+
attachments,
|
|
69
69
|
date: now,
|
|
70
70
|
read: false,
|
|
71
71
|
};
|
|
@@ -932,3 +932,39 @@ export class GraphQL {
|
|
|
932
932
|
return resolved;
|
|
933
933
|
}
|
|
934
934
|
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Lightweight GraphQL type wrapper matching Ruby's GraphQLType.
|
|
938
|
+
*/
|
|
939
|
+
export class GraphQLType {
|
|
940
|
+
static readonly SCALARS = ["String", "Int", "Float", "Boolean", "ID"];
|
|
941
|
+
|
|
942
|
+
name: string;
|
|
943
|
+
kind: string;
|
|
944
|
+
ofType: GraphQLType | null;
|
|
945
|
+
|
|
946
|
+
constructor(name: string, kind: string = "object", ofType: GraphQLType | null = null) {
|
|
947
|
+
this.name = name;
|
|
948
|
+
this.kind = kind;
|
|
949
|
+
this.ofType = ofType;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Parse a GraphQL type string like "String", "String!", "[Int!]!".
|
|
954
|
+
*/
|
|
955
|
+
static parse(typeStr: string): GraphQLType {
|
|
956
|
+
const s = String(typeStr).trim();
|
|
957
|
+
if (s.endsWith("!")) {
|
|
958
|
+
const inner = GraphQLType.parse(s.slice(0, -1));
|
|
959
|
+
return new GraphQLType(s, "non_null", inner);
|
|
960
|
+
}
|
|
961
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
962
|
+
const inner = GraphQLType.parse(s.slice(1, -1));
|
|
963
|
+
return new GraphQLType(s, "list", inner);
|
|
964
|
+
}
|
|
965
|
+
if (GraphQLType.SCALARS.includes(s)) {
|
|
966
|
+
return new GraphQLType(s, "scalar");
|
|
967
|
+
}
|
|
968
|
+
return new GraphQLType(s, "object");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
@@ -12,15 +12,15 @@ export type {
|
|
|
12
12
|
WebSocketRouteDefinition,
|
|
13
13
|
} from "./types.js";
|
|
14
14
|
|
|
15
|
-
export { startServer, resolvePortAndHost, handle } from "./server.js";
|
|
15
|
+
export { startServer, resolvePortAndHost, handle, start, stop } from "./server.js";
|
|
16
16
|
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
17
17
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
18
18
|
export type { RouteInfo } from "./router.js";
|
|
19
19
|
export { discoverRoutes } from "./routeDiscovery.js";
|
|
20
20
|
export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware } from "./middleware.js";
|
|
21
21
|
export type { CorsConfig } from "./middleware.js";
|
|
22
|
-
export { createRequest
|
|
23
|
-
export { createResponse, errorResponse, setDefaultTemplatesDir } from "./response.js";
|
|
22
|
+
export { createRequest } from "./request.js";
|
|
23
|
+
export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFrond, getFrameworkFrond } from "./response.js";
|
|
24
24
|
export { tryServeStatic } from "./static.js";
|
|
25
25
|
export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
|
|
26
26
|
export { Log } from "./logger.js";
|
|
@@ -74,8 +74,8 @@ export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore,
|
|
|
74
74
|
export { Messenger } from "./messenger.js";
|
|
75
75
|
export type { SendResult, EmailMessage } from "./messenger.js";
|
|
76
76
|
export { DevMailbox, createMessenger } from "./devMailbox.js";
|
|
77
|
-
export { WSDLService,
|
|
78
|
-
export type {
|
|
77
|
+
export { WSDLService, WSDLOperation } from "./wsdl.js";
|
|
78
|
+
export type { WSDLOperationMeta } from "./wsdl.js";
|
|
79
79
|
export { HtmlElement, htmlElement, addHtmlHelpers } from "./htmlElement.js";
|
|
80
80
|
export { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
|
|
81
81
|
export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
|
|
@@ -96,7 +96,7 @@ export { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
|
|
|
96
96
|
export type { ValkeySessionConfig } from "./sessionHandlers/valkeyHandler.js";
|
|
97
97
|
export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
98
98
|
export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
|
|
99
|
-
export { tests, assertEqual,
|
|
99
|
+
export { tests, assertEqual, assertRaises, assertTrue, assertFalse, runAll, reset } from "./testing.js";
|
|
100
100
|
export { TestClient, TestResponse } from "./testClient.js";
|
|
101
101
|
export { Container, container } from "./container.js";
|
|
102
102
|
export { Validator } from "./validator.js";
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* const mail = new Messenger(); // reads from .env
|
|
23
23
|
* const mail = new Messenger({ host: "smtp.office365.com", port: 587 }); // override
|
|
24
|
-
* await mail.send(
|
|
24
|
+
* await mail.send("user@test.com", "Welcome", "<h1>Hello!</h1>", true, "Hello!");
|
|
25
25
|
*/
|
|
26
26
|
import net from "node:net";
|
|
27
27
|
import tls from "node:tls";
|
|
@@ -355,7 +355,19 @@ export class Messenger {
|
|
|
355
355
|
/**
|
|
356
356
|
* Send an email via SMTP.
|
|
357
357
|
*/
|
|
358
|
-
async send(
|
|
358
|
+
async send(
|
|
359
|
+
to: string | string[],
|
|
360
|
+
subject: string,
|
|
361
|
+
body: string,
|
|
362
|
+
html: boolean = false,
|
|
363
|
+
text?: string,
|
|
364
|
+
cc?: string | string[],
|
|
365
|
+
bcc?: string | string[],
|
|
366
|
+
replyTo?: string,
|
|
367
|
+
attachments?: string[],
|
|
368
|
+
headers?: Record<string, string>,
|
|
369
|
+
): Promise<SendResult> {
|
|
370
|
+
const options: SendOptions = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
|
|
359
371
|
const toList = Array.isArray(options.to) ? options.to : [options.to];
|
|
360
372
|
const ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
|
|
361
373
|
const bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
|
|
@@ -655,7 +667,24 @@ export class Messenger {
|
|
|
655
667
|
/**
|
|
656
668
|
* Search messages using IMAP search criteria.
|
|
657
669
|
*/
|
|
658
|
-
async search(
|
|
670
|
+
async search(
|
|
671
|
+
folder: string = "INBOX",
|
|
672
|
+
subject?: string,
|
|
673
|
+
sender?: string,
|
|
674
|
+
since?: string,
|
|
675
|
+
before?: string,
|
|
676
|
+
unseenOnly: boolean = false,
|
|
677
|
+
limit: number = 50,
|
|
678
|
+
): Promise<ImapMessage[]> {
|
|
679
|
+
// Build IMAP SEARCH criteria from structured params
|
|
680
|
+
const criteria: string[] = ["ALL"];
|
|
681
|
+
if (subject) criteria.push(`SUBJECT "${subject}"`);
|
|
682
|
+
if (sender) criteria.push(`FROM "${sender}"`);
|
|
683
|
+
if (since) criteria.push(`SINCE ${since}`);
|
|
684
|
+
if (before) criteria.push(`BEFORE ${before}`);
|
|
685
|
+
if (unseenOnly) criteria.push("UNSEEN");
|
|
686
|
+
|
|
687
|
+
const query = criteria.join(" ");
|
|
659
688
|
const socket = await this.imapConnect();
|
|
660
689
|
try {
|
|
661
690
|
await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
|
|
@@ -665,7 +694,7 @@ export class Messenger {
|
|
|
665
694
|
|
|
666
695
|
uids.reverse();
|
|
667
696
|
const messages: ImapMessage[] = [];
|
|
668
|
-
for (const uid of uids.slice(0,
|
|
697
|
+
for (const uid of uids.slice(0, limit)) {
|
|
669
698
|
const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
|
|
670
699
|
messages.push(parseHeaderResponse(uid, fetchResp));
|
|
671
700
|
}
|
|
@@ -716,6 +745,25 @@ export class Messenger {
|
|
|
716
745
|
}
|
|
717
746
|
}
|
|
718
747
|
|
|
748
|
+
/**
|
|
749
|
+
* List available IMAP folders/mailboxes.
|
|
750
|
+
*/
|
|
751
|
+
async folders(): Promise<string[]> {
|
|
752
|
+
const socket = await this.imapConnect();
|
|
753
|
+
try {
|
|
754
|
+
const resp = await imapCommand(socket, 'LIST "" "*"');
|
|
755
|
+
const result: string[] = [];
|
|
756
|
+
for (const line of resp.split("\r\n")) {
|
|
757
|
+
// Parse LIST response: * LIST (\flags) "/" "FolderName"
|
|
758
|
+
const m = line.match(/\* LIST \([^)]*\) "[^"]*" "?([^"\r\n]+)"?/i);
|
|
759
|
+
if (m) result.push(m[1]);
|
|
760
|
+
}
|
|
761
|
+
return result;
|
|
762
|
+
} finally {
|
|
763
|
+
await this.imapDisconnect(socket);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
719
767
|
/**
|
|
720
768
|
* Test IMAP connectivity without reading.
|
|
721
769
|
*/
|
|
@@ -268,6 +268,13 @@ export class CorsMiddleware {
|
|
|
268
268
|
|
|
269
269
|
return [req, res];
|
|
270
270
|
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check if a request is an OPTIONS preflight.
|
|
274
|
+
*/
|
|
275
|
+
static isPreflight(method: string): boolean {
|
|
276
|
+
return method?.toUpperCase() === "OPTIONS";
|
|
277
|
+
}
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
/**
|
|
@@ -354,6 +361,36 @@ export class RateLimiterMiddleware {
|
|
|
354
361
|
entry.timestamps.push(now);
|
|
355
362
|
return [req, res];
|
|
356
363
|
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Check if an IP is within rate limits without recording a request.
|
|
367
|
+
* Returns [allowed, info] matching Python/Ruby API.
|
|
368
|
+
*/
|
|
369
|
+
static check(ip: string): [boolean, { limit: number; remaining: number; reset: number; window: number }] {
|
|
370
|
+
const limit = process.env.TINA4_RATE_LIMIT ? parseInt(process.env.TINA4_RATE_LIMIT, 10) : 100;
|
|
371
|
+
const windowSeconds = process.env.TINA4_RATE_WINDOW ? parseInt(process.env.TINA4_RATE_WINDOW, 10) : 60;
|
|
372
|
+
const windowMs = windowSeconds * 1000;
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
const cutoff = now - windowMs;
|
|
375
|
+
|
|
376
|
+
let entry = RateLimiterMiddleware.store.get(ip);
|
|
377
|
+
if (!entry) {
|
|
378
|
+
entry = { timestamps: [] };
|
|
379
|
+
RateLimiterMiddleware.store.set(ip, entry);
|
|
380
|
+
}
|
|
381
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
382
|
+
|
|
383
|
+
const remaining = Math.max(0, limit - entry.timestamps.length);
|
|
384
|
+
const reset = entry.timestamps.length > 0
|
|
385
|
+
? Math.ceil((entry.timestamps[0] + windowMs - now) / 1000)
|
|
386
|
+
: windowSeconds;
|
|
387
|
+
|
|
388
|
+
if (entry.timestamps.length >= limit) {
|
|
389
|
+
return [false, { limit, remaining: 0, reset, window: windowSeconds }];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return [true, { limit, remaining: remaining - 1, reset: windowSeconds, window: windowSeconds }];
|
|
393
|
+
}
|
|
357
394
|
}
|
|
358
395
|
|
|
359
396
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Middleware } from "./types.js";
|
|
1
|
+
import type { Middleware, Tina4Request, Tina4Response } from "./types.js";
|
|
2
2
|
|
|
3
3
|
/** Per-IP sliding window entry */
|
|
4
4
|
interface RateLimitEntry {
|
|
@@ -105,3 +105,90 @@ export function rateLimiter(config?: RateLimiterConfig): Middleware {
|
|
|
105
105
|
next();
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/** Rate limit check result */
|
|
110
|
+
export interface RateLimitResult {
|
|
111
|
+
allowed: boolean;
|
|
112
|
+
limit: number;
|
|
113
|
+
remaining: number;
|
|
114
|
+
reset: number;
|
|
115
|
+
retryAfter?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Class-based rate limiter with check/reset/apply methods.
|
|
120
|
+
* Matches the Python/PHP/Ruby API surface.
|
|
121
|
+
*/
|
|
122
|
+
export class RateLimiter {
|
|
123
|
+
readonly limit: number;
|
|
124
|
+
readonly window: number;
|
|
125
|
+
private store = new Map<string, number[]>();
|
|
126
|
+
|
|
127
|
+
constructor(config?: RateLimiterConfig) {
|
|
128
|
+
this.limit = config?.limit
|
|
129
|
+
?? (process.env.TINA4_RATE_LIMIT ? parseInt(process.env.TINA4_RATE_LIMIT, 10) : 100);
|
|
130
|
+
this.window = config?.windowSeconds
|
|
131
|
+
?? (process.env.TINA4_RATE_WINDOW ? parseInt(process.env.TINA4_RATE_WINDOW, 10) : 60);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Check if a request from the given IP is allowed. */
|
|
135
|
+
check(ip: string): RateLimitResult {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const windowMs = this.window * 1000;
|
|
138
|
+
const cutoff = now - windowMs;
|
|
139
|
+
|
|
140
|
+
let timestamps = this.store.get(ip);
|
|
141
|
+
if (!timestamps) {
|
|
142
|
+
timestamps = [];
|
|
143
|
+
this.store.set(ip, timestamps);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Prune expired
|
|
147
|
+
const filtered = timestamps.filter((t) => t > cutoff);
|
|
148
|
+
this.store.set(ip, filtered);
|
|
149
|
+
|
|
150
|
+
const resetTime = filtered.length > 0
|
|
151
|
+
? Math.ceil((filtered[0] + windowMs) / 1000)
|
|
152
|
+
: Math.ceil((now + windowMs) / 1000);
|
|
153
|
+
|
|
154
|
+
if (filtered.length >= this.limit) {
|
|
155
|
+
const retryAfter = Math.max(1, resetTime - Math.ceil(now / 1000));
|
|
156
|
+
return { allowed: false, limit: this.limit, remaining: 0, reset: resetTime, retryAfter };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
filtered.push(now);
|
|
160
|
+
return {
|
|
161
|
+
allowed: true,
|
|
162
|
+
limit: this.limit,
|
|
163
|
+
remaining: this.limit - filtered.length,
|
|
164
|
+
reset: resetTime,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Clear all tracked request data. */
|
|
169
|
+
reset(): void {
|
|
170
|
+
this.store.clear();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Apply rate limiting to a request/response pair. Sets headers and 429 if exceeded. */
|
|
174
|
+
apply(request: Tina4Request, response: Tina4Response): [Tina4Request, Tina4Response] {
|
|
175
|
+
const ip = (request as Record<string, unknown>).ip as string ?? "unknown";
|
|
176
|
+
const result = this.check(ip);
|
|
177
|
+
|
|
178
|
+
response.header("X-RateLimit-Limit", String(result.limit));
|
|
179
|
+
response.header("X-RateLimit-Remaining", String(result.remaining));
|
|
180
|
+
response.header("X-RateLimit-Reset", String(result.reset));
|
|
181
|
+
|
|
182
|
+
if (!result.allowed) {
|
|
183
|
+
response.header("Retry-After", String(result.retryAfter ?? 1));
|
|
184
|
+
response({ error: "Too Many Requests", retryAfter: result.retryAfter }, 429);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return [request, response];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Middleware hook — enforces rate limiting before the route handler. */
|
|
191
|
+
beforeRateLimit(request: Tina4Request, response: Tina4Response): [Tina4Request, Tina4Response] {
|
|
192
|
+
return this.apply(request, response);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -36,6 +36,29 @@ export function createRequest(req: IncomingMessage): Tina4Request {
|
|
|
36
36
|
tReq.ip = req.socket?.remoteAddress ?? "127.0.0.1";
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Add convenience methods
|
|
40
|
+
tReq.header = function (name: string): string | undefined {
|
|
41
|
+
const val = req.headers[name.toLowerCase()];
|
|
42
|
+
if (Array.isArray(val)) return val[0];
|
|
43
|
+
return val;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
tReq.bearerToken = function (): string | null {
|
|
47
|
+
const auth = tReq.header("authorization") ?? "";
|
|
48
|
+
if (auth.toLowerCase().startsWith("bearer ")) {
|
|
49
|
+
return auth.slice(7);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
tReq.param = function (key: string, defaultValue?: string): string | undefined {
|
|
55
|
+
return tReq.params[key] ?? tReq.query[key] ?? defaultValue;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
tReq.parseBody = function (): Promise<void> {
|
|
59
|
+
return parseBody(tReq);
|
|
60
|
+
};
|
|
61
|
+
|
|
39
62
|
return tReq;
|
|
40
63
|
}
|
|
41
64
|
|
|
@@ -50,7 +73,7 @@ export class PayloadTooLargeError extends Error {
|
|
|
50
73
|
}
|
|
51
74
|
}
|
|
52
75
|
|
|
53
|
-
|
|
76
|
+
async function parseBody(req: Tina4Request): Promise<void> {
|
|
54
77
|
const method = req.method?.toUpperCase();
|
|
55
78
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS") return;
|
|
56
79
|
|
|
@@ -9,14 +9,67 @@ const _frondCache = new Map<string, InstanceType<any>>();
|
|
|
9
9
|
/** Default templates directory — set via setDefaultTemplatesDir(). */
|
|
10
10
|
let _defaultTemplatesDir: string | null = null;
|
|
11
11
|
|
|
12
|
+
/** Global user Frond engine — set via setFrond(). */
|
|
13
|
+
let _globalFrond: InstanceType<any> | null = null;
|
|
14
|
+
|
|
15
|
+
/** Singleton framework Frond engine for built-in templates. */
|
|
16
|
+
let _frameworkFrond: InstanceType<any> | null = null;
|
|
17
|
+
|
|
12
18
|
/**
|
|
13
|
-
* Set the default templates directory for render()
|
|
19
|
+
* Set the default templates directory for render().
|
|
14
20
|
* Called by server.ts during startup.
|
|
15
21
|
*/
|
|
16
22
|
export function setDefaultTemplatesDir(dir: string): void {
|
|
17
23
|
_defaultTemplatesDir = dir;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Return the global Frond engine, creating a default if needed.
|
|
28
|
+
*/
|
|
29
|
+
export async function getFrond(): Promise<InstanceType<any>> {
|
|
30
|
+
if (_globalFrond) return _globalFrond;
|
|
31
|
+
const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
|
|
32
|
+
let engine = _frondCache.get(dir);
|
|
33
|
+
if (!engine) {
|
|
34
|
+
const { Frond } = await import("@tina4/frond");
|
|
35
|
+
engine = new Frond(dir);
|
|
36
|
+
_frondCache.set(dir, engine);
|
|
37
|
+
}
|
|
38
|
+
_globalFrond = engine;
|
|
39
|
+
return engine;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Return the singleton Frond engine for built-in framework templates.
|
|
44
|
+
* Syncs custom filters/globals from the user engine.
|
|
45
|
+
*/
|
|
46
|
+
export async function getFrameworkFrond(): Promise<InstanceType<any> | null> {
|
|
47
|
+
const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
|
|
48
|
+
if (!_frameworkFrond && fs.existsSync(frameworkDir)) {
|
|
49
|
+
try {
|
|
50
|
+
const { Frond } = await import("@tina4/frond");
|
|
51
|
+
_frameworkFrond = new Frond(frameworkDir);
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
// Sync custom filters/globals from the user engine
|
|
55
|
+
if (_frameworkFrond && _globalFrond) {
|
|
56
|
+
if (typeof _globalFrond._filters === "object") {
|
|
57
|
+
Object.assign(_frameworkFrond._filters ??= {}, _globalFrond._filters);
|
|
58
|
+
}
|
|
59
|
+
if (typeof _globalFrond._globals === "object") {
|
|
60
|
+
Object.assign(_frameworkFrond._globals ??= {}, _globalFrond._globals);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return _frameworkFrond;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Register a pre-configured Frond engine for response.render().
|
|
68
|
+
*/
|
|
69
|
+
export function setFrond(engine: InstanceType<any>): void {
|
|
70
|
+
_globalFrond = engine;
|
|
71
|
+
}
|
|
72
|
+
|
|
20
73
|
/**
|
|
21
74
|
* Creates a callable response object.
|
|
22
75
|
*
|
|
@@ -233,15 +286,6 @@ export function createResponse(res: ServerResponse): Tina4Response {
|
|
|
233
286
|
}
|
|
234
287
|
};
|
|
235
288
|
|
|
236
|
-
response.template = async function (
|
|
237
|
-
name: string,
|
|
238
|
-
data?: Record<string, unknown>,
|
|
239
|
-
status?: number,
|
|
240
|
-
templateDir?: string,
|
|
241
|
-
): Promise<Tina4Response> {
|
|
242
|
-
return response.render(name, data, status, templateDir);
|
|
243
|
-
};
|
|
244
|
-
|
|
245
289
|
/**
|
|
246
290
|
* Stream response from an async generator for Server-Sent Events (SSE).
|
|
247
291
|
*
|