tina4-nodejs 3.10.76 → 3.10.84

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.
@@ -0,0 +1,374 @@
1
+ /**
2
+ * LiteBackend — file-based queue backend for Tina4 Queue.
3
+ * Stores jobs as JSON files on disk. Zero dependencies.
4
+ */
5
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { type QueueJob } from "../job.js";
9
+ import { createJob, type JobQueueBridge } from "../job.js";
10
+
11
+ export class LiteBackend {
12
+ private basePath: string;
13
+ private seq: number = 0;
14
+
15
+ constructor(basePath: string = "data/queue") {
16
+ this.basePath = basePath;
17
+ }
18
+
19
+ private ensureDir(queue: string): string {
20
+ const dir = join(this.basePath, queue);
21
+ mkdirSync(dir, { recursive: true });
22
+ return dir;
23
+ }
24
+
25
+ private ensureFailedDir(queue: string): string {
26
+ const dir = join(this.basePath, queue, "failed");
27
+ mkdirSync(dir, { recursive: true });
28
+ return dir;
29
+ }
30
+
31
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string {
32
+ const dir = this.ensureDir(queue);
33
+ const id = randomUUID();
34
+ const now = new Date().toISOString();
35
+ this.seq++;
36
+
37
+ const job = {
38
+ id,
39
+ payload,
40
+ status: "pending" as const,
41
+ createdAt: now,
42
+ attempts: 0,
43
+ delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
44
+ priority: priority ?? 0,
45
+ };
46
+
47
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
48
+ writeFileSync(join(dir, `${prefix}_${id}.queue-data`), JSON.stringify(job, null, 2));
49
+ return id;
50
+ }
51
+
52
+ pop(queue: string, bridge: JobQueueBridge): QueueJob | null {
53
+ const dir = this.ensureDir(queue);
54
+
55
+ let files: string[];
56
+ try {
57
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data")).sort();
58
+ } catch {
59
+ return null;
60
+ }
61
+
62
+ const now = new Date().toISOString();
63
+
64
+ for (const file of files) {
65
+ const filePath = join(dir, file);
66
+ let job: QueueJob;
67
+ try {
68
+ job = JSON.parse(readFileSync(filePath, "utf-8"));
69
+ } catch {
70
+ continue;
71
+ }
72
+
73
+ if (job.status !== "pending") continue;
74
+ if (job.delayUntil && job.delayUntil > now) continue;
75
+
76
+ job.status = "reserved";
77
+ job.topic = queue;
78
+ job.priority = job.priority ?? 0;
79
+ writeFileSync(filePath, JSON.stringify(job, null, 2));
80
+
81
+ try {
82
+ unlinkSync(filePath);
83
+ } catch {
84
+ // Already consumed by another worker
85
+ }
86
+
87
+ return createJob(job as any, bridge);
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ size(queue: string, status: string = "pending"): number {
94
+ if (status === "failed") {
95
+ const failedDir = this.ensureFailedDir(queue);
96
+ let files: string[];
97
+ try {
98
+ files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
99
+ } catch {
100
+ return 0;
101
+ }
102
+ return files.length;
103
+ }
104
+
105
+ const dir = this.ensureDir(queue);
106
+ let files: string[];
107
+ try {
108
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
109
+ } catch {
110
+ return 0;
111
+ }
112
+
113
+ let count = 0;
114
+ for (const file of files) {
115
+ try {
116
+ const job = JSON.parse(readFileSync(join(dir, file), "utf-8"));
117
+ if (job.status === status) count++;
118
+ } catch {
119
+ // skip corrupt files
120
+ }
121
+ }
122
+ return count;
123
+ }
124
+
125
+ clear(queue: string): number {
126
+ const dir = this.ensureDir(queue);
127
+ let count = 0;
128
+ try {
129
+ const files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
130
+ for (const file of files) {
131
+ unlinkSync(join(dir, file));
132
+ count++;
133
+ }
134
+ } catch {
135
+ // directory might not exist
136
+ }
137
+
138
+ // Also clear failed jobs
139
+ const failedDir = join(dir, "failed");
140
+ try {
141
+ if (existsSync(failedDir)) {
142
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
143
+ for (const file of files) {
144
+ unlinkSync(join(failedDir, file));
145
+ count++;
146
+ }
147
+ }
148
+ } catch {
149
+ // ignore
150
+ }
151
+ return count;
152
+ }
153
+
154
+ failed(queue: string): QueueJob[] {
155
+ const failedDir = this.ensureFailedDir(queue);
156
+ const results: QueueJob[] = [];
157
+
158
+ try {
159
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data")).sort();
160
+ for (const file of files) {
161
+ try {
162
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
163
+ results.push(job);
164
+ } catch {
165
+ // skip corrupt files
166
+ }
167
+ }
168
+ } catch {
169
+ // directory might not exist
170
+ }
171
+
172
+ return results;
173
+ }
174
+
175
+ retry(queue: string, jobId: string, delaySeconds?: number): boolean {
176
+ try {
177
+ const queues = readdirSync(this.basePath);
178
+ for (const q of queues) {
179
+ const failedDir = join(this.basePath, q, "failed");
180
+ const filePath = join(failedDir, `${jobId}.queue-data`);
181
+
182
+ if (existsSync(filePath)) {
183
+ const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
184
+ job.status = "pending";
185
+ job.attempts = (job.attempts || 0) + 1;
186
+ job.error = undefined;
187
+ job.delayUntil = delaySeconds ? new Date(Date.now() + delaySeconds * 1000).toISOString() : null;
188
+
189
+ this.seq++;
190
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
191
+ const queueDir = join(this.basePath, q);
192
+ writeFileSync(join(queueDir, `${prefix}_${jobId}.queue-data`), JSON.stringify(job, null, 2));
193
+ unlinkSync(filePath);
194
+ return true;
195
+ }
196
+ }
197
+ } catch {
198
+ // ignore
199
+ }
200
+
201
+ return false;
202
+ }
203
+
204
+ deadLetters(queue: string, maxRetries: number = 3): QueueJob[] {
205
+ const failedDir = this.ensureFailedDir(queue);
206
+ const results: QueueJob[] = [];
207
+
208
+ try {
209
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data")).sort();
210
+ for (const file of files) {
211
+ try {
212
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
213
+ if ((job.attempts || 0) >= maxRetries) {
214
+ job.status = "dead";
215
+ results.push(job);
216
+ }
217
+ } catch {
218
+ // skip corrupt files
219
+ }
220
+ }
221
+ } catch {
222
+ // directory might not exist
223
+ }
224
+
225
+ return results;
226
+ }
227
+
228
+ purge(queue: string, status: string, maxRetries: number = 3): number {
229
+ let count = 0;
230
+
231
+ if (status === "dead") {
232
+ const failedDir = this.ensureFailedDir(queue);
233
+ try {
234
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
235
+ for (const file of files) {
236
+ try {
237
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
238
+ if ((job.attempts || 0) >= maxRetries) {
239
+ unlinkSync(join(failedDir, file));
240
+ count++;
241
+ }
242
+ } catch {
243
+ // skip corrupt files
244
+ }
245
+ }
246
+ } catch {
247
+ // directory might not exist
248
+ }
249
+ } else if (status === "failed") {
250
+ const failedDir = this.ensureFailedDir(queue);
251
+ try {
252
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
253
+ for (const file of files) {
254
+ try {
255
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
256
+ if ((job.attempts || 0) < maxRetries) {
257
+ unlinkSync(join(failedDir, file));
258
+ count++;
259
+ }
260
+ } catch {
261
+ // skip corrupt files
262
+ }
263
+ }
264
+ } catch {
265
+ // directory might not exist
266
+ }
267
+ } else {
268
+ const dir = this.ensureDir(queue);
269
+ try {
270
+ const files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
271
+ for (const file of files) {
272
+ try {
273
+ const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
274
+ if (job.status === status) {
275
+ unlinkSync(join(dir, file));
276
+ count++;
277
+ }
278
+ } catch {
279
+ // skip corrupt files
280
+ }
281
+ }
282
+ } catch {
283
+ // directory might not exist
284
+ }
285
+ }
286
+
287
+ return count;
288
+ }
289
+
290
+ retryFailed(queue: string, maxRetries: number = 3): number {
291
+ const failedDir = this.ensureFailedDir(queue);
292
+ const queueDir = this.ensureDir(queue);
293
+ let count = 0;
294
+
295
+ try {
296
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
297
+ for (const file of files) {
298
+ try {
299
+ const filePath = join(failedDir, file);
300
+ const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
301
+
302
+ if ((job.attempts || 0) >= maxRetries) {
303
+ continue;
304
+ }
305
+
306
+ job.status = "pending";
307
+ job.error = undefined;
308
+
309
+ this.seq++;
310
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
311
+ writeFileSync(join(queueDir, `${prefix}_${job.id}.queue-data`), JSON.stringify(job, null, 2));
312
+ unlinkSync(filePath);
313
+ count++;
314
+ } catch {
315
+ // skip corrupt files
316
+ }
317
+ }
318
+ } catch {
319
+ // directory might not exist
320
+ }
321
+
322
+ return count;
323
+ }
324
+
325
+ popById(queue: string, id: string): QueueJob | null {
326
+ const dir = this.ensureDir(queue);
327
+
328
+ let files: string[];
329
+ try {
330
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
331
+ } catch {
332
+ return null;
333
+ }
334
+
335
+ for (const file of files) {
336
+ const filePath = join(dir, file);
337
+ let job: QueueJob;
338
+ try {
339
+ job = JSON.parse(readFileSync(filePath, "utf-8"));
340
+ } catch {
341
+ continue;
342
+ }
343
+
344
+ if (job.status !== "pending") continue;
345
+ if (job.id === id) {
346
+ try { unlinkSync(filePath); } catch { /* already consumed */ }
347
+ return job;
348
+ }
349
+ }
350
+
351
+ return null;
352
+ }
353
+
354
+ failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
355
+ const failedDir = this.ensureFailedDir(queue);
356
+ job.status = "failed";
357
+ job.attempts = (job.attempts || 0) + 1;
358
+ job.error = error;
359
+
360
+ writeFileSync(join(failedDir, `${job.id}.queue-data`), JSON.stringify(job, null, 2));
361
+ }
362
+
363
+ retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
364
+ const dir = this.ensureDir(queue);
365
+ job.status = "pending";
366
+ job.attempts = (job.attempts || 0) + 1;
367
+ job.error = undefined;
368
+ job.delayUntil = delaySeconds ? new Date(Date.now() + delaySeconds * 1000).toISOString() : null;
369
+
370
+ this.seq++;
371
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
372
+ writeFileSync(join(dir, `${prefix}_${job.id}.queue-data`), JSON.stringify(job, null, 2));
373
+ }
374
+ }
@@ -8,7 +8,7 @@ import cluster from "node:cluster";
8
8
  import os from "node:os";
