tina4-nodejs 3.0.0-rc.2 → 3.1.0

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 (31) hide show
  1. package/BENCHMARK_REPORT.md +248 -86
  2. package/CARBONAH.md +4 -4
  3. package/CLAUDE.md +16 -1
  4. package/COMPARISON.md +58 -46
  5. package/README.md +60 -6
  6. package/package.json +2 -1
  7. package/packages/cli/src/bin.ts +8 -0
  8. package/packages/cli/src/commands/generate.ts +237 -0
  9. package/packages/core/gallery/queue/meta.json +1 -1
  10. package/packages/core/gallery/queue/src/lib/queueDb.ts +32 -0
  11. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +28 -0
  12. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +28 -0
  13. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +20 -10
  14. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +25 -0
  15. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +36 -6
  16. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +160 -0
  17. package/packages/core/src/cache.ts +402 -10
  18. package/packages/core/src/index.ts +5 -2
  19. package/packages/core/src/messenger.ts +118 -36
  20. package/packages/core/src/queue.ts +172 -92
  21. package/packages/core/src/response.ts +46 -0
  22. package/packages/core/src/router.ts +94 -1
  23. package/packages/core/src/server.ts +66 -7
  24. package/packages/core/src/types.ts +20 -1
  25. package/packages/core/src/websocketConnection.ts +16 -0
  26. package/packages/frond/src/engine.ts +184 -6
  27. package/packages/orm/src/baseModel.ts +274 -20
  28. package/packages/orm/src/cachedDatabase.ts +180 -0
  29. package/packages/orm/src/index.ts +4 -0
  30. package/packages/orm/src/model.ts +1 -0
  31. package/packages/orm/src/types.ts +75 -0
@@ -1,20 +1,33 @@
1
1
  /**
2
- * Tina4 Queue — File-backed job queue, zero dependencies.
2
+ * Tina4 Queue — Unified job queue with pluggable backends, zero dependencies.
3
3
  *
4
- * Production-grade queue using the file system as storage.
5
- * No Redis, no RabbitMQ, no external dependencies needed.
4
+ * Switching from file to RabbitMQ or Kafka is a .env change — no code change needed.
6
5
  *
6
+ * Supported backends:
7
+ * - 'file' — JSON files on disk (default)
8
+ * - 'rabbitmq' — RabbitMQ via raw TCP (AMQP 0-9-1)
9
+ * - 'kafka' — Kafka via raw TCP
10
+ *
11
+ * Environment variables:
12
+ * TINA4_QUEUE_BACKEND — 'file', 'rabbitmq', or 'kafka'
13
+ * TINA4_QUEUE_URL — connection URL for rabbitmq/kafka
14
+ * TINA4_QUEUE_PATH — file backend storage path (default: data/queue)
15
+ *
16
+ * Usage:
7
17
  * import { Queue } from "@tina4/core";
8
18
  *
9
- * const queue = new Queue();
10
- * queue.push("emails", { to: "alice@test.com", subject: "Hello" });
19
+ * // Auto-detect from env (default: file)
20
+ * const queue = new Queue({ topic: "emails" });
21
+ * queue.push({ to: "alice@test.com", subject: "Hello" });
22
+ *
23
+ * // Explicit backend
24
+ * const queue = new Queue({ topic: "tasks", backend: "rabbitmq" });
11
25
  *
12
- * const job = queue.pop("emails");
13
- * if (job) {
14
- * await processJob(job);
15
- * }
26
+ * // Legacy usage (still works — uses file backend)
27
+ * const queue = new Queue();
28
+ * queue.push("emails", { to: "alice@test.com" });
16
29
  */
17
- import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, renameSync } from "node:fs";
30
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
18
31
  import { join } from "node:path";
19
32
  import { randomUUID } from "node:crypto";
20
33
 
@@ -23,6 +36,8 @@ import { randomUUID } from "node:crypto";
23
36
  export interface QueueConfig {
24
37
  backend?: string;
25
38
  path?: string;
39
+ topic?: string;
40
+ maxRetries?: number;
26
41
  }
27
42
 
