create-nexwire 4.2.4 → 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.
Files changed (113) hide show
  1. package/README.md +20 -8
  2. package/express/.env +1 -1
  3. package/express/.vscode/extensions.json +6 -0
  4. package/express/.vscode/settings.json +37 -26
  5. package/express/biome.json +99 -66
  6. package/express/package.json +31 -29
  7. package/express/src/config/database.ts +48 -29
  8. package/express/src/config/index.ts +83 -83
  9. package/express/src/config/openapi.ts +6 -24
  10. package/express/src/config/queue.ts +30 -30
  11. package/express/src/config/validate.ts +50 -40
  12. package/express/src/database/migrations/sqlite/0000_init.sql +47 -47
  13. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  14. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  15. package/express/src/database/schema.ts +2 -2
  16. package/express/src/framework/cache/cache.ts +40 -8
  17. package/express/src/framework/circuit-breaker/cb.ts +88 -60
  18. package/express/src/framework/database/connection.ts +52 -27
  19. package/express/src/framework/events/dispatcher.ts +61 -49
  20. package/express/src/framework/facade.ts +46 -3
  21. package/express/src/framework/http/app.ts +28 -22
  22. package/express/src/framework/http/logger.ts +27 -16
  23. package/express/src/framework/http/openapi.ts +9 -11
  24. package/express/src/framework/modules/routes.ts +26 -21
  25. package/express/src/framework/notification/channels/broadcast.ts +15 -0
  26. package/express/src/framework/notification/channels/mail.ts +40 -0
  27. package/express/src/framework/notification/contracts.ts +29 -0
  28. package/express/src/framework/notification/index.ts +19 -94
  29. package/express/src/framework/notification/registry.ts +15 -0
  30. package/express/src/framework/providers/adapter.ts +27 -17
  31. package/express/src/framework/queue/queue.ts +43 -44
  32. package/express/src/framework/realtime/broadcast.ts +29 -5
  33. package/express/src/framework/realtime/socket.ts +149 -119
  34. package/express/src/framework/redis/client.ts +148 -151
  35. package/express/src/framework/runtime/server-adapter.ts +39 -34
  36. package/express/src/framework/scheduler/lock.ts +128 -116
  37. package/express/src/framework/server.ts +33 -49
  38. package/express/src/framework/session/session.ts +69 -14
  39. package/express/src/framework/storage/storage.ts +744 -785
  40. package/express/src/framework/support/mail.ts +15 -7
  41. package/express/src/providers/notification.ts +4 -0
  42. package/express/src/resources/src/components/Checkbox.vue +32 -29
  43. package/express/src/resources/src/components/Input.vue +243 -223
  44. package/express/src/resources/src/components/InputGroup.vue +147 -145
  45. package/express/src/resources/src/components/InputPasswordToggle.vue +229 -213
  46. package/express/src/resources/src/components/Modal.vue +71 -55
  47. package/express/src/resources/src/components/Refresh.vue +19 -0
  48. package/express/src/resources/src/helpers/assets.ts +51 -0
  49. package/express/src/resources/src/layouts/Layout/Header.vue +124 -105
  50. package/express/src/resources/src/main.ts +3 -1
  51. package/express/src/resources/src/plugins/gum.ts +1 -1
  52. package/express/src/resources/src/plugins/pulse.ts +157 -147
  53. package/express/src/resources/src/stores/auth.ts +184 -164
  54. package/express/src/resources/vite.config.ts +54 -36
  55. package/express/src/storage/database/nexwire.sqlite +0 -0
  56. package/express/src/storage/logs/app.log +0 -0
  57. package/express/src/storage/logs/fatal.log +0 -0
  58. package/express/src/types/global.d.ts +32 -26
  59. package/hono/.env +1 -1
  60. package/hono/.vscode/extensions.json +6 -0
  61. package/hono/.vscode/settings.json +37 -26
  62. package/hono/biome.json +25 -2
  63. package/hono/package.json +25 -23
  64. package/hono/src/config/database.ts +48 -29
  65. package/hono/src/config/index.ts +83 -83
  66. package/hono/src/config/openapi.ts +6 -24
  67. package/hono/src/config/queue.ts +30 -30
  68. package/hono/src/config/validate.ts +50 -40
  69. package/hono/src/database/migrations/sqlite/0000_init.sql +47 -47
  70. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  71. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  72. package/hono/src/database/schema.ts +2 -2
  73. package/hono/src/framework/cache/cache.ts +40 -8
  74. package/hono/src/framework/circuit-breaker/cb.ts +88 -60
  75. package/hono/src/framework/database/connection.ts +52 -27
  76. package/hono/src/framework/events/dispatcher.ts +61 -49
  77. package/hono/src/framework/facade.ts +38 -1
  78. package/hono/src/framework/http/openapi.ts +7 -9
  79. package/hono/src/framework/notification/channels/broadcast.ts +15 -0
  80. package/hono/src/framework/notification/channels/mail.ts +40 -0
  81. package/hono/src/framework/notification/contracts.ts +29 -0
  82. package/hono/src/framework/notification/index.ts +19 -94
  83. package/hono/src/framework/notification/registry.ts +15 -0
  84. package/hono/src/framework/providers/adapter.ts +27 -17
  85. package/hono/src/framework/queue/queue.ts +43 -44
  86. package/hono/src/framework/realtime/broadcast.ts +29 -5
  87. package/hono/src/framework/realtime/socket.ts +217 -196
  88. package/hono/src/framework/redis/client.ts +148 -151
  89. package/hono/src/framework/scheduler/lock.ts +128 -116
  90. package/hono/src/framework/server.ts +27 -29
  91. package/hono/src/framework/session/session.ts +62 -14
  92. package/hono/src/framework/storage/storage.ts +744 -785
  93. package/hono/src/framework/support/mail.ts +56 -48
  94. package/hono/src/providers/notification.ts +4 -0
  95. package/hono/src/resources/src/components/Checkbox.vue +32 -29
  96. package/hono/src/resources/src/components/Input.vue +243 -234
  97. package/hono/src/resources/src/components/InputGroup.vue +147 -149
  98. package/hono/src/resources/src/components/InputPasswordToggle.vue +229 -222
  99. package/hono/src/resources/src/components/Modal.vue +71 -55
  100. package/hono/src/resources/src/components/Refresh.vue +19 -0
  101. package/hono/src/resources/src/helpers/assets.ts +51 -0
  102. package/hono/src/resources/src/layouts/Layout/Header.vue +124 -105
  103. package/hono/src/resources/src/main.ts +3 -1
  104. package/hono/src/resources/src/plugins/gum.ts +1 -1
  105. package/hono/src/resources/src/plugins/pulse.ts +157 -147
  106. package/hono/src/resources/src/stores/auth.ts +184 -164
  107. package/hono/src/resources/vite.config.ts +54 -36
  108. package/hono/src/storage/database/nexwire.sqlite +0 -0
  109. package/hono/src/storage/logs/app.log +0 -0
  110. package/hono/src/storage/logs/fatal.log +0 -0
  111. package/hono/src/types/global.d.ts +32 -26
  112. package/package.json +2 -2
  113. package/src/index.js +139 -14
