tina4-nodejs 3.10.90 → 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/api.ts +6 -6
- package/packages/core/src/auth.ts +28 -15
- package/packages/core/src/cache.ts +9 -0
- package/packages/core/src/devAdmin.ts +85 -7
- package/packages/core/src/devMailbox.ts +21 -21
- package/packages/core/src/fakeData.ts +24 -14
- package/packages/core/src/graphql.ts +37 -1
- package/packages/core/src/i18n.ts +1 -1
- package/packages/core/src/index.ts +6 -6
- package/packages/core/src/mcp.ts +3 -0
- package/packages/core/src/messenger.ts +52 -4
- package/packages/core/src/middleware.ts +61 -0
- package/packages/core/src/queue.ts +103 -30
- package/packages/core/src/queueBackends/liteBackend.ts +43 -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/router.ts +32 -14
- 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/session.ts +4 -4
- 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 +24 -3
- package/packages/core/src/websocketConnection.ts +4 -0
- package/packages/core/src/wsdl.ts +12 -12
- package/packages/frond/src/engine.ts +6 -0
- package/packages/orm/src/adapters/firebird.ts +2 -2
- package/packages/orm/src/adapters/mssql.ts +2 -2
- package/packages/orm/src/adapters/mysql.ts +2 -2
- package/packages/orm/src/adapters/postgres.ts +2 -2
- package/packages/orm/src/adapters/sqlite.ts +3 -3
- package/packages/orm/src/autoCrud.ts +117 -74
- package/packages/orm/src/baseModel.ts +44 -7
- package/packages/orm/src/database.ts +58 -15
- package/packages/orm/src/databaseResult.ts +5 -0
- package/packages/orm/src/fakeData.ts +1 -11
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/migration.ts +78 -5
- package/packages/orm/src/queryBuilder.ts +2 -2
- package/packages/orm/src/sqlTranslation.ts +20 -3
- package/packages/orm/src/types.ts +2 -2
- package/packages/swagger/src/generator.ts +2 -2
- package/packages/swagger/src/index.ts +1 -1
|
@@ -51,6 +51,30 @@ export class MiddlewareChain {
|
|
|
51
51
|
* the chain short-circuits and runBefore returns shouldContinue = false.
|
|
52
52
|
*/
|
|
53
53
|
export class MiddlewareRunner {
|
|
54
|
+
/** Globally registered middleware classes (parity with PHP/Ruby/Python orchestrators). */
|
|
55
|
+
private static globalMiddleware: any[] = [];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a middleware class to run on every request.
|
|
59
|
+
* Mirrors Tina4\Middleware::use (PHP), Tina4::Middleware.use (Ruby),
|
|
60
|
+
* and Middleware.use (Python).
|
|
61
|
+
*/
|
|
62
|
+
static use(cls: any): void {
|
|
63
|
+
if (!MiddlewareRunner.globalMiddleware.includes(cls)) {
|
|
64
|
+
MiddlewareRunner.globalMiddleware.push(cls);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Return the list of globally registered middleware classes. */
|
|
69
|
+
static getGlobal(): any[] {
|
|
70
|
+
return [...MiddlewareRunner.globalMiddleware];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Clear all globally registered middleware (primarily for tests). */
|
|
74
|
+
static reset(): void {
|
|
75
|
+
MiddlewareRunner.globalMiddleware = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
/**
|
|
55
79
|
* Execute every beforeX static method found on the supplied classes,
|
|
56
80
|
* in order. Returns the (possibly mutated) request and response pair and a
|
|
@@ -244,6 +268,13 @@ export class CorsMiddleware {
|
|
|
244
268
|
|
|
245
269
|
return [req, res];
|
|
246
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
|
+
}
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
/**
|
|
@@ -330,6 +361,36 @@ export class RateLimiterMiddleware {
|
|
|
330
361
|
entry.timestamps.push(now);
|
|
331
362
|
return [req, res];
|
|
332
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
|
+
}
|
|
333
394
|
}
|
|
334
395
|
|
|
335
396
|
/**
|
|
@@ -51,6 +51,14 @@ export interface ProcessOptions {
|
|
|
51
51
|
pollInterval?: number;
|
|
52
52
|
maxJobs?: number;
|
|
53
53
|
maxRetries?: number;
|
|
54
|
+
batchSize?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ConsumeOptions {
|
|
58
|
+
batchSize?: number;
|
|
59
|
+
pollInterval?: number;
|
|
60
|
+
iterations?: number;
|
|
61
|
+
id?: string;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export interface QueueBackendInterface {
|
|
@@ -139,11 +147,18 @@ export class Queue {
|
|
|
139
147
|
return this.liteBackend.pop(q, this);
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Pop up to count jobs at once. Returns a partial batch if fewer available.
|
|
152
|
+
*/
|
|
153
|
+
popBatch(count: number): QueueJob[] {
|
|
154
|
+
return this.liteBackend.popBatch(this.topic, this, count);
|
|
155
|
+
}
|
|
156
|
+
|
|
142
157
|
/**
|
|
143
158
|
* Process jobs from a queue with a handler function.
|
|
144
159
|
*/
|
|
145
160
|
process(
|
|
146
|
-
handler: (job: QueueJob) => Promise<void> | void,
|
|
161
|
+
handler: (job: QueueJob | QueueJob[]) => Promise<void> | void,
|
|
147
162
|
options?: ProcessOptions,
|
|
148
163
|
): void {
|
|
149
164
|
const queue = this.topic;
|
|
@@ -151,23 +166,44 @@ export class Queue {
|
|
|
151
166
|
|
|
152
167
|
const maxJobs = opts?.maxJobs ?? Infinity;
|
|
153
168
|
const maxRetries = opts?.maxRetries ?? this._maxRetries;
|
|
169
|
+
const batchSize = opts?.batchSize;
|
|
154
170
|
let processed = 0;
|
|
155
171
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
result
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
if (batchSize && batchSize > 1) {
|
|
173
|
+
while (processed < maxJobs) {
|
|
174
|
+
const remaining = maxJobs === Infinity ? batchSize : Math.min(batchSize, maxJobs - processed);
|
|
175
|
+
const jobs = this.popBatch(remaining);
|
|
176
|
+
if (jobs.length === 0) break;
|
|
177
|
+
try {
|
|
178
|
+
const result = handler(jobs);
|
|
179
|
+
if (result instanceof Promise) {
|
|
180
|
+
result.catch((err: Error) => {
|
|
181
|
+
for (const job of jobs) this._failJob(queue, job, err.message, maxRetries);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
} catch (err: unknown) {
|
|
185
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
186
|
+
for (const job of jobs) this._failJob(queue, job, message, maxRetries);
|
|
187
|
+
}
|
|
188
|
+
processed += jobs.length;
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
while (processed < maxJobs) {
|
|
192
|
+
const job = this.pop();
|
|
193
|
+
if (!job) break;
|
|
194
|
+
try {
|
|
195
|
+
const result = handler(job);
|
|
196
|
+
if (result instanceof Promise) {
|
|
197
|
+
result.catch((err: Error) => {
|
|
198
|
+
this._failJob(queue, job, err.message, maxRetries);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
processed++;
|
|
202
|
+
} catch (err: unknown) {
|
|
203
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
204
|
+
this._failJob(queue, job, message, maxRetries);
|
|
205
|
+
processed++;
|
|
165
206
|
}
|
|
166
|
-
processed++;
|
|
167
|
-
} catch (err: unknown) {
|
|
168
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
169
|
-
this._failJob(queue, job, message, maxRetries);
|
|
170
|
-
processed++;
|
|
171
207
|
}
|
|
172
208
|
}
|
|
173
209
|
}
|
|
@@ -211,7 +247,12 @@ export class Queue {
|
|
|
211
247
|
* @param delaySeconds - Optional delay before jobs become available
|
|
212
248
|
* @returns true if at least one job was re-queued, false if none found
|
|
213
249
|
*/
|
|
214
|
-
retry(delaySeconds?: number): boolean {
|
|
250
|
+
retry(jobId?: string, delaySeconds?: number): boolean {
|
|
251
|
+
if (jobId) {
|
|
252
|
+
// Retry a specific job by ID
|
|
253
|
+
return this.liteBackend.retry(this.topic, jobId, delaySeconds);
|
|
254
|
+
}
|
|
255
|
+
// Retry all dead-letter jobs
|
|
215
256
|
const deadJobs = this.deadLetters();
|
|
216
257
|
if (deadJobs.length === 0) return false;
|
|
217
258
|
let retried = false;
|
|
@@ -246,7 +287,7 @@ export class Queue {
|
|
|
246
287
|
/**
|
|
247
288
|
* Produce a message onto a topic. Convenience wrapper around push().
|
|
248
289
|
*/
|
|
249
|
-
produce(topic: string, payload: unknown,
|
|
290
|
+
produce(topic: string, payload: unknown, priority: number = 0, delay: number = 0): string {
|
|
250
291
|
if (this.externalBackend) {
|
|
251
292
|
return this.externalBackend.push(topic, payload, delay);
|
|
252
293
|
}
|
|
@@ -279,29 +320,61 @@ export class Queue {
|
|
|
279
320
|
* for await (const job of queue.consume("emails")) { ... }
|
|
280
321
|
* for await (const job of queue.consume("emails", undefined, 5000)) { ... }
|
|
281
322
|
*/
|
|
282
|
-
async *consume(
|
|
283
|
-
|
|
323
|
+
async *consume(topicOrOptions?: string | ConsumeOptions, id?: string, pollInterval: number = 1000, iterations: number = 0, batchSize: number = 1): AsyncGenerator<QueueJob | QueueJob[]> {
|
|
324
|
+
// Support options-object form: consume({ batchSize, pollInterval, iterations, id })
|
|
325
|
+
let q: string;
|
|
326
|
+
let resolvedId: string | undefined;
|
|
327
|
+
let resolvedPollInterval: number;
|
|
328
|
+
let resolvedIterations: number;
|
|
329
|
+
let resolvedBatchSize: number;
|
|
330
|
+
|
|
331
|
+
if (topicOrOptions !== null && typeof topicOrOptions === "object") {
|
|
332
|
+
const opts = topicOrOptions as ConsumeOptions;
|
|
333
|
+
q = this.topic;
|
|
334
|
+
resolvedId = opts.id;
|
|
335
|
+
resolvedPollInterval = opts.pollInterval ?? 1000;
|
|
336
|
+
resolvedIterations = opts.iterations ?? 0;
|
|
337
|
+
resolvedBatchSize = opts.batchSize ?? batchSize;
|
|
338
|
+
} else {
|
|
339
|
+
q = (topicOrOptions as string | undefined) ?? this.topic;
|
|
340
|
+
resolvedId = id;
|
|
341
|
+
resolvedPollInterval = pollInterval;
|
|
342
|
+
resolvedIterations = iterations;
|
|
343
|
+
resolvedBatchSize = batchSize;
|
|
344
|
+
}
|
|
284
345
|
|
|
285
|
-
if (
|
|
286
|
-
const raw = this.popById(
|
|
346
|
+
if (resolvedId !== undefined) {
|
|
347
|
+
const raw = this.popById(resolvedId);
|
|
287
348
|
if (raw) yield createJob(raw as any, this);
|
|
288
349
|
return;
|
|
289
350
|
}
|
|
290
351
|
|
|
291
352
|
// pollInterval=0 → single-pass drain (returns when empty)
|
|
292
353
|
// pollInterval>0 → long-running poll (sleeps when empty, never returns)
|
|
293
|
-
// iterations>0 → stop after consuming N jobs
|
|
354
|
+
// iterations>0 → stop after consuming N jobs (or N batches when batchSize>1)
|
|
294
355
|
let consumed = 0;
|
|
295
356
|
while (true) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
357
|
+
if (resolvedBatchSize && resolvedBatchSize > 1) {
|
|
358
|
+
const jobs = this.popBatch(resolvedBatchSize);
|
|
359
|
+
if (jobs.length === 0) {
|
|
360
|
+
if (resolvedPollInterval <= 0) break;
|
|
361
|
+
await new Promise(resolve => setTimeout(resolve, resolvedPollInterval));
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
yield jobs;
|
|
365
|
+
consumed++;
|
|
366
|
+
if (resolvedIterations > 0 && consumed >= resolvedIterations) break;
|
|
367
|
+
} else {
|
|
368
|
+
const raw = this.pop() as any;
|
|
369
|
+
if (raw === null) {
|
|
370
|
+
if (resolvedPollInterval <= 0) break;
|
|
371
|
+
await new Promise(resolve => setTimeout(resolve, resolvedPollInterval));
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
yield createJob(raw, this);
|
|
375
|
+
consumed++;
|
|
376
|
+
if (resolvedIterations > 0 && consumed >= resolvedIterations) break;
|
|
301
377
|
}
|
|
302
|
-
yield createJob(raw, this);
|
|
303
|
-
consumed++;
|
|
304
|
-
if (iterations > 0 && consumed >= iterations) break;
|
|
305
378
|
}
|
|
306
379
|
}
|
|
307
380
|
|
|
@@ -90,6 +90,49 @@ export class LiteBackend {
|
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
popBatch(queue: string, bridge: JobQueueBridge, count: number): QueueJob[] {
|
|
94
|
+
const dir = this.ensureDir(queue);
|
|
95
|
+
|
|
96
|
+
let files: string[];
|
|
97
|
+
try {
|
|
98
|
+
files = readdirSync(dir).filter(f => f.endsWith(".queue-data")).sort();
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const results: QueueJob[] = [];
|
|
105
|
+
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (results.length >= count) break;
|
|
108
|
+
const filePath = join(dir, file);
|
|
109
|
+
let job: QueueJob;
|
|
110
|
+
try {
|
|
111
|
+
job = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (job.status !== "pending") continue;
|
|
117
|
+
if (job.delayUntil && job.delayUntil > now) continue;
|
|
118
|
+
|
|
119
|
+
job.status = "reserved";
|
|
120
|
+
job.topic = queue;
|
|
121
|
+
job.priority = job.priority ?? 0;
|
|
122
|
+
writeFileSync(filePath, JSON.stringify(job, null, 2));
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(filePath);
|
|
126
|
+
} catch {
|
|
127
|
+
// Already consumed by another worker
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
results.push(createJob(job as any, bridge));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
size(queue: string, status: string = "pending"): number {
|
|
94
137
|
if (status === "failed") {
|
|
95
138
|
const failedDir = this.ensureFailedDir(queue);
|
|
@@ -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
|
*
|
|
@@ -54,6 +54,12 @@ export class RouteRef {
|
|
|
54
54
|
this.route.cached = true;
|
|
55
55
|
return this;
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** Append middleware class(es) to this route. */
|
|
59
|
+
middleware(...middlewareClasses: Middleware[]): this {
|
|
60
|
+
this.route.middlewares = [...(this.route.middlewares ?? []), ...middlewareClasses];
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export interface RouteInfo {
|
|
@@ -194,7 +200,7 @@ export class Router {
|
|
|
194
200
|
/**
|
|
195
201
|
* Match a request method + pathname to a registered route.
|
|
196
202
|
*/
|
|
197
|
-
match(method: string,
|
|
203
|
+
match(method: string, path: string): MatchResult | null {
|
|
198
204
|
const upperMethod = method.toUpperCase();
|
|
199
205
|
|
|
200
206
|
// Try exact method first, then ANY routes are already registered under each method
|
|
@@ -202,7 +208,7 @@ export class Router {
|
|
|
202
208
|
if (!routes) return null;
|
|
203
209
|
|
|
204
210
|
for (const route of routes) {
|
|
205
|
-
const match = route.regex.exec(
|
|
211
|
+
const match = route.regex.exec(path);
|
|
206
212
|
if (match) {
|
|
207
213
|
const params: Record<string, string> = {};
|
|
208
214
|
for (let i = 0; i < route.paramNames.length; i++) {
|
|
@@ -310,46 +316,58 @@ export class Router {
|
|
|
310
316
|
// Router.get("/path", handler)
|
|
311
317
|
// as an alternative to importing the top-level get(), post(), etc.
|
|
312
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Register a route for a specific HTTP method.
|
|
321
|
+
* Core registration method — all convenience methods delegate here.
|
|
322
|
+
*/
|
|
323
|
+
static add(method: string, path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
324
|
+
const m = method.toUpperCase();
|
|
325
|
+
if (m === "ANY") {
|
|
326
|
+
return defaultRouter.any(path, handler, middleware, swaggerMeta);
|
|
327
|
+
}
|
|
328
|
+
return defaultRouter.addRoute({ method: m, pattern: path, handler, middlewares: middleware, meta: swaggerMeta, template });
|
|
329
|
+
}
|
|
330
|
+
|
|
313
331
|
/**
|
|
314
332
|
* Register a GET route on the default global router.
|
|
315
333
|
*/
|
|
316
|
-
static get(path: string, handler: RouteHandler,
|
|
317
|
-
return defaultRouter.get(path, handler,
|
|
334
|
+
static get(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
335
|
+
return defaultRouter.get(path, handler, middleware, swaggerMeta);
|
|
318
336
|
}
|
|
319
337
|
|
|
320
338
|
/**
|
|
321
339
|
* Register a POST route on the default global router.
|
|
322
340
|
*/
|
|
323
|
-
static post(path: string, handler: RouteHandler,
|
|
324
|
-
return defaultRouter.post(path, handler,
|
|
341
|
+
static post(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
342
|
+
return defaultRouter.post(path, handler, middleware, swaggerMeta);
|
|
325
343
|
}
|
|
326
344
|
|
|
327
345
|
/**
|
|
328
346
|
* Register a PUT route on the default global router.
|
|
329
347
|
*/
|
|
330
|
-
static put(path: string, handler: RouteHandler,
|
|
331
|
-
return defaultRouter.put(path, handler,
|
|
348
|
+
static put(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
349
|
+
return defaultRouter.put(path, handler, middleware, swaggerMeta);
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
/**
|
|
335
353
|
* Register a PATCH route on the default global router.
|
|
336
354
|
*/
|
|
337
|
-
static patch(path: string, handler: RouteHandler,
|
|
338
|
-
return defaultRouter.patch(path, handler,
|
|
355
|
+
static patch(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
356
|
+
return defaultRouter.patch(path, handler, middleware, swaggerMeta);
|
|
339
357
|
}
|
|
340
358
|
|
|
341
359
|
/**
|
|
342
360
|
* Register a DELETE route on the default global router.
|
|
343
361
|
*/
|
|
344
|
-
static delete(path: string, handler: RouteHandler,
|
|
345
|
-
return defaultRouter.delete(path, handler,
|
|
362
|
+
static delete(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
363
|
+
return defaultRouter.delete(path, handler, middleware, swaggerMeta);
|
|
346
364
|
}
|
|
347
365
|
|
|
348
366
|
/**
|
|
349
367
|
* Register a route that matches ANY HTTP method on the default global router.
|
|
350
368
|
*/
|
|
351
|
-
static any(path: string, handler: RouteHandler,
|
|
352
|
-
return defaultRouter.any(path, handler,
|
|
369
|
+
static any(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
370
|
+
return defaultRouter.any(path, handler, middleware, swaggerMeta);
|
|
353
371
|
}
|
|
354
372
|
|
|
355
373
|
/**
|