Fix realtime notifications: event bus wasn't a real singleton in production

Root cause of "no toast, no sound, no live updates" reported in
production: the ticket-event EventEmitter was only stashed on globalThis
in development (to survive Next.js's dev-mode hot reload). The comment
explaining that assumed production only loads the module once — true for
a simple Node require() cache, but Turbopack's route-level bundling can
inline this small shared module separately into each route's own output
chunk instead of pointing them at one shared instance. So /api/events
(the SSE endpoint) and whatever publishes an event (ticket creation,
mail ingestion, agent replies, ...) were each getting their own private,
disconnected EventEmitter — the SSE connection itself worked fine
(heartbeats arrived reliably, confirmed via the browser's Network tab),
but no actual ticket.created/message.created event ever reached a
subscriber. Now globalThis is always used as the singleton home,
regardless of NODE_ENV — verified locally end-to-end: streamed
/api/events with curl while creating a ticket via the API, and the
ticket.created + message.created events arrived on the stream
immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-07 12:01:56 +00:00
1 parent 681f89000f
commit 4424883496
1 file changed
+11 -6
+11 -6
View File
@@ -6,16 +6,21 @@ export type TicketEvent =
| { type: "ticket.updated"; ticket: TicketDTO }
| { type: "message.created"; ticketId: string; customerId: string; assigneeId: string | null; message: MessageDTO };
// Next.js dev mode reloads this module on every edit — stash the emitter on
// globalThis so hot reload doesn't spawn a second, disconnected bus.
// Stashed on globalThis rather than relying on Node's module cache to give
// us a singleton: Next.js dev mode reloads this module on every edit (hot
// reload would otherwise spawn a second, disconnected bus), and — the
// production-breaking case — Turbopack's route-level bundling can inline
// this small module separately into each route's own chunk instead of
// sharing one instance, so /api/events (the SSE subscriber) and whatever
// publishes an event (ticket creation, mail ingestion, ...) can each end up
// holding their own private EventEmitter that never sees the other's
// events. globalThis is the one thing guaranteed to be the same object
// across every chunk in the process, in both dev and production.
const globalForBus = globalThis as unknown as { ticketEventBus?: EventEmitter };
const emitter = globalForBus.ticketEventBus ?? new EventEmitter();
emitter.setMaxListeners(200); // one per open SSE connection
if (process.env.NODE_ENV !== "production") {
globalForBus.ticketEventBus = emitter;
}
globalForBus.ticketEventBus = emitter;
const CHANNEL = "ticket-event";