@@ -0,0 +1,40 @@
1
+ import { mail } from "@/framework/support/mail.js";
2
+ import { resolveAuthProvider } from "@/framework/providers/adapter.js";
3
+ import { redisClientIfReady } from "@/framework/redis/client.js";
4
+ import { dispatchEvent } from "@/framework/events/dispatcher.js";
5
+ import type { NotificationChannel } from "@/framework/notification/contracts.js";
6
+
7
+ function escapeHtml(str: string) {
8
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9
+ }
10
+
11
+ export const mailChannel: NotificationChannel = {
12
+ name: "mail",
13
+ async send(target, notification, options) {
14
+ if (!options.mail) return false;
15
+
16
+ let to = options.mail.to || null;
17
+ if (!to) {
18
+ try {
19
+ const provider = await resolveAuthProvider();
20
+ if (provider?.getUserContact) {
21
+ const contact = await provider.getUserContact(target.id);
22
+ if (contact?.email) to = contact.email as string;
23
+ }
24
+ } catch { }
25
+ }
26
+
27
+ if (!to) return false;
28
+
29
+ const subject = options.mail.subject || notification.title;
30
+ const html = options.mail.html || `<h2>${escapeHtml(notification.title)}</h2>${notification.body ? `<p>${escapeHtml(notification.body)}</p>` : ""}`;
31
+
32
+ if (redisClientIfReady()) {
33
+ await dispatchEvent("notification:mail", { to, subject, html }, { queue: "mail" });
34
+ } else {
35
+ await mail.sendMail({ to, subject, html });
36
+ }
37
+
38
+ return true;
39
+ }
40
+ };
@@ -0,0 +1,29 @@
1
+ import type { NotifiableTarget, NotificationRecord } from "@/framework/providers/contracts.js";
2
+
3
+ export type NotificationTarget = number | string | NotifiableTarget;
4
+ export type NormalizedTarget = { type: string; id: number | string };
5
+
6
+ export type NotificationOptions = {
7
+ type?: string;
8
+ title: string;
9
+ body?: string;
10
+ link?: string;
11
+ data?: Record<string, unknown>;
12
+
13
+ // Built-in Channels
14
+ broadcast?: boolean;
15
+ mail?: {
16
+ to?: string;
17
+ subject?: string;
18
+ html?: string;
19
+ };
20
+
21
+ // Extensible: Add any custom channel options here (e.g., sms, push, discord)
22
+ sms?: { to?: string; message?: string };
23
+ push?: { token?: string; payload?: any };
24
+ };
25
+
26
+ export interface NotificationChannel {
27
+ name: string;
28
+ send(target: NormalizedTarget, notification: NotificationRecord, options: NotificationOptions): Promise<boolean>;
29
+ }
@@ -1,39 +1,12 @@
1
- import { dispatchEvent } from "@/framework/events/dispatcher.js";
2
- import { resolveAuthProvider, resolveNotificationProvider } from "@/framework/providers/adapter.js";
3
- import type { NotifiableTarget, NotificationRecord } from "@/framework/providers/contracts.js";
4
- import { redisClientIfReady } from "@/framework/redis/client.js";
5
- import { mail } from "@/framework/support/mail.js";
1
+ import type { NormalizedTarget, NotificationOptions, NotificationTarget } from "@/framework/notification/contracts.js";
2
+ import { getChannels } from "@/framework/notification/registry.js";
3
+ import { resolveNotificationProvider } from "@/framework/providers/adapter.js";
4
+ import type { NotificationRecord } from "@/framework/providers/contracts.js";
6
5
 