9
9
  import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
10
10
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
11
- import { validToken } from "./auth.js";
11
+ import { validToken, getPayload } from "./auth.js";
12
12
  import { discoverRoutes } from "./routeDiscovery.js";
13
13
  import { createRequest, parseBody } from "./request.js";
14
14
  import { createResponse, setDefaultTemplatesDir } from "./response.js";
@@ -596,7 +596,7 @@ ${reset}
596
596
  console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`);
597
597
  }
598
598
 
599
- // Generate auto-CRUD routes (file-based routes take precedence)
599
+ // Generate auto-CRUD routes for all discovered models
600
600
  const crudRoutes = orm.generateCrudRoutes(models);
601
601
  for (const route of crudRoutes) {
602
602
  // Only add if no file-based route already handles this
@@ -791,15 +791,12 @@ ${reset}
791
791
  if (match.secure === true && match.noAuth !== true && !isDevAdmin) {
792
792
  const authHeader = req.headers.authorization ?? "";
793
793
  const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
794
- const secret = process.env.SECRET || "";
795
- const payload = token ? validToken(token, secret) : null;
796
-
797
- if (!payload) {
794
+ if (!token || !validToken(token)) {
798
795
  res.raw.writeHead(401, { "Content-Type": "application/json" });
799
796
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
800
797
  return;
801
798
  }
802
- req.user = payload;
799
+ req.user = getPayload(token) ?? {};
803
800
  }
804
801
 
805
802
  // Inject path params by name into handler arguments, then request/response
@@ -170,6 +170,10 @@ export class WebSocketServer {
170
170
  private server: Server | null = null;
171
171
  private clients: Map<string, WebSocketClient> = new Map();
172
172
  private handlers: Map<string, EventHandler[]> = new Map();
173
+ /** rooms[roomName] = Set of clientIds */
174
+ private rooms: Map<string, Set<string>> = new Map();
175
+ /** clientRooms[clientId] = Set of roomNames */
176
+ private clientRooms: Map<string, Set<string>> = new Map();
173
177
 
174
178
  constructor(options?: { port?: number }) {
175
179
  this.port = options?.port ?? parseInt(process.env.TINA4_WS_PORT ?? "8080", 10);
@@ -281,6 +285,70 @@ export class WebSocketServer {
281
285
  return this.clients;
282
286
  }
283
287
 
288
+ // ── Rooms ──────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Add a client to a named room.
292
+ */
293
+ joinRoom(clientId: string, roomName: string): void {
294
+ if (!this.rooms.has(roomName)) this.rooms.set(roomName, new Set());
295
+ this.rooms.get(roomName)!.add(clientId);
296
+
297
+ if (!this.clientRooms.has(clientId)) this.clientRooms.set(clientId, new Set());
298
+ this.clientRooms.get(clientId)!.add(roomName);
299
+ }
300
+
301
+ /**
302
+ * Remove a client from a named room.
303
+ */
304
+ leaveRoom(clientId: string, roomName: string): void {
305
+ this.rooms.get(roomName)?.delete(clientId);
306
+ this.clientRooms.get(clientId)?.delete(roomName);
307
+ }
308
+
309
+ /**
310
+ * Return the list of client IDs in a room.
311
+ */
312
+ getRoomConnections(roomName: string): string[] {
313
+ return Array.from(this.rooms.get(roomName) ?? []);
314
+ }
315
+
316
+ /**
317
+ * Return the number of clients in a room.
318
+ */
319
+ roomCount(roomName: string): number {
320
+ return this.rooms.get(roomName)?.size ?? 0;
321
+ }
322
+
323
+ /**
324
+ * Return the names of all rooms a client has joined.
325
+ */
326
+ getClientRooms(clientId: string): string[] {
327
+ return Array.from(this.clientRooms.get(clientId) ?? []);
328
+ }
329
+
330
+ /**
331
+ * Broadcast a message to all clients in a room.
332
+ */
333
+ broadcastToRoom(roomName: string, message: string, excludeIds?: string[]): void {
334
+ const members = this.rooms.get(roomName);
335
+ if (!members) return;
336
+
337
+ const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
338
+ const exclude = new Set(excludeIds ?? []);
339
+
340
+ for (const clientId of members) {
341
+ if (exclude.has(clientId)) continue;
342
+ const client = this.clients.get(clientId);
343
+ if (!client || client.closed) continue;
344
+ try {
345
+ client.socket.write(frame);
346
+ } catch {
347
+ // client disconnected
348
+ }
349
+ }
350
+ }
351
+
284
352
  // ── Private ────────────────────────────────────────────────
285
353
 
286
354
  private emit(event: string, ...args: unknown[]): void {
@@ -348,12 +416,14 @@ export class WebSocketServer {
348
416
  socket.on("close", () => {
349
417
  client.closed = true;
350
418
  this.clients.delete(clientId);
419
+ this.removeClientFromAllRooms(clientId);
351
420
  this.emit("close", client);
352
421
  });
353
422
 
354
423
  socket.on("error", (err) => {
355
424
  client.closed = true;
356
425
  this.clients.delete(clientId);
426
+ this.removeClientFromAllRooms(clientId);
357
427
  this.emit("error", err, client);
358
428
  });
359
429
  }
@@ -405,6 +475,7 @@ export class WebSocketServer {
405
475
  // already closed
406
476
  }
407
477
  this.clients.delete(client.id);
478
+ this.removeClientFromAllRooms(client.id);
408
479
  this.emit("close", client);
409
480
  }
410
481
  break;
@@ -414,4 +485,14 @@ export class WebSocketServer {
414
485
 
415
486
  setBuffer(remaining);
416
487
  }
488
+
489
+ private removeClientFromAllRooms(clientId: string): void {
490
+ const rooms = this.clientRooms.get(clientId);
491
+ if (rooms) {
492
+ for (const roomName of rooms) {
493
+ this.rooms.get(roomName)?.delete(clientId);
494
+ }
495
+ }
496
+ this.clientRooms.delete(clientId);
497
+ }
417
498
  }