28
43
  export interface QueueJob {
@@ -41,53 +56,103 @@ export interface ProcessOptions {
41
56
  maxRetries?: number;
42
57
  }
43
58
 
59
+ export interface QueueBackendInterface {
60
+ push(queue: string, payload: unknown, delay?: number): string;
61
+ pop(queue: string): QueueJob | null;
62
+ size(queue: string): number;
63
+ clear(queue: string): void;
64
+ }
65
+
44
66
  // ── Queue ────────────────────────────────────────────────────
45
67
 
46
68
  export class Queue {
47
- private backend: string;
69
+ private backendName: string;
48
70
  private basePath: string;
71
+ private topic: string;
72
+ private maxRetries: number;
49
73
  private seq: number = 0;
50
- private externalBackend: { push: (q: string, p: unknown, d?: number) => string; pop: (q: string) => QueueJob | null; size: (q: string) => number; clear: (q: string) => void } | null = null;
74
+ private externalBackend: QueueBackendInterface | null = null;
51
75
 
52
- constructor(backend?: string, config?: QueueConfig) {
53
- this.backend = backend ?? config?.backend ?? process.env.TINA4_QUEUE_BACKEND ?? "file";
54
- this.basePath = config?.path ?? process.env.TINA4_QUEUE_PATH ?? "data/queue";
76
+ /**
77
+ * Unified Queue constructor.
78
+ *
79
+ * Accepts either:
80
+ * - new Queue({ topic: "tasks", backend: "rabbitmq" })
81
+ * - new Queue("rabbitmq", { path: "data/queue" }) // legacy
82
+ * - new Queue() // file backend, default topic
83
+ */
84
+ constructor(backendOrConfig?: string | QueueConfig, config?: QueueConfig) {
85
+ let resolvedConfig: QueueConfig = {};
86
+
87
+ if (typeof backendOrConfig === "string") {
88
+ // Legacy: new Queue("rabbitmq", { ... })
89
+ resolvedConfig = { ...(config ?? {}), backend: backendOrConfig };
90
+ } else if (typeof backendOrConfig === "object" && backendOrConfig !== null) {
91
+ resolvedConfig = backendOrConfig;
92
+ }
93
+
94
+ this.backendName = resolvedConfig.backend
95
+ ?? process.env.TINA4_QUEUE_BACKEND
96
+ ?? "file";
97
+ this.basePath = resolvedConfig.path
98
+ ?? process.env.TINA4_QUEUE_PATH
99
+ ?? "data/queue";
100
+ this.topic = resolvedConfig.topic ?? "default";
101
+ this.maxRetries = resolvedConfig.maxRetries ?? 3;
55
102
 
56
103
  // Initialize external backends
57
- if (this.backend === "rabbitmq") {
58
- // Dynamic import to avoid loading when not needed
104
+ if (this.backendName === "rabbitmq") {
59
105
  const { RabbitMQBackend } = require("./queueBackends/rabbitmqBackend.js");
60
106
  this.externalBackend = new RabbitMQBackend();
61
- } else if (this.backend === "kafka") {
107
+ } else if (this.backendName === "kafka") {
62
108
  const { KafkaBackend } = require("./queueBackends/kafkaBackend.js");
63
109
  this.externalBackend = new KafkaBackend();
64
110
  }
65
111
  }
66
112
 
67
- /**
68
- * Ensure directory exists for a queue.
69
- */
113
+ // ── Directory helpers ────────────────────────────────────────
114
+
70
115
  private ensureDir(queue: string): string {
71
116
  const dir = join(this.basePath, queue);
72
117
  mkdirSync(dir, { recursive: true });
73
118
  return dir;
74
119
  }
75
120
 
76
- /**
77
- * Ensure the failed subdirectory exists for a queue.
78
- */
79
121
  private ensureFailedDir(queue: string): string {
80
122
  const dir = join(this.basePath, queue, "failed");
81
123
  mkdirSync(dir, { recursive: true });
82
124
  return dir;
83
125
  }
84
126
 
127
+ // ── Unified API (topic-aware) ────────────────────────────────
128
+
85
129
  /**
86
130
  * Add a job to the queue. Returns job ID.
131
+ *
132
+ * Can be called as:
133
+ * queue.push(payload) — uses constructor topic
134
+ * queue.push(payload, delay) — uses constructor topic with delay
135
+ * queue.push("queueName", payload) — legacy: explicit queue name
87
136
  */
88
- push(queue: string, payload: unknown, delay?: number): string {
137
+ push(queueOrPayload: string | unknown, payloadOrDelay?: unknown, delay?: number): string {
138
+ let queue: string;
139
+ let payload: unknown;
140
+ let actualDelay: number | undefined;
141
+
142
+ if (typeof queueOrPayload === "string" && payloadOrDelay !== undefined && typeof payloadOrDelay !== "number") {
143
+ // Legacy: push("queueName", payload, delay?)
144
+ queue = queueOrPayload;
145
+ payload = payloadOrDelay;
146
+ actualDelay = delay;
147
+ } else {
148
+ // Unified: push(payload) or push(payload, delay)
149
+ queue = this.topic;
150
+ payload = queueOrPayload;
151
+ actualDelay = typeof payloadOrDelay === "number" ? payloadOrDelay : delay;
152
+ }
153
+
89
154
  if (this.externalBackend) {
90
- return this.externalBackend.push(queue, payload, delay);
155
+ return this.externalBackend.push(queue, payload, actualDelay);
91
156
  }
92
157
  const dir = this.ensureDir(queue);
93
158
  const id = randomUUID();
@@ -100,10 +165,9 @@ export class Queue {
100
165
  status: "pending",
101
166
  createdAt: now,
102
167
  attempts: 0,
103
- delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
168
+ delayUntil: actualDelay ? new Date(Date.now() + actualDelay * 1000).toISOString() : null,
104
169
  };
105
170
 
106
- // Use timestamp + sequence prefix for FIFO ordering
107
171
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
108
172
  writeFileSync(join(dir, `${prefix}_${id}.json`), JSON.stringify(job, null, 2));
109
173
  return id;
@@ -111,12 +175,18 @@ export class Queue {
111
175
 
112
176
  /**
113
177
  * Atomically claim the next available job. Returns null if empty.
178
+ *
179
+ * Can be called as:
180
+ * queue.pop() — uses constructor topic
181
+ * queue.pop("queueName") — legacy: explicit queue name
114
182
  */
115
- pop(queue: string): QueueJob | null {
183
+ pop(queue?: string): QueueJob | null {
184
+ const q = queue ?? this.topic;
185
+
116
186
  if (this.externalBackend) {
117
- return this.externalBackend.pop(queue);
187
+ return this.externalBackend.pop(q);
118
188
  }
119
- const dir = this.ensureDir(queue);
189
+ const dir = this.ensureDir(q);
120
190
 
121
191
  let files: string[];
122
192
  try {
@@ -139,11 +209,9 @@ export class Queue {
139
209
  if (job.status !== "pending") continue;
140
210
  if (job.delayUntil && job.delayUntil > now) continue;
141
211
 
142
- // Reserve the job
143
212
  job.status = "reserved";
144
213
  writeFileSync(filePath, JSON.stringify(job, null, 2));
145
214
 
146
- // Remove the file (job is consumed)
147
215
  try {
148
216
  unlinkSync(filePath);
149
217
  } catch {
@@ -160,47 +228,36 @@ export class Queue {
160
228
  * Process jobs from a queue with a handler function.
161
229
  */
162
230
  process(
163
- queue: string,
164
- handler: (job: QueueJob) => Promise<void> | void,
231
+ handlerOrQueue: string | ((job: QueueJob) => Promise<void> | void),
232
+ handlerOrOptions?: ((job: QueueJob) => Promise<void> | void) | ProcessOptions,
165
233
  options?: ProcessOptions,
166
234
  ): void {
167
- const maxJobs = options?.maxJobs ?? Infinity;
168
- const maxRetries = options?.maxRetries ?? 3;
169
- let processed = 0;
170
-
171
- const tick = () => {
172
- if (processed >= maxJobs) return;
173
-
174
- const job = this.pop(queue);
175
- if (!job) return;
235
+ let queue: string;
236
+ let handler: (job: QueueJob) => Promise<void> | void;
237
+ let opts: ProcessOptions | undefined;
238
+
239
+ if (typeof handlerOrQueue === "string") {
240
+ // Legacy: process("queueName", handler, options)
241
+ queue = handlerOrQueue;
242
+ handler = handlerOrOptions as (job: QueueJob) => Promise<void> | void;
243
+ opts = options;
244
+ } else {
245
+ // Unified: process(handler, options?)
246
+ queue = this.topic;
247
+ handler = handlerOrQueue;
248
+ opts = handlerOrOptions as ProcessOptions | undefined;
249
+ }
176
250
 
177
- try {
178
- const result = handler(job);
179
- if (result instanceof Promise) {
180
- result
181
- .then(() => { processed++; })
182
- .catch((err: Error) => {
183
- this._failJob(queue, job, err.message, maxRetries);
184
- processed++;
185
- });
186
- } else {
187
- processed++;
188
- }
189
- } catch (err: unknown) {
190
- const message = err instanceof Error ? err.message : String(err);
191
- this._failJob(queue, job, message, maxRetries);
192
- processed++;
193
- }
194
- };
251
+ const maxJobs = opts?.maxJobs ?? Infinity;
252
+ const maxRetries = opts?.maxRetries ?? this.maxRetries;
253
+ let processed = 0;
195
254
 
196
- // Process all currently available jobs synchronously
197
255
  while (processed < maxJobs) {
198
256
  const job = this.pop(queue);
199
257
  if (!job) break;
200
258
  try {
201
259
  const result = handler(job);
202
260
  if (result instanceof Promise) {
203
- // For async handlers in sync process loop, we still increment
204
261
  result.catch((err: Error) => {
205
262
  this._failJob(queue, job, err.message, maxRetries);
206
263
  });
@@ -217,11 +274,13 @@ export class Queue {
217
274
  /**
218
275
  * Count pending jobs in a queue.
219
276
  */
220
- size(queue: string): number {
277
+ size(queue?: string): number {
278
+ const q = queue ?? this.topic;
279
+
221
280
  if (this.externalBackend) {
222
- return this.externalBackend.size(queue);
281
+ return this.externalBackend.size(q);
223
282
  }
224
- const dir = this.ensureDir(queue);
283
+ const dir = this.ensureDir(q);
225
284
  let files: string[];
226
285
  try {
227
286
  files = readdirSync(dir).filter(f => f.endsWith(".json"));
@@ -244,12 +303,14 @@ export class Queue {
244
303
  /**
245
304
  * Remove all jobs from a queue.
246
305
  */
247
- clear(queue: string): void {
306
+ clear(queue?: string): void {
307
+ const q = queue ?? this.topic;
308
+
248
309
  if (this.externalBackend) {
249
- this.externalBackend.clear(queue);
310
+ this.externalBackend.clear(q);
250
311
  return;
251
312
  }
252
- const dir = this.ensureDir(queue);
313
+ const dir = this.ensureDir(q);
253
314
  try {
254
315
  const files = readdirSync(dir).filter(f => f.endsWith(".json"));
255
316
  for (const file of files) {
@@ -276,8 +337,9 @@ export class Queue {
276
337
  /**
277
338
  * Get all failed jobs for a queue.
278
339
  */
279
- failed(queue: string): QueueJob[] {
280
- const failedDir = this.ensureFailedDir(queue);
340
+ failed(queue?: string): QueueJob[] {
341
+ const q = queue ?? this.topic;
342
+ const failedDir = this.ensureFailedDir(q);
281
343
  const results: QueueJob[] = [];
282
344
 
283
345
  try {
@@ -301,7 +363,6 @@ export class Queue {
301
363
  * Retry a failed job by moving it back to the queue.
302
364
  */
303
365
  retry(jobId: string): boolean {
304
- // Search for the job in all queue failed directories
305
366
  try {
306
367
  const queues = readdirSync(this.basePath);
307
368
  for (const queue of queues) {
@@ -314,7 +375,6 @@ export class Queue {
314
375
  job.attempts = (job.attempts || 0) + 1;
315
376
  job.error = undefined;
316
377
 
317
- // Move back to queue directory with sortable prefix
318
378
  this.seq++;
319
379
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
320
380
  const queueDir = join(this.basePath, queue);
@@ -333,8 +393,10 @@ export class Queue {
333
393
  /**
334
394
  * Get dead letter jobs — failed jobs that exceeded max retries.
335
395
  */
336
- deadLetters(queue: string, maxRetries: number = 3): QueueJob[] {
337
- const failedDir = this.ensureFailedDir(queue);
396
+ deadLetters(queue?: string, maxRetries?: number): QueueJob[] {
397
+ const q = queue ?? this.topic;
398
+ const mr = maxRetries ?? this.maxRetries;
399
+ const failedDir = this.ensureFailedDir(q);
338
400
  const results: QueueJob[] = [];
339
401
 
340
402
  try {
@@ -342,7 +404,7 @@ export class Queue {
342
404
  for (const file of files) {
343
405
  try {
344
406
  const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
345
- if ((job.attempts || 0) >= maxRetries) {
407
+ if ((job.attempts || 0) >= mr) {
346
408
  job.status = "dead";
347
409
  results.push(job);
348
410
  }
@@ -358,12 +420,25 @@ export class Queue {
358
420
  }
359
421
 
360
422
  /**
361
- * Delete messages by status (completed, failed, dead).
362
- * For 'dead', removes failed jobs that exceeded maxRetries.
363
- * For 'failed', removes failed jobs under maxRetries.
364
- * For other statuses, removes matching jobs from the main queue directory.
423
+ * Delete messages by status.
365
424
  */
366
- purge(queue: string, status: string, maxRetries: number = 3): number {
425
+ purge(statusOrQueue: string, statusOrMaxRetries?: string | number, maxRetries?: number): number {
426
+ let queue: string;
427
+ let status: string;
428
+ let mr: number;
429
+
430
+ if (typeof statusOrMaxRetries === "string") {
431
+ // Legacy: purge("queueName", "status", maxRetries?)
432
+ queue = statusOrQueue;
433
+ status = statusOrMaxRetries;
434
+ mr = maxRetries ?? this.maxRetries;
435
+ } else {
436
+ // Unified: purge("status") or purge("status", maxRetries)
437
+ queue = this.topic;
438
+ status = statusOrQueue;
439
+ mr = typeof statusOrMaxRetries === "number" ? statusOrMaxRetries : (maxRetries ?? this.maxRetries);
440
+ }
441
+
367
442
  let count = 0;
368
443
 
369
444
  if (status === "dead") {
@@ -373,7 +448,7 @@ export class Queue {
373
448
  for (const file of files) {
374
449
  try {
375
450
  const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
376
- if ((job.attempts || 0) >= maxRetries) {
451
+ if ((job.attempts || 0) >= mr) {
377
452
  unlinkSync(join(failedDir, file));
378
453
  count++;
379
454
  }
@@ -391,7 +466,7 @@ export class Queue {
391
466
  for (const file of files) {
392
467
  try {
393
468
  const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
394
- if ((job.attempts || 0) < maxRetries) {
469
+ if ((job.attempts || 0) < mr) {
395
470
  unlinkSync(join(failedDir, file));
396
471
  count++;
397
472
  }
@@ -403,7 +478,6 @@ export class Queue {
403
478
  // directory might not exist
404
479
  }
405
480
  } else {
406
- // completed, pending, or other — scan main queue directory
407
481
  const dir = this.ensureDir(queue);
408
482
  try {
409
483
  const files = readdirSync(dir).filter(f => f.endsWith(".json"));
@@ -428,11 +502,12 @@ export class Queue {
428
502
 
429
503
  /**
430
504
  * Re-queue failed jobs that haven't exceeded max retries back to pending.
431
- * Returns the number of jobs re-queued.
432
505
  */
433
- retryFailed(queue: string, maxRetries: number = 3): number {
434
- const failedDir = this.ensureFailedDir(queue);
435
- const queueDir = this.ensureDir(queue);
506
+ retryFailed(queue?: string, maxRetries?: number): number {
507
+ const q = queue ?? this.topic;
508
+ const mr = maxRetries ?? this.maxRetries;
509
+ const failedDir = this.ensureFailedDir(q);
510
+ const queueDir = this.ensureDir(q);
436
511
  let count = 0;
437
512
 
438
513
  try {
@@ -442,15 +517,13 @@ export class Queue {
442
517
  const filePath = join(failedDir, file);
443
518
  const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
444
519
 
445
- // Only retry if under max retries (not dead)
446
- if ((job.attempts || 0) >= maxRetries) {
520
+ if ((job.attempts || 0) >= mr) {
447
521
  continue;
448
522
  }
449
523
 
450
524
  job.status = "pending";
451
525
  job.error = undefined;
452
526
 
453
- // Move back to queue directory with sortable prefix
454
527
  this.seq++;
455
528
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
456
529
  writeFileSync(join(queueDir, `${prefix}_${job.id}.json`), JSON.stringify(job, null, 2));
@@ -467,6 +540,13 @@ export class Queue {
467
540
  return count;
468
541
  }
469
542
 
543
+ /**
544
+ * Get the configured topic name.
545
+ */
546
+ getTopic(): string {
547
+ return this.topic;
548
+ }
549
+
470
550
  /**
471
551
  * Move a job to the failed directory.
472
552
  */
@@ -1,4 +1,6 @@
1
1
  import type { ServerResponse } from "node:http";
2
+ import fs from "node:fs";
3
+ import nodePath from "node:path";
2
4
  import type { Tina4Response, CookieOptions } from "./types.js";
3
5
 
4
6
  /**
@@ -126,6 +128,50 @@ export function createResponse(res: ServerResponse): Tina4Response {
126
128
  return response.cookie(name, "", { ...options, maxAge: 0, expires: new Date(0) });
127
129
  };
128
130
 
131
+ response.file = function (filePath: string, options?: { download?: boolean; contentType?: string }): Tina4Response {
132
+ if (!fs.existsSync(filePath)) {
133
+ res.statusCode = 404;
134
+ res.end("File not found");
135
+ return response;
136
+ }
137
+
138
+ const content = fs.readFileSync(filePath);
139
+ const ext = nodePath.extname(filePath).toLowerCase();
140
+ const mimeTypes: Record<string, string> = {
141
+ ".html": "text/html", ".css": "text/css", ".js": "application/javascript",
142
+ ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg",
143
+ ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
144
+ ".pdf": "application/pdf", ".zip": "application/zip", ".csv": "text/csv",
145
+ ".xml": "application/xml", ".webp": "image/webp", ".ico": "image/x-icon",
146
+ ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf",
147
+ ".txt": "text/plain", ".mp4": "video/mp4", ".mp3": "audio/mpeg",
148
+ };
149
+
150
+ res.setHeader("Content-Type", options?.contentType || mimeTypes[ext] || "application/octet-stream");
151
+ res.setHeader("Content-Length", content.length);
152
+ if (options?.download) {
153
+ res.setHeader("Content-Disposition", `attachment; filename="${nodePath.basename(filePath)}"`);
154
+ }
155
+ res.end(content);
156
+ return response;
157
+ };
158
+
159
+ response.render = async function (templateName: string, data?: Record<string, unknown>): Promise<Tina4Response> {
160
+ try {
161
+ const twig = await import("@tina4/twig");
162
+ const html = await twig.renderTemplate(templateName, data);
163
+ response.html(html);
164
+ } catch (err) {
165
+ res.statusCode = 500;
166
+ response.json({
167
+ error: "Template rendering failed",
168
+ statusCode: 500,
169
+ message: String(err),
170
+ });
171
+ }
172
+ return response;
173
+ };
174
+
129
175
  response.template = async function (name: string, data?: Record<string, unknown>): Promise<Tina4Response> {
130
176
  try {
131
177
  const twig = await import("@tina4/twig");
@@ -1,4 +1,4 @@
1
- import type { RouteHandler, RouteDefinition, RouteMeta, Middleware, Tina4Request, Tina4Response } from "./types.js";
1
+ import type { RouteHandler, RouteDefinition, RouteMeta, Middleware, Tina4Request, Tina4Response, WebSocketRouteHandler, WebSocketRouteDefinition } from "./types.js";
2
2
 
3
3
  interface MatchResult {
4
4
  handler: RouteHandler;
@@ -33,6 +33,7 @@ export interface RouteInfo {
33
33
 
34
34
  export class Router {
35
35
  private routes: Map<string, CompiledRoute[]> = new Map();
36
+ private wsRoutes: WebSocketRouteDefinition[] = [];
36
37
 
37
38
  /**
38
39
  * Add a raw route definition (used internally and by file-based routing).
@@ -188,8 +189,96 @@ export class Router {
188
189
  return info;
189
190
  }
190
191
 
192
+ /**
193
+ * Register a WebSocket route.
194
+ */
195
+ websocket(path: string, handler: WebSocketRouteHandler): void {
196
+ // Remove existing ws route with same pattern (for hot-reload)
197
+ this.wsRoutes = this.wsRoutes.filter((r) => r.pattern !== path);
198
+ this.wsRoutes.push({ pattern: path, handler });
199
+ }
200
+
201
+ /**
202
+ * Get all registered WebSocket route definitions.
203
+ */
204
+ getWebSocketRoutes(): WebSocketRouteDefinition[] {
205
+ return [...this.wsRoutes];
206
+ }
207
+
208
+ /**
209
+ * Match a WebSocket upgrade request path to a registered ws route.
210
+ */
211
+ matchWebSocket(pathname: string): WebSocketRouteDefinition | null {
212
+ for (const route of this.wsRoutes) {
213
+ if (route.pattern === pathname) return route;
214
+ }
215
+ return null;
216
+ }
217
+
191
218
  clear(): void {
192
219
  this.routes.clear();
220
+ this.wsRoutes = [];
221
+ }
222
+
223
+ // ── Static convenience methods ───────────────────────────────
224
+ // These delegate to the defaultRouter singleton so users can write
225
+ // Router.get("/path", handler)
226
+ // as an alternative to importing the top-level get(), post(), etc.
227
+
228
+ /**
229
+ * Register a GET route on the default global router.
230
+ */
231
+ static get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
232
+ defaultRouter.get(path, handler, middlewares, meta);
233
+ }
234
+
235
+ /**
236
+ * Register a POST route on the default global router.
237
+ */
238
+ static post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
239
+ defaultRouter.post(path, handler, middlewares, meta);
240
+ }
241
+
242
+ /**
243
+ * Register a PUT route on the default global router.
244
+ */
245
+ static put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
246
+ defaultRouter.put(path, handler, middlewares, meta);
247
+ }
248
+
249
+ /**
250
+ * Register a PATCH route on the default global router.
251
+ */
252
+ static patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
253
+ defaultRouter.patch(path, handler, middlewares, meta);
254
+ }
255
+
256
+ /**
257
+ * Register a DELETE route on the default global router.
258
+ */
259
+ static delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
260
+ defaultRouter.delete(path, handler, middlewares, meta);
261
+ }
262
+
263
+ /**
264
+ * Register a route that matches ANY HTTP method on the default global router.
265
+ */
266
+ static any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
267
+ defaultRouter.any(path, handler, middlewares, meta);
268
+ }
269
+
270
+ /**
271
+ * Register a WebSocket route on the default global router.
272
+ */
273
+ static websocket(path: string, handler: WebSocketRouteHandler): void {
274
+ defaultRouter.websocket(path, handler);
275
+ }
276
+
277
+ /**
278
+ * Create a route group on the default global router.
279
+ */
280
+ static group(prefix: string, callback: (group: RouteGroup) => void, middlewares?: Middleware[]): void {
281
+ defaultRouter.group(prefix, callback, middlewares);
193
282
  }
194
283
 
195
284
  private compilePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
@@ -394,5 +483,9 @@ export function any(path: string, handler: RouteHandler, middlewares?: Middlewar
394
483
  defaultRouter.any(path, handler, middlewares, meta);
395
484
  }
396
485
 
486
+ export function websocket(path: string, handler: WebSocketRouteHandler): void {
487
+ defaultRouter.websocket(path, handler);
488
+ }
489
+
397
490
  // Re-export "del" as "delete" for developer convenience (use: import { delete as del } from "@tina4/core")
398
491
  export { del as delete };