create-nexwire 4.2.5 → 4.3.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/README.md +20 -8
- package/express/.env +1 -1
- package/express/package.json +1 -1
- package/express/src/config/database.ts +48 -29
- package/express/src/config/index.ts +83 -83
- package/express/src/config/openapi.ts +6 -24
- package/express/src/config/queue.ts +30 -30
- package/express/src/config/validate.ts +50 -40
- package/express/src/database/migrations/sqlite/0000_init.sql +47 -47
- package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/express/src/framework/cache/cache.ts +40 -8
- package/express/src/framework/circuit-breaker/cb.ts +88 -60
- package/express/src/framework/database/connection.ts +52 -27
- package/express/src/framework/events/dispatcher.ts +61 -49
- package/express/src/framework/facade.ts +46 -3
- package/express/src/framework/http/app.ts +28 -22
- package/express/src/framework/http/logger.ts +27 -16
- package/express/src/framework/http/openapi.ts +9 -11
- package/express/src/framework/modules/routes.ts +26 -21
- package/express/src/framework/notification/channels/broadcast.ts +15 -0
- package/express/src/framework/notification/channels/mail.ts +40 -0
- package/express/src/framework/notification/contracts.ts +29 -0
- package/express/src/framework/notification/index.ts +19 -94
- package/express/src/framework/notification/registry.ts +15 -0
- package/express/src/framework/providers/adapter.ts +27 -17
- package/express/src/framework/queue/queue.ts +43 -44
- package/express/src/framework/realtime/broadcast.ts +29 -5
- package/express/src/framework/realtime/socket.ts +149 -119
- package/express/src/framework/redis/client.ts +148 -151
- package/express/src/framework/runtime/server-adapter.ts +39 -34
- package/express/src/framework/scheduler/lock.ts +128 -116
- package/express/src/framework/server.ts +33 -49
- package/express/src/framework/session/session.ts +69 -14
- package/express/src/framework/storage/storage.ts +744 -785
- package/express/src/framework/support/mail.ts +15 -7
- package/express/src/providers/notification.ts +4 -0
- package/express/src/resources/src/layouts/Layout/Header.vue +124 -105
- package/express/src/resources/src/plugins/pulse.ts +157 -147
- package/express/src/resources/vite.config.ts +54 -43
- package/express/src/storage/database/nexwire.sqlite +0 -0
- package/express/src/storage/logs/app.log +0 -0
- package/express/src/storage/logs/fatal.log +0 -0
- package/express/src/types/global.d.ts +32 -26
- package/hono/.env +1 -1
- package/hono/package.json +1 -1
- package/hono/src/config/database.ts +48 -29
- package/hono/src/config/index.ts +83 -83
- package/hono/src/config/openapi.ts +6 -24
- package/hono/src/config/queue.ts +30 -30
- package/hono/src/config/validate.ts +50 -40
- package/hono/src/database/migrations/sqlite/0000_init.sql +47 -47
- package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/hono/src/framework/cache/cache.ts +40 -8
- package/hono/src/framework/circuit-breaker/cb.ts +88 -60
- package/hono/src/framework/database/connection.ts +52 -27
- package/hono/src/framework/events/dispatcher.ts +61 -49
- package/hono/src/framework/facade.ts +38 -1
- package/hono/src/framework/http/openapi.ts +7 -9
- package/hono/src/framework/notification/channels/broadcast.ts +15 -0
- package/hono/src/framework/notification/channels/mail.ts +40 -0
- package/hono/src/framework/notification/contracts.ts +29 -0
- package/hono/src/framework/notification/index.ts +19 -94
- package/hono/src/framework/notification/registry.ts +15 -0
- package/hono/src/framework/providers/adapter.ts +27 -17
- package/hono/src/framework/queue/queue.ts +43 -44
- package/hono/src/framework/realtime/broadcast.ts +29 -5
- package/hono/src/framework/realtime/socket.ts +217 -196
- package/hono/src/framework/redis/client.ts +148 -151
- package/hono/src/framework/scheduler/lock.ts +128 -116
- package/hono/src/framework/server.ts +27 -29
- package/hono/src/framework/session/session.ts +62 -14
- package/hono/src/framework/storage/storage.ts +744 -785
- package/hono/src/framework/support/mail.ts +56 -48
- package/hono/src/providers/notification.ts +4 -0
- package/hono/src/resources/src/layouts/Layout/Header.vue +124 -105
- package/hono/src/resources/src/plugins/pulse.ts +157 -147
- package/hono/src/resources/vite.config.ts +54 -43
- package/hono/src/storage/database/nexwire.sqlite +0 -0
- package/hono/src/storage/logs/app.log +0 -0
- package/hono/src/storage/logs/fatal.log +0 -0
- package/hono/src/types/global.d.ts +32 -26
- package/package.json +2 -2
- package/src/index.js +139 -14
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Job, type JobsOptions, Queue, QueueEvents, Worker } from "bullmq";
|
|
2
2
|
import { type DurableContext, type DurableJob, DurableWorker, RedisStateStore } from "bullmq-durable";
|
|
3
|
-
import { queueConfig } from "@/config/index.js";
|
|
3
|
+
import { queueConfig, redisConfig } from "@/config/index.js";
|
|
4
4
|
import { discoverModuleFiles, importFile } from "@/framework/modules/discover.js";
|
|
5
5
|
import { redisClientIfReady } from "@/framework/redis/client.js";
|
|
6
|
+
import { logger } from "@/framework/support/logger.js";
|
|
6
7
|
|
|
7
8
|
export type QueueJob = {
|
|
8
9
|
name: string;
|
|
@@ -10,9 +11,7 @@ export type QueueJob = {
|
|
|
10
11
|
};
|
|
11
12
|
|
|
12
13
|
export type QueueHandler = (job: Job) => Promise<any>;
|
|
13
|
-
|
|
14
14
|
export type DurableQueueHandler = (job: DurableJob, ctx: DurableContext) => Promise<any>;
|
|
15
|
-
|
|
16
15
|
export type AnyQueueHandler = QueueHandler | DurableQueueHandler;
|
|
17
16
|
|
|
18
17
|
const queues = new Map<string, Queue>();
|
|
@@ -48,6 +47,14 @@ function key(queue: string, job: string) {
|
|
|
48
47
|
return `${queue}:${job}`;
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Why: BullMQ manages its own connections. We pass the URL string,
|
|
52
|
+
* not the shared client, so BullMQ doesn't crash if the shared client dies.
|
|
53
|
+
*/
|
|
54
|
+
function connectionOptions() {
|
|
55
|
+
return { url: redisConfig.url };
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
/**
|
|
52
59
|
* Why: Registers a job handler for a queue + job name.
|
|
53
60
|
* When: Module job files are loaded during boot.
|
|
@@ -73,14 +80,14 @@ export function shouldQueue(job: string, queue: string, handler: AnyQueueHandler
|
|
|
73
80
|
* How: Reuses shared Redis connection and memoized queue map.
|
|
74
81
|
*/
|
|
75
82
|
export function getQueue(queue = "default") {
|
|
76
|
-
|
|
77
|
-
if (!
|
|
83
|
+
// Check if Redis is supposed to be enabled
|
|
84
|
+
if (!redisConfig.enabled) return null;
|
|
78
85
|
|
|
79
86
|
if (!queues.has(queue)) {
|
|
80
87
|
queues.set(
|
|
81
88
|
queue,
|
|
82
89
|
new Queue(queue, {
|
|
83
|
-
connection:
|
|
90
|
+
connection: connectionOptions(), // Let BullMQ handle its own connection
|
|
84
91
|
prefix: queuePrefix(),
|
|
85
92
|
defaultJobOptions: {
|
|
86
93
|
attempts: 3,
|
|
@@ -197,15 +204,20 @@ export async function queueJob(
|
|
|
197
204
|
return null;
|
|
198
205
|
}
|
|
199
206
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
try {
|
|
208
|
+
return await queue.add(job, data, {
|
|
209
|
+
delay: options.delay ? options.delay * 1000 : 0,
|
|
210
|
+
attempts: options.attempts ?? 3,
|
|
211
|
+
jobId: options.jobId,
|
|
212
|
+
priority: options.priority,
|
|
213
|
+
removeOnComplete: options.removeOnComplete ?? 1000,
|
|
214
|
+
removeOnFail: options.removeOnFail ?? 5000,
|
|
215
|
+
backoff: options.backoff ?? { type: "exponential", delay: 3000 }
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger.error(`Failed to queue job ${job}`, { error });
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
/**
|
|
@@ -234,7 +246,7 @@ export async function bootQueueJobs() {
|
|
|
234
246
|
* queue's keys on demand if any code path still dispatches to it.
|
|
235
247
|
*/
|
|
236
248
|
export async function pruneStaleQueues(protectedQueues: string[] = []) {
|
|
237
|
-
if (!queueConfig.autoPruneQueues) return;
|
|
249
|
+
if (!queueConfig.autoPruneQueues || !redisConfig.enabled) return;
|
|
238
250
|
|
|
239
251
|
const client = redisClientIfReady();
|
|
240
252
|
if (!client) return;
|
|
@@ -264,13 +276,13 @@ export async function pruneStaleQueues(protectedQueues: string[] = []) {
|
|
|
264
276
|
if (live !== 0) continue;
|
|
265
277
|
|
|
266
278
|
await queue.obliterate({ force: true });
|
|
267
|
-
|
|
279
|
+
logger.info(`Pruned stale queue "${name}"`);
|
|
268
280
|
} catch (error: any) {
|
|
269
|
-
|
|
281
|
+
logger.warn(`Could not prune stale queue "${name}": ${error?.message || error}`);
|
|
270
282
|
}
|
|
271
283
|
}
|
|
272
284
|
} catch (error: any) {
|
|
273
|
-
|
|
285
|
+
logger.warn(`Could not scan queues for stale cleanup: ${error?.message || error}`);
|
|
274
286
|
}
|
|
275
287
|
}
|
|
276
288
|
|
|
@@ -282,23 +294,21 @@ export async function pruneStaleQueues(protectedQueues: string[] = []) {
|
|
|
282
294
|
*/
|
|
283
295
|
export async function startQueueWorker(queueNames = ["default"]) {
|
|
284
296
|
await bootQueueJobs();
|
|
285
|
-
|
|
286
|
-
if (!client) throw new Error("Redis is required for queue workers");
|
|
297
|
+
if (!redisConfig.enabled) throw new Error("Redis is required for queue workers");
|
|
287
298
|
|
|
288
299
|
for (const queueName of queueNames) {
|
|
289
|
-
// Already working this queue in this process: a second worker would add a
|
|
290
|
-
// second blocking connection and a second copy of every log line, and would
|
|
291
|
-
// never process a job the first one was not already getting.
|
|
292
300
|
if (workedQueues.has(queueName)) continue;
|
|
293
301
|
workedQueues.add(queueName);
|
|
294
302
|
|
|
295
303
|
if (!events.has(queueName)) {
|
|
296
|
-
|
|
304
|
+
// Let BullMQ manage its own connection for events
|
|
305
|
+
events.set(queueName, new QueueEvents(queueName, { connection: connectionOptions(), prefix: queuePrefix() }));
|
|
297
306
|
}
|
|
298
307
|
|
|
299
308
|
const durable = [...durableHandlers.keys()].some((k) => k.startsWith(`${queueName}:`));
|
|
300
309
|
|
|
301
310
|
if (durable) {
|
|
311
|
+
// Durable worker still needs a shared client for its state store in this specific lib
|
|
302
312
|
const store = durableStateStoreIfReady();
|
|
303
313
|
if (!store) throw new Error("Redis is required for durable queue workers");
|
|
304
314
|
|
|
@@ -311,20 +321,14 @@ export async function startQueueWorker(queueNames = ["default"]) {
|
|
|
311
321
|
}
|
|
312
322
|
|
|
313
323
|
const worker = new DurableWorker(queueName, map as never, {
|
|
314
|
-
connection:
|
|
324
|
+
connection: connectionOptions(),
|
|
315
325
|
prefix: queuePrefix(),
|
|
316
326
|
stateStore: store,
|
|
317
327
|
concurrency: 10
|
|
318
328
|
});
|
|
319
329
|
|
|
320
|
-
worker.on("completed", (job) => {
|
|
321
|
-
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
worker.on("failed", (job, error) => {
|
|
325
|
-
const jobName = job?.name ?? "unknown";
|
|
326
|
-
console.log(`[${new Date().toLocaleTimeString()}] Failed: ${jobName} (${queueName}) - ${error.message}`);
|
|
327
|
-
});
|
|
330
|
+
worker.on("completed", (job) => logger.info(`Processed: ${job.name} (${queueName})`));
|
|
331
|
+
worker.on("failed", (job, error) => logger.warn(`Failed: ${job?.name ?? "unknown"} (${queueName}) - ${error.message}`));
|
|
328
332
|
|
|
329
333
|
workers.push(worker as unknown as Worker);
|
|
330
334
|
continue;
|
|
@@ -333,22 +337,16 @@ export async function startQueueWorker(queueNames = ["default"]) {
|
|
|
333
337
|
const worker = new Worker(
|
|
334
338
|
queueName,
|
|
335
339
|
async (job) => {
|
|
336
|
-
|
|
340
|
+
logger.info(`Processing: ${job.name} (${queueName})`);
|
|
337
341
|
const handler = handlers.get(key(queueName, job.name));
|
|
338
342
|
if (!handler) throw new Error(`No handler registered for ${queueName}:${job.name}`);
|
|
339
343
|
return await handler(job);
|
|
340
344
|
},
|
|
341
|
-
{ connection:
|
|
345
|
+
{ connection: connectionOptions(), prefix: queuePrefix(), concurrency: 10 }
|
|
342
346
|
);
|
|
343
347
|
|
|
344
|
-
worker.on("completed", (job) => {
|
|
345
|
-
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
worker.on("failed", (job, error) => {
|
|
349
|
-
const jobName = job?.name ?? "unknown";
|
|
350
|
-
console.log(`[${new Date().toLocaleTimeString()}] Failed: ${jobName} (${queueName}) - ${error.message}`);
|
|
351
|
-
});
|
|
348
|
+
worker.on("completed", (job) => logger.info(`Processed: ${job.name} (${queueName})`));
|
|
349
|
+
worker.on("failed", (job, error) => logger.warn(`Failed: ${job?.name ?? "unknown"} (${queueName}) - ${error.message}`));
|
|
352
350
|
|
|
353
351
|
workers.push(worker);
|
|
354
352
|
}
|
|
@@ -364,6 +362,7 @@ export async function startQueueWorker(queueNames = ["default"]) {
|
|
|
364
362
|
* Cache/session/rate-limit keys are intentionally left alone.
|
|
365
363
|
*/
|
|
366
364
|
export async function clearQueue() {
|
|
365
|
+
if (!redisConfig.enabled) return;
|
|
367
366
|
const client = redisClientIfReady();
|
|
368
367
|
if (!client) return;
|
|
369
368
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { env } from "@/env.js";
|
|
2
|
+
import { createCircuitBreaker, isCircuitBreakerError } from "@/framework/circuit-breaker/cb.js";
|
|
2
3
|
import { socketServer } from "@/framework/realtime/socket.js";
|
|
3
4
|
import { redisClientIfReady } from "@/framework/redis/client.js";
|
|
5
|
+
import { logger } from "@/framework/support/logger.js";
|
|
4
6
|
|
|
5
7
|
export type BroadcastOptions = {
|
|
6
8
|
users?: Array<string | number>;
|
|
@@ -10,6 +12,13 @@ export type BroadcastOptions = {
|
|
|
10
12
|
all?: boolean;
|
|
11
13
|
};
|
|
12
14
|
|
|
15
|
+
// Circuit breaker for the pub/sub relay (used by workers that don't have Socket.IO)
|
|
16
|
+
const broadcastBreaker = createCircuitBreaker({
|
|
17
|
+
name: "Realtime Broadcast Relay",
|
|
18
|
+
failureThreshold: 3,
|
|
19
|
+
resetTimeout: 30000
|
|
20
|
+
});
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* Why: Emits realtime events to targeted audiences.
|
|
15
24
|
* When: Domain events need websocket fan-out.
|
|
@@ -29,18 +38,33 @@ function emitViaIo(event: string, payload: any, options: BroadcastOptions) {
|
|
|
29
38
|
for (const room of options.rooms || []) io.to(room).emit(event, payload);
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
export function broadcast(event: string, payload: any, options: BroadcastOptions = {}) {
|
|
41
|
+
export async function broadcast(event: string, payload: any, options: BroadcastOptions = {}) {
|
|
33
42
|
const io = socketServer();
|
|
34
43
|
|
|
44
|
+
// If this process has Socket.IO (the main API server), emit directly
|
|
35
45
|
if (io) {
|
|
36
|
-
|
|
46
|
+
try {
|
|
47
|
+
emitViaIo(event, payload, options);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
logger.error("Failed to broadcast event via Socket.IO", { error, event });
|
|
50
|
+
}
|
|
37
51
|
return;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
//
|
|
54
|
+
// If this is a background worker, relay through Redis
|
|
41
55
|
const redis = redisClientIfReady();
|
|
42
56
|
if (!redis) return;
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
try {
|
|
59
|
+
await broadcastBreaker.exec(async () => {
|
|
60
|
+
const channel = `${env.REDIS_PREFIX}:broadcast`;
|
|
61
|
+
await redis.publish(channel, JSON.stringify({ event, payload, options }));
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (isCircuitBreakerError(error)) {
|
|
65
|
+
logger.warn("Broadcast relay skipped: Circuit breaker is open");
|
|
66
|
+
} else {
|
|
67
|
+
logger.error("Failed to relay broadcast via Redis", { error, event });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
46
70
|
}
|
|
@@ -1,119 +1,149 @@
|
|
|
1
|
-
import { createAdapter } from "@socket.io/redis-adapter";
|
|
2
|
-
import { Server as SocketIOServer } from "socket.io";
|
|
3
|
-
import { appConfig, realtimeConfig, redisConfig } from "@/config/index.js";
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
socket.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
1
|
+
import { createAdapter } from "@socket.io/redis-adapter";
|
|
2
|
+
import { Server as SocketIOServer } from "socket.io";
|
|
3
|
+
import { appConfig, realtimeConfig, redisConfig } from "@/config/index.js";
|
|
4
|
+
import { createCircuitBreaker, isCircuitBreakerError } from "@/framework/circuit-breaker/cb.js";
|
|
5
|
+
import { authFromSocketHandshake, unauthenticatedRealtimeAuth } from "@/framework/realtime/socket-cookie.js";
|
|
6
|
+
import type { RealtimeAuthContext } from "@/framework/realtime/types.js";
|
|
7
|
+
import { redisClientIfReady } from "@/framework/redis/client.js";
|
|
8
|
+
import { logger } from "@/framework/support/logger.js";
|
|
9
|
+
|
|
10
|
+
export const WS_PATH = "/socket.io";
|
|
11
|
+
|
|
12
|
+
// Create a breaker for the Socket.IO Redis adapter
|
|
13
|
+
const socketAdapterBreaker = createCircuitBreaker({
|
|
14
|
+
name: "Socket.IO Redis Adapter",
|
|
15
|
+
failureThreshold: 3,
|
|
16
|
+
resetTimeout: 30000
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type RealtimeServeWebSocket = Record<string, never>;
|
|
20
|
+
|
|
21
|
+
export type RealtimeInit = {
|
|
22
|
+
io: SocketIOServer;
|
|
23
|
+
websocketServe: RealtimeServeWebSocket;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let io: SocketIOServer | null = null;
|
|
27
|
+
let adapterSubClient: ReturnType<typeof redisClientIfReady> | null = null;
|
|
28
|
+
|
|
29
|
+
function socketAllowedOrigins() {
|
|
30
|
+
const origins = [appConfig.url, appConfig.frontendUrl].filter(Boolean) as string[];
|
|
31
|
+
if (realtimeConfig.enabled) origins.push("https://admin.socket.io");
|
|
32
|
+
return [...new Set(origins)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function attachRedisAdapter() {
|
|
36
|
+
if (!redisConfig.enabled) return;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await socketAdapterBreaker.exec(async () => {
|
|
40
|
+
const pubClient = redisClientIfReady();
|
|
41
|
+
if (!pubClient) throw new Error("Redis client not ready");
|
|
42
|
+
|
|
43
|
+
// Socket.IO requires a duplicate connection for subscriptions
|
|
44
|
+
adapterSubClient = pubClient.duplicate();
|
|
45
|
+
await adapterSubClient.connect();
|
|
46
|
+
|
|
47
|
+
io?.adapter(createAdapter(pubClient, adapterSubClient));
|
|
48
|
+
logger.debug("Socket.IO Redis adapter attached successfully");
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isCircuitBreakerError(error)) {
|
|
52
|
+
logger.warn("Socket.IO Redis adapter skipped due to circuit breaker");
|
|
53
|
+
} else {
|
|
54
|
+
logger.error("Failed to attach Socket.IO Redis adapter", { error });
|
|
55
|
+
}
|
|
56
|
+
// Fallback: Socket.IO will automatically use its default in-memory adapter.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function registerAuthAndRooms() {
|
|
61
|
+
io?.use(async (socket, next) => {
|
|
62
|
+
const auth = await authFromSocketHandshake(socket);
|
|
63
|
+
socket.data.auth = auth as RealtimeAuthContext;
|
|
64
|
+
|
|
65
|
+
if (auth.isAuthenticated) {
|
|
66
|
+
socket.join("auth");
|
|
67
|
+
if (auth.userId) socket.join(`user:${auth.userId}`);
|
|
68
|
+
for (const role of auth.roles) socket.join(`role:${role}`);
|
|
69
|
+
} else {
|
|
70
|
+
socket.join("guest");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
next();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
io?.on("connection", (socket) => {
|
|
77
|
+
const auth = (socket.data.auth || unauthenticatedRealtimeAuth()) as RealtimeAuthContext;
|
|
78
|
+
|
|
79
|
+
logger.debug("Socket connected", {
|
|
80
|
+
socketId: socket.id,
|
|
81
|
+
authenticated: auth.isAuthenticated,
|
|
82
|
+
userId: auth.userId
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
socket.on("join", (room) => socket.join(String(room)));
|
|
86
|
+
socket.on("disconnect", (reason) => {
|
|
87
|
+
logger.info("Socket disconnected", {
|
|
88
|
+
socketId: socket.id,
|
|
89
|
+
reason,
|
|
90
|
+
authenticated: auth.isAuthenticated,
|
|
91
|
+
userId: auth.userId
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Why: Boots the singleton Socket.IO server for Express.
|
|
99
|
+
* When: Express HTTP server starts and realtime is enabled.
|
|
100
|
+
* Where: Framework server lifecycle.
|
|
101
|
+
* How: Attaches Socket.IO directly to the Express/Node httpServer.
|
|
102
|
+
*/
|
|
103
|
+
export async function initRealtime(target: { httpServer: any }): Promise<RealtimeInit | null> {
|
|
104
|
+
if (!realtimeConfig.enabled) {
|
|
105
|
+
logger.debug("Socket.IO disabled (disabled in config)");
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (io) return { io, websocketServe: {} };
|
|
110
|
+
|
|
111
|
+
// Express uses the native Node.js httpServer
|
|
112
|
+
io = new SocketIOServer(target.httpServer, {
|
|
113
|
+
path: WS_PATH, // Explicitly set the path to match Hono
|
|
114
|
+
cors: { origin: socketAllowedOrigins(), credentials: true }
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await attachRedisAdapter();
|
|
118
|
+
registerAuthAndRooms();
|
|
119
|
+
|
|
120
|
+
logger.debug(`Socket.IO enabled at ${WS_PATH}`);
|
|
121
|
+
return { io, websocketServe: {} };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function socketServer() {
|
|
125
|
+
return io;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function ioServer() {
|
|
129
|
+
return io;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function closeRealtime() {
|
|
133
|
+
if (!io) return;
|
|
134
|
+
|
|
135
|
+
await new Promise<void>((resolve) => {
|
|
136
|
+
io?.close(() => resolve());
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (adapterSubClient) {
|
|
140
|
+
try {
|
|
141
|
+
await adapterSubClient.quit();
|
|
142
|
+
} catch {
|
|
143
|
+
adapterSubClient.disconnect();
|
|
144
|
+
}
|
|
145
|
+
adapterSubClient = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
io = null;
|
|
149
|
+
}
|