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.
- package/BENCHMARK_REPORT.md +248 -86
- package/CARBONAH.md +4 -4
- package/CLAUDE.md +16 -1
- package/COMPARISON.md +58 -46
- package/README.md +60 -6
- package/package.json +2 -1
- package/packages/cli/src/bin.ts +8 -0
- package/packages/cli/src/commands/generate.ts +237 -0
- package/packages/core/gallery/queue/meta.json +1 -1
- package/packages/core/gallery/queue/src/lib/queueDb.ts +32 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +28 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +28 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +20 -10
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +25 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +36 -6
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +160 -0
- package/packages/core/src/cache.ts +402 -10
- package/packages/core/src/index.ts +5 -2
- package/packages/core/src/messenger.ts +118 -36
- package/packages/core/src/queue.ts +172 -92
- package/packages/core/src/response.ts +46 -0
- package/packages/core/src/router.ts +94 -1
- package/packages/core/src/server.ts +66 -7
- package/packages/core/src/types.ts +20 -1
- package/packages/core/src/websocketConnection.ts +16 -0
- package/packages/frond/src/engine.ts +184 -6
- package/packages/orm/src/baseModel.ts +274 -20
- package/packages/orm/src/cachedDatabase.ts +180 -0
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/model.ts +1 -0
- package/packages/orm/src/types.ts +75 -0
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tina4 Queue —
|
|
2
|
+
* Tina4 Queue — Unified job queue with pluggable backends, zero dependencies.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
* queue
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
74
|
+
private externalBackend: QueueBackendInterface | null = null;
|
|
51
75
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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:
|
|
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
|
|
183
|
+
pop(queue?: string): QueueJob | null {
|
|
184
|
+
const q = queue ?? this.topic;
|
|
185
|
+
|
|
116
186
|
if (this.externalBackend) {
|
|
117
|
-
return this.externalBackend.pop(
|
|
187
|
+
return this.externalBackend.pop(q);
|
|
118
188
|
}
|
|
119
|
-
const dir = this.ensureDir(
|
|
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
|
-
|
|
164
|
-
|
|
231
|
+
handlerOrQueue: string | ((job: QueueJob) => Promise<void> | void),
|
|
232
|
+
handlerOrOptions?: ((job: QueueJob) => Promise<void> | void) | ProcessOptions,
|
|
165
233
|
options?: ProcessOptions,
|
|
166
234
|
): void {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
let
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
277
|
+
size(queue?: string): number {
|
|
278
|
+
const q = queue ?? this.topic;
|
|
279
|
+
|
|
221
280
|
if (this.externalBackend) {
|
|
222
|
-
return this.externalBackend.size(
|
|
281
|
+
return this.externalBackend.size(q);
|
|
223
282
|
}
|
|
224
|
-
const dir = this.ensureDir(
|
|
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
|
|
306
|
+
clear(queue?: string): void {
|
|
307
|
+
const q = queue ?? this.topic;
|
|
308
|
+
|
|
248
309
|
if (this.externalBackend) {
|
|
249
|
-
this.externalBackend.clear(
|
|
310
|
+
this.externalBackend.clear(q);
|
|
250
311
|
return;
|
|
251
312
|
}
|
|
252
|
-
const dir = this.ensureDir(
|
|
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
|
|
280
|
-
const
|
|
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
|
|
337
|
-
const
|
|
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) >=
|
|
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
|
|
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(
|
|
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) >=
|
|
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) <
|
|
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
|
|
434
|
-
const
|
|
435
|
-
const
|
|
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
|
-
|
|
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 };
|