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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/ai.ts +1 -1
  3. package/packages/core/src/api.ts +6 -6
  4. package/packages/core/src/auth.ts +28 -15
  5. package/packages/core/src/cache.ts +9 -0
  6. package/packages/core/src/devAdmin.ts +85 -7
  7. package/packages/core/src/devMailbox.ts +21 -21
  8. package/packages/core/src/fakeData.ts +24 -14
  9. package/packages/core/src/graphql.ts +37 -1
  10. package/packages/core/src/i18n.ts +1 -1
  11. package/packages/core/src/index.ts +6 -6
  12. package/packages/core/src/mcp.ts +3 -0
  13. package/packages/core/src/messenger.ts +52 -4
  14. package/packages/core/src/middleware.ts +61 -0
  15. package/packages/core/src/queue.ts +103 -30
  16. package/packages/core/src/queueBackends/liteBackend.ts +43 -0
  17. package/packages/core/src/rateLimiter.ts +88 -1
  18. package/packages/core/src/request.ts +24 -1
  19. package/packages/core/src/response.ts +54 -10
  20. package/packages/core/src/router.ts +32 -14
  21. package/packages/core/src/scss.ts +44 -2
  22. package/packages/core/src/server.ts +26 -4
  23. package/packages/core/src/service.ts +7 -0
  24. package/packages/core/src/session.ts +4 -4
  25. package/packages/core/src/testClient.ts +2 -2
  26. package/packages/core/src/testing.ts +6 -6
  27. package/packages/core/src/types.ts +8 -1
  28. package/packages/core/src/watcher.ts +66 -0
  29. package/packages/core/src/websocket.ts +24 -3
  30. package/packages/core/src/websocketConnection.ts +4 -0
  31. package/packages/core/src/wsdl.ts +12 -12
  32. package/packages/frond/src/engine.ts +6 -0
  33. package/packages/orm/src/adapters/firebird.ts +2 -2
  34. package/packages/orm/src/adapters/mssql.ts +2 -2
  35. package/packages/orm/src/adapters/mysql.ts +2 -2
  36. package/packages/orm/src/adapters/postgres.ts +2 -2
  37. package/packages/orm/src/adapters/sqlite.ts +3 -3
  38. package/packages/orm/src/autoCrud.ts +117 -74
  39. package/packages/orm/src/baseModel.ts +44 -7
  40. package/packages/orm/src/database.ts +58 -15
  41. package/packages/orm/src/databaseResult.ts +5 -0
  42. package/packages/orm/src/fakeData.ts +1 -11
  43. package/packages/orm/src/index.ts +1 -1
  44. package/packages/orm/src/migration.ts +78 -5
  45. package/packages/orm/src/queryBuilder.ts +2 -2
  46. package/packages/orm/src/sqlTranslation.ts +20 -3
  47. package/packages/orm/src/types.ts +2 -2
  48. package/packages/swagger/src/generator.ts +2 -2
  49. 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
- while (processed < maxJobs) {
157
- const job = this.pop();
158
- if (!job) break;
159
- try {
160
- const result = handler(job);
161
- if (result instanceof Promise) {
162
- result.catch((err: Error) => {
163
- this._failJob(queue, job, err.message, maxRetries);
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, delay?: number, priority: number = 0): string {
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(topic?: string, id?: string, pollInterval: number = 1000, iterations: number = 0): AsyncGenerator<QueueJob> {
283
- const q = topic ?? this.topic;
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 (id !== undefined) {
286
- const raw = this.popById(id);
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
- const raw = this.pop(q) as any;
297
- if (raw === null) {
298
- if (pollInterval <= 0) break;
299
- await new Promise(resolve => setTimeout(resolve, pollInterval));
300
- continue;
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
- export async function parseBody(req: Tina4Request): Promise<void> {
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()/template().
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, pathname: string): MatchResult | null {
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(pathname);
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, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
317
- return defaultRouter.get(path, handler, middlewares, meta);
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, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
324
- return defaultRouter.post(path, handler, middlewares, meta);
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, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
331
- return defaultRouter.put(path, handler, middlewares, meta);
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, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
338
- return defaultRouter.patch(path, handler, middlewares, meta);
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, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
345
- return defaultRouter.delete(path, handler, middlewares, meta);
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, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
352
- return defaultRouter.any(path, handler, middlewares, meta);
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
  /**