tina4-nodejs 3.0.0-rc.2
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 +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- package/skills/tina4-maintainer.skill +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class MiddlewareChain {
|
|
4
|
+
private middlewares: Middleware[] = [];
|
|
5
|
+
|
|
6
|
+
use(fn: Middleware): void {
|
|
7
|
+
this.middlewares.push(fn);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async run(req: Tina4Request, res: Tina4Response): Promise<boolean> {
|
|
11
|
+
let index = 0;
|
|
12
|
+
let completed = true;
|
|
13
|
+
|
|
14
|
+
const next = (): void => {
|
|
15
|
+
index++;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
for (index = 0; index < this.middlewares.length; index++) {
|
|
19
|
+
const prevIndex = index;
|
|
20
|
+
await this.middlewares[index](req, res, next);
|
|
21
|
+
|
|
22
|
+
// If response was already sent, stop the chain
|
|
23
|
+
if (res.raw.writableEnded) {
|
|
24
|
+
completed = false;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// If next() wasn't called, stop the chain
|
|
29
|
+
if (index === prevIndex) {
|
|
30
|
+
// next() increments index, so if it wasn't called, index stays the same
|
|
31
|
+
// But we increment in the for loop, so we need to check differently
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return completed;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Configuration for the CORS middleware */
|
|
40
|
+
export interface CorsConfig {
|
|
41
|
+
/** Allowed origins. Default: "*" (or TINA4_CORS_ORIGINS env, comma-separated) */
|
|
42
|
+
origins?: string | string[];
|
|
43
|
+
/** Allowed methods. Default: standard REST methods (or TINA4_CORS_METHODS env) */
|
|
44
|
+
methods?: string | string[];
|
|
45
|
+
/** Allowed headers. Default: Content-Type, Authorization (or TINA4_CORS_HEADERS env) */
|
|
46
|
+
headers?: string | string[];
|
|
47
|
+
/** Access-Control-Max-Age in seconds. Default: 86400 (or TINA4_CORS_MAX_AGE env) */
|
|
48
|
+
maxAge?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Built-in CORS middleware.
|
|
53
|
+
* Reads configuration from env vars if not provided:
|
|
54
|
+
* TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
|
|
55
|
+
* TINA4_CORS_METHODS — comma-separated list of allowed methods
|
|
56
|
+
* TINA4_CORS_HEADERS — comma-separated list of allowed headers
|
|
57
|
+
* TINA4_CORS_MAX_AGE — preflight cache duration in seconds
|
|
58
|
+
*
|
|
59
|
+
* Preflight (OPTIONS) returns 204 with appropriate headers.
|
|
60
|
+
* Supports wildcard ("*") and specific origin matching.
|
|
61
|
+
*/
|
|
62
|
+
export function cors(config?: CorsConfig): Middleware {
|
|
63
|
+
const originsRaw = config?.origins
|
|
64
|
+
?? process.env.TINA4_CORS_ORIGINS
|
|
65
|
+
?? "*";
|
|
66
|
+
const allowedOrigins = Array.isArray(originsRaw)
|
|
67
|
+
? originsRaw
|
|
68
|
+
: originsRaw.split(",").map((o) => o.trim());
|
|
69
|
+
|
|
70
|
+
const methodsRaw = config?.methods
|
|
71
|
+
?? process.env.TINA4_CORS_METHODS
|
|
72
|
+
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
73
|
+
const allowedMethods = Array.isArray(methodsRaw)
|
|
74
|
+
? methodsRaw.join(", ")
|
|
75
|
+
: methodsRaw;
|
|
76
|
+
|
|
77
|
+
const headersRaw = config?.headers
|
|
78
|
+
?? process.env.TINA4_CORS_HEADERS
|
|
79
|
+
?? "Content-Type, Authorization";
|
|
80
|
+
const allowedHeaders = Array.isArray(headersRaw)
|
|
81
|
+
? headersRaw.join(", ")
|
|
82
|
+
: headersRaw;
|
|
83
|
+
|
|
84
|
+
const maxAge = config?.maxAge
|
|
85
|
+
?? (process.env.TINA4_CORS_MAX_AGE ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10) : 86400);
|
|
86
|
+
|
|
87
|
+
return (req, res, next) => {
|
|
88
|
+
const requestOrigin = req.headers.origin ?? "";
|
|
89
|
+
|
|
90
|
+
// Determine the correct origin header value
|
|
91
|
+
let originHeader: string;
|
|
92
|
+
if (allowedOrigins.includes("*")) {
|
|
93
|
+
originHeader = "*";
|
|
94
|
+
} else if (allowedOrigins.includes(requestOrigin)) {
|
|
95
|
+
originHeader = requestOrigin;
|
|
96
|
+
// When responding with a specific origin, add Vary: Origin
|
|
97
|
+
res.header("Vary", "Origin");
|
|
98
|
+
} else {
|
|
99
|
+
// Origin not allowed — still call next() but don't set CORS headers
|
|
100
|
+
if (req.method === "OPTIONS") {
|
|
101
|
+
res(null, 204);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
next();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
res.header("Access-Control-Allow-Origin", originHeader);
|
|
109
|
+
res.header("Access-Control-Allow-Methods", allowedMethods);
|
|
110
|
+
res.header("Access-Control-Allow-Headers", allowedHeaders);
|
|
111
|
+
|
|
112
|
+
if (req.method === "OPTIONS") {
|
|
113
|
+
res.header("Access-Control-Max-Age", String(maxAge));
|
|
114
|
+
res(null, 204);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
next();
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Built-in request logger middleware
|
|
123
|
+
export function requestLogger(): Middleware {
|
|
124
|
+
return (req, res, next) => {
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
|
|
127
|
+
res.raw.on("finish", () => {
|
|
128
|
+
const duration = Date.now() - start;
|
|
129
|
+
const status = res.raw.statusCode;
|
|
130
|
+
const method = req.method ?? "?";
|
|
131
|
+
const url = req.url ?? "/";
|
|
132
|
+
const color = status >= 400 ? "\x1b[31m" : status >= 300 ? "\x1b[33m" : "\x1b[32m";
|
|
133
|
+
console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
next();
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Queue — File-backed job queue, zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Production-grade queue using the file system as storage.
|
|
5
|
+
* No Redis, no RabbitMQ, no external dependencies needed.
|
|
6
|
+
*
|
|
7
|
+
* import { Queue } from "@tina4/core";
|
|
8
|
+
*
|
|
9
|
+
* const queue = new Queue();
|
|
10
|
+
* queue.push("emails", { to: "alice@test.com", subject: "Hello" });
|
|
11
|
+
*
|
|
12
|
+
* const job = queue.pop("emails");
|
|
13
|
+
* if (job) {
|
|
14
|
+
* await processJob(job);
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, renameSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
// ── Types ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface QueueConfig {
|
|
24
|
+
backend?: string;
|
|
25
|
+
path?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface QueueJob {
|
|
29
|
+
id: string;
|
|
30
|
+
payload: unknown;
|
|
31
|
+
status: "pending" | "reserved" | "failed" | "dead";
|
|
32
|
+
createdAt: string;
|
|
33
|
+
attempts: number;
|
|
34
|
+
delayUntil: string | null;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProcessOptions {
|
|
39
|
+
pollInterval?: number;
|
|
40
|
+
maxJobs?: number;
|
|
41
|
+
maxRetries?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Queue ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export class Queue {
|
|
47
|
+
private backend: string;
|
|
48
|
+
private basePath: string;
|
|
49
|
+
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;
|
|
51
|
+
|
|
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";
|
|
55
|
+
|
|
56
|
+
// Initialize external backends
|
|
57
|
+
if (this.backend === "rabbitmq") {
|
|
58
|
+
// Dynamic import to avoid loading when not needed
|
|
59
|
+
const { RabbitMQBackend } = require("./queueBackends/rabbitmqBackend.js");
|
|
60
|
+
this.externalBackend = new RabbitMQBackend();
|
|
61
|
+
} else if (this.backend === "kafka") {
|
|
62
|
+
const { KafkaBackend } = require("./queueBackends/kafkaBackend.js");
|
|
63
|
+
this.externalBackend = new KafkaBackend();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Ensure directory exists for a queue.
|
|
69
|
+
*/
|
|
70
|
+
private ensureDir(queue: string): string {
|
|
71
|
+
const dir = join(this.basePath, queue);
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Ensure the failed subdirectory exists for a queue.
|
|
78
|
+
*/
|
|
79
|
+
private ensureFailedDir(queue: string): string {
|
|
80
|
+
const dir = join(this.basePath, queue, "failed");
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
return dir;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Add a job to the queue. Returns job ID.
|
|
87
|
+
*/
|
|
88
|
+
push(queue: string, payload: unknown, delay?: number): string {
|
|
89
|
+
if (this.externalBackend) {
|
|
90
|
+
return this.externalBackend.push(queue, payload, delay);
|
|
91
|
+
}
|
|
92
|
+
const dir = this.ensureDir(queue);
|
|
93
|
+
const id = randomUUID();
|
|
94
|
+
const now = new Date().toISOString();
|
|
95
|
+
this.seq++;
|
|
96
|
+
|
|
97
|
+
const job: QueueJob = {
|
|
98
|
+
id,
|
|
99
|
+
payload,
|
|
100
|
+
status: "pending",
|
|
101
|
+
createdAt: now,
|
|
102
|
+
attempts: 0,
|
|
103
|
+
delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Use timestamp + sequence prefix for FIFO ordering
|
|
107
|
+
const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
|
|
108
|
+
writeFileSync(join(dir, `${prefix}_${id}.json`), JSON.stringify(job, null, 2));
|
|
109
|
+
return id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Atomically claim the next available job. Returns null if empty.
|
|
114
|
+
*/
|
|
115
|
+
pop(queue: string): QueueJob | null {
|
|
116
|
+
if (this.externalBackend) {
|
|
117
|
+
return this.externalBackend.pop(queue);
|
|
118
|
+
}
|
|
119
|
+
const dir = this.ensureDir(queue);
|
|
120
|
+
|
|
121
|
+
let files: string[];
|
|
122
|
+
try {
|
|
123
|
+
files = readdirSync(dir).filter(f => f.endsWith(".json")).sort();
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const filePath = join(dir, file);
|
|
132
|
+
let job: QueueJob;
|
|
133
|
+
try {
|
|
134
|
+
job = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (job.status !== "pending") continue;
|
|
140
|
+
if (job.delayUntil && job.delayUntil > now) continue;
|
|
141
|
+
|
|
142
|
+
// Reserve the job
|
|
143
|
+
job.status = "reserved";
|
|
144
|
+
writeFileSync(filePath, JSON.stringify(job, null, 2));
|
|
145
|
+
|
|
146
|
+
// Remove the file (job is consumed)
|
|
147
|
+
try {
|
|
148
|
+
unlinkSync(filePath);
|
|
149
|
+
} catch {
|
|
150
|
+
// Already consumed by another worker
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return job;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Process jobs from a queue with a handler function.
|
|
161
|
+
*/
|
|
162
|
+
process(
|
|
163
|
+
queue: string,
|
|
164
|
+
handler: (job: QueueJob) => Promise<void> | void,
|
|
165
|
+
options?: ProcessOptions,
|
|
166
|
+
): 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;
|
|
176
|
+
|
|
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
|
+
};
|
|
195
|
+
|
|
196
|
+
// Process all currently available jobs synchronously
|
|
197
|
+
while (processed < maxJobs) {
|
|
198
|
+
const job = this.pop(queue);
|
|
199
|
+
if (!job) break;
|
|
200
|
+
try {
|
|
201
|
+
const result = handler(job);
|
|
202
|
+
if (result instanceof Promise) {
|
|
203
|
+
// For async handlers in sync process loop, we still increment
|
|
204
|
+
result.catch((err: Error) => {
|
|
205
|
+
this._failJob(queue, job, err.message, maxRetries);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
processed++;
|
|
209
|
+
} catch (err: unknown) {
|
|
210
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
211
|
+
this._failJob(queue, job, message, maxRetries);
|
|
212
|
+
processed++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Count pending jobs in a queue.
|
|
219
|
+
*/
|
|
220
|
+
size(queue: string): number {
|
|
221
|
+
if (this.externalBackend) {
|
|
222
|
+
return this.externalBackend.size(queue);
|
|
223
|
+
}
|
|
224
|
+
const dir = this.ensureDir(queue);
|
|
225
|
+
let files: string[];
|
|
226
|
+
try {
|
|
227
|
+
files = readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
228
|
+
} catch {
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let count = 0;
|
|
233
|
+
for (const file of files) {
|
|
234
|
+
try {
|
|
235
|
+
const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
236
|
+
if (job.status === "pending") count++;
|
|
237
|
+
} catch {
|
|
238
|
+
// skip corrupt files
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return count;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Remove all jobs from a queue.
|
|
246
|
+
*/
|
|
247
|
+
clear(queue: string): void {
|
|
248
|
+
if (this.externalBackend) {
|
|
249
|
+
this.externalBackend.clear(queue);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const dir = this.ensureDir(queue);
|
|
253
|
+
try {
|
|
254
|
+
const files = readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
unlinkSync(join(dir, file));
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// directory might not exist
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Also clear failed jobs
|
|
263
|
+
const failedDir = join(dir, "failed");
|
|
264
|
+
try {
|
|
265
|
+
if (existsSync(failedDir)) {
|
|
266
|
+
const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
unlinkSync(join(failedDir, file));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// ignore
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get all failed jobs for a queue.
|
|
278
|
+
*/
|
|
279
|
+
failed(queue: string): QueueJob[] {
|
|
280
|
+
const failedDir = this.ensureFailedDir(queue);
|
|
281
|
+
const results: QueueJob[] = [];
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const files = readdirSync(failedDir).filter(f => f.endsWith(".json")).sort();
|
|
285
|
+
for (const file of files) {
|
|
286
|
+
try {
|
|
287
|
+
const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
|
|
288
|
+
results.push(job);
|
|
289
|
+
} catch {
|
|
290
|
+
// skip corrupt files
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
// directory might not exist
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return results;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Retry a failed job by moving it back to the queue.
|
|
302
|
+
*/
|
|
303
|
+
retry(jobId: string): boolean {
|
|
304
|
+
// Search for the job in all queue failed directories
|
|
305
|
+
try {
|
|
306
|
+
const queues = readdirSync(this.basePath);
|
|
307
|
+
for (const queue of queues) {
|
|
308
|
+
const failedDir = join(this.basePath, queue, "failed");
|
|
309
|
+
const filePath = join(failedDir, `${jobId}.json`);
|
|
310
|
+
|
|
311
|
+
if (existsSync(filePath)) {
|
|
312
|
+
const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
313
|
+
job.status = "pending";
|
|
314
|
+
job.attempts = (job.attempts || 0) + 1;
|
|
315
|
+
job.error = undefined;
|
|
316
|
+
|
|
317
|
+
// Move back to queue directory with sortable prefix
|
|
318
|
+
this.seq++;
|
|
319
|
+
const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
|
|
320
|
+
const queueDir = join(this.basePath, queue);
|
|
321
|
+
writeFileSync(join(queueDir, `${prefix}_${jobId}.json`), JSON.stringify(job, null, 2));
|
|
322
|
+
unlinkSync(filePath);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
// ignore
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get dead letter jobs — failed jobs that exceeded max retries.
|
|
335
|
+
*/
|
|
336
|
+
deadLetters(queue: string, maxRetries: number = 3): QueueJob[] {
|
|
337
|
+
const failedDir = this.ensureFailedDir(queue);
|
|
338
|
+
const results: QueueJob[] = [];
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const files = readdirSync(failedDir).filter(f => f.endsWith(".json")).sort();
|
|
342
|
+
for (const file of files) {
|
|
343
|
+
try {
|
|
344
|
+
const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
|
|
345
|
+
if ((job.attempts || 0) >= maxRetries) {
|
|
346
|
+
job.status = "dead";
|
|
347
|
+
results.push(job);
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
// skip corrupt files
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch {
|
|
354
|
+
// directory might not exist
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
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.
|
|
365
|
+
*/
|
|
366
|
+
purge(queue: string, status: string, maxRetries: number = 3): number {
|
|
367
|
+
let count = 0;
|
|
368
|
+
|
|
369
|
+
if (status === "dead") {
|
|
370
|
+
const failedDir = this.ensureFailedDir(queue);
|
|
371
|
+
try {
|
|
372
|
+
const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
try {
|
|
375
|
+
const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
|
|
376
|
+
if ((job.attempts || 0) >= maxRetries) {
|
|
377
|
+
unlinkSync(join(failedDir, file));
|
|
378
|
+
count++;
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
// skip corrupt files
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
// directory might not exist
|
|
386
|
+
}
|
|
387
|
+
} else if (status === "failed") {
|
|
388
|
+
const failedDir = this.ensureFailedDir(queue);
|
|
389
|
+
try {
|
|
390
|
+
const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
|
|
391
|
+
for (const file of files) {
|
|
392
|
+
try {
|
|
393
|
+
const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
|
|
394
|
+
if ((job.attempts || 0) < maxRetries) {
|
|
395
|
+
unlinkSync(join(failedDir, file));
|
|
396
|
+
count++;
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
// skip corrupt files
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
// directory might not exist
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
// completed, pending, or other — scan main queue directory
|
|
407
|
+
const dir = this.ensureDir(queue);
|
|
408
|
+
try {
|
|
409
|
+
const files = readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
410
|
+
for (const file of files) {
|
|
411
|
+
try {
|
|
412
|
+
const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
413
|
+
if (job.status === status) {
|
|
414
|
+
unlinkSync(join(dir, file));
|
|
415
|
+
count++;
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
// skip corrupt files
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
// directory might not exist
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return count;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Re-queue failed jobs that haven't exceeded max retries back to pending.
|
|
431
|
+
* Returns the number of jobs re-queued.
|
|
432
|
+
*/
|
|
433
|
+
retryFailed(queue: string, maxRetries: number = 3): number {
|
|
434
|
+
const failedDir = this.ensureFailedDir(queue);
|
|
435
|
+
const queueDir = this.ensureDir(queue);
|
|
436
|
+
let count = 0;
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
|
|
440
|
+
for (const file of files) {
|
|
441
|
+
try {
|
|
442
|
+
const filePath = join(failedDir, file);
|
|
443
|
+
const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
444
|
+
|
|
445
|
+
// Only retry if under max retries (not dead)
|
|
446
|
+
if ((job.attempts || 0) >= maxRetries) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
job.status = "pending";
|
|
451
|
+
job.error = undefined;
|
|
452
|
+
|
|
453
|
+
// Move back to queue directory with sortable prefix
|
|
454
|
+
this.seq++;
|
|
455
|
+
const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
|
|
456
|
+
writeFileSync(join(queueDir, `${prefix}_${job.id}.json`), JSON.stringify(job, null, 2));
|
|
457
|
+
unlinkSync(filePath);
|
|
458
|
+
count++;
|
|
459
|
+
} catch {
|
|
460
|
+
// skip corrupt files
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// directory might not exist
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return count;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Move a job to the failed directory.
|
|
472
|
+
*/
|
|
473
|
+
private _failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
|
|
474
|
+
const failedDir = this.ensureFailedDir(queue);
|
|
475
|
+
job.status = "failed";
|
|
476
|
+
job.attempts = (job.attempts || 0) + 1;
|
|
477
|
+
job.error = error;
|
|
478
|
+
|
|
479
|
+
writeFileSync(join(failedDir, `${job.id}.json`), JSON.stringify(job, null, 2));
|
|
480
|
+
}
|
|
481
|
+
}
|