7
6
  let missingProviderWarned = false;
8
7
 
9
- /** A numeric id (default auth), a string id (UUID/ULID), or an explicit target. */
10
- export type NotificationTarget = number | string | NotifiableTarget;
11
-
12
- /** Extra JSON metadata attached to the notification (e.g. entity IDs, links). */
13
- export type NotificationOptions = {
14
- /** Notification category key (default: "info") */
15
- type?: string;
16
- /** Notification title */
17
- title: string;
18
- /** Notification body text */
19
- body?: string;
20
- /** Optional click-through URL */
21
- link?: string;
22
- /** Arbitrary JSON payload stored in `data` column */
23
- data?: Record<string, unknown>;
24
- /** Whether to broadcast the notification to the user's socket in realtime */
25
- broadcast?: boolean;
26
- /** Email delivery options */
27
- mail?: {
28
- /** Explicit recipient. Skips the auth provider contact lookup. */
29
- to?: string;
30
- subject?: string;
31
- html?: string;
32
- };
33
- };
34
-
35
8
  /** Turn the public target argument into the polymorphic owner pair. */
36
- function normalizeTarget(target: NotificationTarget): Required<Pick<NotifiableTarget, "id">> & { type: string } {
9
+ function normalizeTarget(target: NotificationTarget): NormalizedTarget {
37
10
  if (target !== null && typeof target === "object") {
38
11
  return {
39
12
  type: String(target.type || "user").trim() || "user",
@@ -44,13 +17,10 @@ function normalizeTarget(target: NotificationTarget): Required<Pick<NotifiableTa
44
17
  }
45
18
 
46
19
  /**
47
- * Why: Persists a notification through the registered provider.
48
- * When: A feature wants to record something for a user (or another notifiable).
20
+ * Why: Persists a notification to the database and triggers all active channels.
21
+ * When: A feature wants to record something for a user.
49
22
  * Where: Application code, through the framework facade.
50
- * How: The provider owns storage; broadcasting and email stay here so a custom
51
- * store cannot silently disable them. A missing provider is reported once
52
- * and returns `null` instead of throwing, matching the framework's
53
- * "optional module" behaviour.
23
+ * How: The provider owns database storage. The channels handle delivery (mail, sms, ws).
54
24
  */
55
25
  export async function notify(target: NotificationTarget, options: NotificationOptions): Promise<NotificationRecord | null> {
56
26
  const provider = await resolveNotificationProvider();
@@ -65,6 +35,8 @@ export async function notify(target: NotificationTarget, options: NotificationOp
65
35
  }
66
36
 
67
37
  const normalizedTarget = normalizeTarget(target);
38
+
39
+ // 1. Save to Database via the Provider
68
40
  const normalized = await provider.create(normalizedTarget, {
69
41
  type: options.type,
70
42
  title: options.title,
@@ -73,62 +45,15 @@ export async function notify(target: NotificationTarget, options: NotificationOp
73
45
  data: options.data ?? null
74
46
  });
75
47
 
76
- if (options.broadcast) {
77
- await dispatchEvent("notification.created", normalized, {
78
- broadcast: { users: [normalizedTarget.id] }
79
- });
80
- }
81
-
82
- if (options.mail) {
83
- await sendMailChannel(normalizedTarget, normalized, options);
84
- }
48
+ // 2. Dispatch to all registered channels in parallel
49
+ const channels = getChannels();
50
+ await Promise.allSettled(
51
+ channels.map((channel) =>
52
+ channel.send(normalizedTarget, normalized, options).catch((err) => {
53
+ console.error(`[notification] Channel ${channel.name} failed:`, err);
54
+ })
55
+ )
56
+ );
85
57
 
86
58
  return normalized;
87
59
  }
88
-
89
- /** Deliver an email notification, either queued (if Redis is available) or direct. */
90
- async function sendMailChannel(
91
- target: { type: string; id: number | string },
92
- notification: NotificationRecord,
93
- options: NotificationOptions
94
- ) {
95
- let to: string | null = options.mail?.to || null;
96
-
97
- if (!to) {
98
- try {
99
- const provider = await resolveAuthProvider();
100
- if (provider?.getUserContact) {
101
- const contact = await provider.getUserContact(target.id);
102
- if (contact?.email) to = contact.email as string;
103
- }
104
- } catch {}
105
- }
106
-
107
- if (!to) return;
108
-
109
- const subject = options.mail?.subject || notification.title;
110
- const html = options.mail?.html || defaultMailHtml(notification.title, notification.body);
111
-
112
- if (redisClientIfReady()) {
113
- await dispatchEvent(
114
- "notification:mail",
115
- {
116
- to,
117
- subject,
118
- html
119
- },
120
- { queue: "mail" }
121
- );
122
- } else {
123
- await mail.sendMail({ to, subject, html });
124
- }
125
- }
126
-
127
- /** Generate a basic HTML email body from title and body text. */
128
- function defaultMailHtml(title: string, body: string | null) {
129
- return `<h2>${escapeHtml(title)}</h2>${body ? `<p>${escapeHtml(body)}</p>` : ""}`;
130
- }
131
-
132
- function escapeHtml(str: string) {
133
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
134
- }
@@ -0,0 +1,15 @@
1
+ import type { NotificationChannel } from "@/framework/notification/contracts.js";
2
+
3
+ const channels = new Map<string, NotificationChannel>();
4
+
5
+ export function registerChannel(channel: NotificationChannel) {
6
+ channels.set(channel.name, channel);
7
+ }
8
+
9
+ export function getChannels(): NotificationChannel[] {
10
+ return Array.from(channels.values());
11
+ }
12
+
13
+ export function clearChannels() {
14
+ channels.clear();
15
+ }
@@ -18,6 +18,7 @@ async function loadDefaultProvider(): Promise<AuthProvider | null> {
18
18
  if (defaultProvider) return defaultProvider;
19
19
  if (defaultLoadAttempted) return null;
20
20
  defaultLoadAttempted = true;
21
+
21
22
  try {
22
23
  const userMod = await import("@/modules/auth/database/models/user.js").catch(() => null);
23
24
  if (!userMod?.users || !userMod?.refreshTokens) return null;
@@ -25,22 +26,28 @@ async function loadDefaultProvider(): Promise<AuthProvider | null> {
25
26
  const users = userMod.users;
26
27
  const refreshTokens = userMod.refreshTokens;
27
28
 
28
- defaultProvider = {
29
- async getUserById(id: number | string) {
30
- try {
31
- const user = await (db.query.users.findFirst as any)?.({
32
- where: eq(users.id, id as any),
33
- with: { role: true }
34
- });
35
- if (user) return user;
36
- } catch {}
37
- try {
38
- const rows = await db.select().from(users).where(eq(users.id, id as any));
39
- return rows?.[0] ?? null;
40
- } catch {
41
- return null;
42
- }
43
- },
29
+ // Define the core logic as standalone functions to avoid `this` context issues
30
+ const getUserById = async (id: number | string) => {
31
+ try {
32
+ const user = await (db.query.users.findFirst as any)?.({
33
+ where: eq(users.id, id as any),
34
+ with: { role: true }
35
+ });
36
+ if (user) return user;
37
+ } catch {}
38
+ try {
39
+ const rows = await db
40
+ .select()
41
+ .from(users)
42
+ .where(eq(users.id, id as any));
43
+ return rows?.[0] ?? null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ };
48
+
49
+ const provider: AuthProvider = {
50
+ getUserById,
44
51
  async findRefreshToken(jti: string) {
45
52
  try {
46
53
  const token = await (db.query.refreshTokens.findFirst as any)?.({
@@ -89,7 +96,8 @@ async function loadDefaultProvider(): Promise<AuthProvider | null> {
89
96
  },
90
97
  async getUserContact(userId: number | string) {
91
98
  try {
92
- const user = await this.getUserById(userId);
99
+ // Use the standalone function instead of `this.getUserById`
100
+ const user = await getUserById(userId);
93
101
  if (!user) return null;
94
102
  return {
95
103
  email: user.email ?? null,
@@ -101,6 +109,8 @@ async function loadDefaultProvider(): Promise<AuthProvider | null> {
101
109
  }
102
110
  }
103
111
  };
112
+
113
+ defaultProvider = provider;
104
114
  return defaultProvider;
105
115
  } catch {
106
116
  return null;
@@ -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
- const client = redisClientIfReady();
77
- if (!client) return null;
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: client as any,
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
- return await queue.add(job, data, {
201
- delay: options.delay ? options.delay * 1000 : 0,
202
- attempts: options.attempts ?? 3,
203
- jobId: options.jobId,
204
- priority: options.priority,
205
- removeOnComplete: options.removeOnComplete ?? 1000,
206
- removeOnFail: options.removeOnFail ?? 5000,
207
- backoff: options.backoff ?? { type: "exponential", delay: 3000 }
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
- console.log(`Pruned stale queue "${name}"`);
279
+ logger.info(`Pruned stale queue "${name}"`);
268
280
  } catch (error: any) {
269
- console.warn(`Could not prune stale queue "${name}": ${error?.message || error}`);
281
+ logger.warn(`Could not prune stale queue "${name}": ${error?.message || error}`);
270
282
  }
271
283
  }
272
284
  } catch (error: any) {
273
- console.warn(`Could not scan queues for stale cleanup: ${error?.message || error}`);
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
- const client = redisClientIfReady();
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
- events.set(queueName, new QueueEvents(queueName, { connection: client as any, prefix: queuePrefix() }));
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: client as any,
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
- console.log(`[${new Date().toLocaleTimeString()}] Processed: ${job.name} (${queueName})`);
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
- console.log(`[${new Date().toLocaleTimeString()}] Processing: ${job.name} (${queueName})`);
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: client as any, prefix: queuePrefix(), concurrency: 10 }
345
+ { connection: connectionOptions(), prefix: queuePrefix(), concurrency: 10 }
342
346
  );
343
347
 
344
- worker.on("completed", (job) => {
345
- console.log(`[${new Date().toLocaleTimeString()}] Processed: ${job.name} (${queueName})`);
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
- emitViaIo(event, payload, options);
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
- // No Socket.IO in this process — relay through Redis so the main process can broadcast
54
+ // If this is a background worker, relay through Redis
41
55
  const redis = redisClientIfReady();
42
56
  if (!redis) return;
43
57
 
44
- const channel = `${env.REDIS_PREFIX}:broadcast`;
45
- redis.publish(channel, JSON.stringify({ event, payload, options }));
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
  }