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.
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/packages/core/src/auth.ts +36 -54
- package/packages/core/src/index.ts +4 -1
- package/packages/core/src/job.ts +86 -0
- package/packages/core/src/middleware.ts +5 -8
- package/packages/core/src/queue.ts +73 -465
- package/packages/core/src/queueBackends/liteBackend.ts +374 -0
- package/packages/core/src/server.ts +4 -7
- package/packages/core/src/websocket.ts +81 -0
- package/packages/orm/src/baseModel.ts +140 -11
- package/packages/orm/src/index.ts +1 -0
- package/packages/orm/src/migration.ts +81 -0
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|