@open-mercato/events 0.4.5-develop-6bdcebbece → 0.4.5-develop-0c30cb4b11

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/AGENTS.md CHANGED
@@ -90,3 +90,54 @@ Workers in `modules/events/workers/` handle async event processing. Follow the s
90
90
  - **Declaring events in a module**: `packages/core/AGENTS.md` → Events
91
91
  - **Adding subscribers in a module**: `packages/core/AGENTS.md` → Events → Event Subscribers
92
92
  - **Queue worker contract**: `packages/queue/AGENTS.md`
93
+
94
+ ## DOM Event Bridge (SSE)
95
+
96
+ The DOM Event Bridge streams server-side events to the browser via Server-Sent Events (SSE).
97
+
98
+ ### How It Works
99
+
100
+ 1. Module declares events with `clientBroadcast: true` in `events.ts`
101
+ 2. SSE endpoint at `/api/events/stream` subscribes to the event bus
102
+ 3. Client-side `eventBridge.ts` connects via `EventSource` with auto-reconnect
103
+ 4. Events are dispatched as `om:event` CustomEvents on `window`
104
+ 5. Widgets/components listen via `useAppEvent(pattern, handler)` hook
105
+
106
+ ### Enabling Broadcast on Events
107
+
108
+ In your module's `events.ts`:
109
+ ```typescript
110
+ const events = [
111
+ { id: 'mymod.entity.created', label: 'Created', category: 'crud', clientBroadcast: true },
112
+ ] as const
113
+ ```
114
+
115
+ ### Consuming Events in Components
116
+
117
+ ```typescript
118
+ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
119
+
120
+ // Wildcard: listen to all events from a module
121
+ useAppEvent('mymod.*', (event) => {
122
+ console.log(event.id, event.payload)
123
+ }, [])
124
+
125
+ // Exact match
126
+ useAppEvent('mymod.entity.created', (event) => {
127
+ // refresh data
128
+ }, [])
129
+ ```
130
+
131
+ ### Key Rules
132
+
133
+ - Events are server-filtered by audience before SSE send:
134
+ - Tenant: `tenantId` must match
135
+ - Organization: `organizationId` or `organizationIds` must match selected organization
136
+ - Recipient user: `recipientUserId` or `recipientUserIds` must include connection user
137
+ - Recipient role: `recipientRoleId` or `recipientRoleIds` must intersect connection roles
138
+ - Missing `tenantId` in event payload means no delivery
139
+ - SSE sends heartbeats every 30s; client auto-reconnects if no heartbeat within 45s
140
+ - Max payload size is 4096 bytes per event
141
+ - Client deduplicates events within a 500ms window
142
+ - `isBroadcastEvent(eventId)` checks if an event has `clientBroadcast: true`
143
+ - The `useEventBridge()` hook must be mounted once in the app shell to start receiving events
package/dist/bus.js CHANGED
@@ -1,6 +1,23 @@
1
1
  import { createQueue } from "@open-mercato/queue";
2
2
  import { getRedisUrl } from "@open-mercato/shared/lib/redis/connection";
3
3
  const EVENTS_QUEUE_NAME = "events";
4
+ const GLOBAL_EVENT_TAPS_KEY = "__openMercatoEventBusGlobalTaps__";
5
+ function getGlobalEventTaps() {
6
+ const existing = globalThis[GLOBAL_EVENT_TAPS_KEY];
7
+ if (existing instanceof Set) {
8
+ return existing;
9
+ }
10
+ const created = /* @__PURE__ */ new Set();
11
+ globalThis[GLOBAL_EVENT_TAPS_KEY] = created;
12
+ return created;
13
+ }
14
+ function registerGlobalEventTap(handler) {
15
+ const taps = getGlobalEventTaps();
16
+ taps.add(handler);
17
+ return () => {
18
+ taps.delete(handler);
19
+ };
20
+ }
4
21
  function matchEventPattern(eventName, pattern) {
5
22
  if (pattern === "*") return true;
6
23
  if (pattern === eventName) return true;
@@ -53,6 +70,14 @@ function createEventBus(opts) {
53
70
  }
54
71
  }
55
72
  async function emit(event, payload, options) {
73
+ const taps = getGlobalEventTaps();
74
+ for (const tap of taps) {
75
+ try {
76
+ await Promise.resolve(tap(event, payload, options));
77
+ } catch (error) {
78
+ console.error(`[events] Global tap error for "${event}":`, error);
79
+ }
80
+ }
56
81
  await deliver(event, payload);
57
82
  if (options?.persistent) {
58
83
  const q = getQueue();
@@ -74,6 +99,7 @@ function createEventBus(opts) {
74
99
  };
75
100
  }
76
101
  export {
77
- createEventBus
102
+ createEventBus,
103
+ registerGlobalEventTap
78
104
  };
79
105
  //# sourceMappingURL=bus.js.map
package/dist/bus.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/bus.ts"],
4
- "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { getRedisUrl } from '@open-mercato/shared/lib/redis/connection'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\n/**\n * Match an event name against a pattern.\n *\n * Supports:\n * - Exact match: `customers.people.created`\n * - Wildcard `*` matches single segment: `customers.*` matches `customers.people` but not `customers.people.created`\n * - Global wildcard: `*` alone matches all events\n *\n * @param eventName - The actual event name\n * @param pattern - The pattern to match against\n * @returns True if the event matches the pattern\n */\nfunction matchEventPattern(eventName: string, pattern: string): boolean {\n // Global wildcard matches all events\n if (pattern === '*') return true\n\n // Exact match\n if (pattern === eventName) return true\n\n // No wildcards in pattern means we need exact match, which already failed\n if (!pattern.includes('*')) return false\n\n // Convert pattern to regex:\n // - Escape regex special chars (except *)\n // - Replace * with [^.]+ (match one or more non-dot chars)\n const regexPattern = pattern\n .replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&')\n .replace(/\\*/g, '[^.]+')\n const regex = new RegExp(`^${regexPattern}$`)\n return regex.test(eventName)\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<SubscriberHandler>>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n */\n function getQueue(): Queue<EventJobData> {\n if (!queue) {\n if (queueStrategy === 'async') {\n queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrl('QUEUE') }\n })\n } else {\n queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n }\n return queue\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n */\n async function deliver(event: string, payload: EventPayload): Promise<void> {\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const handler of handlers) {\n try {\n // Pass eventName in context for wildcard handlers\n await Promise.resolve(handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n }))\n } catch (error) {\n console.error(`[events] Handler error for \"${event}\" (pattern: \"${pattern}\"):`, error)\n }\n }\n }\n }\n\n /**\n * Registers a handler for an event.\n */\n function on(event: string, handler: SubscriberHandler): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n listeners.get(event)!.add(handler)\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler)\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n // Always deliver to in-memory handlers first\n await deliver(event, payload)\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({ event, payload })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,mBAAmB;AAE5B,SAAS,mBAAmB;AAW5B,MAAM,oBAAoB;AAc1B,SAAS,kBAAkB,WAAmB,SAA0B;AAEtE,MAAI,YAAY,IAAK,QAAO;AAG5B,MAAI,YAAY,UAAW,QAAO;AAGlC,MAAI,CAAC,QAAQ,SAAS,GAAG,EAAG,QAAO;AAKnC,QAAM,eAAe,QAClB,QAAQ,qBAAqB,MAAM,EACnC,QAAQ,OAAO,OAAO;AACzB,QAAM,QAAQ,IAAI,OAAO,IAAI,YAAY,GAAG;AAC5C,SAAO,MAAM,KAAK,SAAS;AAC7B;AAsCO,SAAS,eAAe,MAAkC;AAE/D,QAAM,YAAY,oBAAI,IAAoC;AAG1D,QAAM,gBAAgB,KAAK,kBACxB,QAAQ,IAAI,mBAAmB,UAAU,UAAU;AAGtD,MAAI,QAAoC;AAKxC,WAAS,WAAgC;AACvC,QAAI,CAAC,OAAO;AACV,UAAI,kBAAkB,SAAS;AAC7B,gBAAQ,YAA0B,mBAAmB,SAAS;AAAA,UAC5D,YAAY,EAAE,KAAK,YAAY,OAAO,EAAE;AAAA,QAC1C,CAAC;AAAA,MACH,OAAO;AACL,gBAAQ,YAA0B,mBAAmB,OAAO;AAAA,MAC9D;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAMA,iBAAe,QAAQ,OAAe,SAAsC;AAE1E,eAAW,CAAC,SAAS,QAAQ,KAAK,WAAW;AAC3C,UAAI,CAAC,kBAAkB,OAAO,OAAO,EAAG;AACxC,UAAI,CAAC,YAAY,SAAS,SAAS,EAAG;AAEtC,iBAAW,WAAW,UAAU;AAC9B,YAAI;AAEF,gBAAM,QAAQ,QAAQ,QAAQ,SAAS;AAAA,YACrC,SAAS,KAAK;AAAA,YACd,WAAW;AAAA,UACb,CAAC,CAAC;AAAA,QACJ,SAAS,OAAO;AACd,kBAAQ,MAAM,+BAA+B,KAAK,gBAAgB,OAAO,OAAO,KAAK;AAAA,QACvF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAKA,WAAS,GAAG,OAAe,SAAkC;AAC3D,QAAI,CAAC,UAAU,IAAI,KAAK,GAAG;AACzB,gBAAU,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IAChC;AACA,cAAU,IAAI,KAAK,EAAG,IAAI,OAAO;AAAA,EACnC;AAKA,WAAS,0BAA0B,MAAoC;AACrE,eAAW,OAAO,MAAM;AACtB,SAAG,IAAI,OAAO,IAAI,OAAO;AAAA,IAC3B;AAAA,EACF;AAOA,iBAAe,KACb,OACA,SACA,SACe;AAEf,UAAM,QAAQ,OAAO,OAAO;AAG5B,QAAI,SAAS,YAAY;AACvB,YAAM,IAAI,SAAS;AACnB,YAAM,EAAE,QAAQ,EAAE,OAAO,QAAQ,CAAC;AAAA,IACpC;AAAA,EACF;AAKA,iBAAe,aAA2C;AACxD,UAAM,IAAI,SAAS;AACnB,WAAO,EAAE,MAAM;AAAA,EACjB;AAGA,QAAM,YAAY;AAElB,SAAO;AAAA,IACL;AAAA,IACA;AAAA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { getRedisUrl } from '@open-mercato/shared/lib/redis/connection'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/**\n * Match an event name against a pattern.\n *\n * Supports:\n * - Exact match: `customers.people.created`\n * - Wildcard `*` matches single segment: `customers.*` matches `customers.people` but not `customers.people.created`\n * - Global wildcard: `*` alone matches all events\n *\n * @param eventName - The actual event name\n * @param pattern - The pattern to match against\n * @returns True if the event matches the pattern\n */\nfunction matchEventPattern(eventName: string, pattern: string): boolean {\n // Global wildcard matches all events\n if (pattern === '*') return true\n\n // Exact match\n if (pattern === eventName) return true\n\n // No wildcards in pattern means we need exact match, which already failed\n if (!pattern.includes('*')) return false\n\n // Convert pattern to regex:\n // - Escape regex special chars (except *)\n // - Replace * with [^.]+ (match one or more non-dot chars)\n const regexPattern = pattern\n .replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&')\n .replace(/\\*/g, '[^.]+')\n const regex = new RegExp(`^${regexPattern}$`)\n return regex.test(eventName)\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<SubscriberHandler>>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n */\n function getQueue(): Queue<EventJobData> {\n if (!queue) {\n if (queueStrategy === 'async') {\n queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrl('QUEUE') }\n })\n } else {\n queue = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n }\n return queue\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n */\n async function deliver(event: string, payload: EventPayload): Promise<void> {\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const handler of handlers) {\n try {\n // Pass eventName in context for wildcard handlers\n await Promise.resolve(handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n }))\n } catch (error) {\n console.error(`[events] Handler error for \"${event}\" (pattern: \"${pattern}\"):`, error)\n }\n }\n }\n }\n\n /**\n * Registers a handler for an event.\n */\n function on(event: string, handler: SubscriberHandler): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n listeners.get(event)!.add(handler)\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler)\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n console.error(`[events] Global tap error for \"${event}\":`, error)\n }\n }\n\n // Always deliver to in-memory handlers first\n await deliver(event, payload)\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({ event, payload })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,214 @@
1
+ import { resolveRequestContext } from "@open-mercato/shared/lib/api/context";
2
+ import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
3
+ import { registerGlobalEventTap } from "../../../../bus.js";
4
+ const metadata = {
5
+ GET: { requireAuth: true }
6
+ };
7
+ const HEARTBEAT_INTERVAL_MS = 3e4;
8
+ const MAX_PAYLOAD_BYTES = 4096;
9
+ function collectStringValues(input) {
10
+ if (!Array.isArray(input)) return [];
11
+ const values = [];
12
+ for (const value of input) {
13
+ if (typeof value !== "string") continue;
14
+ const trimmed = value.trim();
15
+ if (!trimmed) continue;
16
+ values.push(trimmed);
17
+ }
18
+ return values;
19
+ }
20
+ function normalizeAudience(data) {
21
+ const tenantId = typeof data.tenantId === "string" ? data.tenantId : null;
22
+ const organizationScopes = /* @__PURE__ */ new Set();
23
+ if (typeof data.organizationId === "string" && data.organizationId.trim().length > 0) {
24
+ organizationScopes.add(data.organizationId.trim());
25
+ }
26
+ for (const organizationId of collectStringValues(data.organizationIds)) {
27
+ organizationScopes.add(organizationId);
28
+ }
29
+ const recipientUserScopes = /* @__PURE__ */ new Set();
30
+ if (typeof data.recipientUserId === "string" && data.recipientUserId.trim().length > 0) {
31
+ recipientUserScopes.add(data.recipientUserId.trim());
32
+ }
33
+ for (const userId of collectStringValues(data.recipientUserIds)) {
34
+ recipientUserScopes.add(userId);
35
+ }
36
+ const recipientRoleScopes = /* @__PURE__ */ new Set();
37
+ if (typeof data.recipientRoleId === "string" && data.recipientRoleId.trim().length > 0) {
38
+ recipientRoleScopes.add(data.recipientRoleId.trim());
39
+ }
40
+ for (const roleId of collectStringValues(data.recipientRoleIds)) {
41
+ recipientRoleScopes.add(roleId);
42
+ }
43
+ return {
44
+ tenantId,
45
+ organizationScopes: Array.from(organizationScopes),
46
+ recipientUserScopes: Array.from(recipientUserScopes),
47
+ recipientRoleScopes: Array.from(recipientRoleScopes)
48
+ };
49
+ }
50
+ function matchesAudience(conn, audience) {
51
+ if (!audience.tenantId) return false;
52
+ if (conn.tenantId !== audience.tenantId) return false;
53
+ if (audience.organizationScopes.length > 0) {
54
+ if (!conn.organizationId) return false;
55
+ if (!audience.organizationScopes.includes(conn.organizationId)) return false;
56
+ }
57
+ if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {
58
+ return false;
59
+ }
60
+ if (audience.recipientRoleScopes.length > 0) {
61
+ const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId));
62
+ if (!roleMatched) return false;
63
+ }
64
+ return true;
65
+ }
66
+ const connections = /* @__PURE__ */ new Set();
67
+ let globalTapRegistered = false;
68
+ function buildSsePayload(eventName, data, organizationId) {
69
+ return JSON.stringify({
70
+ id: eventName,
71
+ payload: data,
72
+ timestamp: Date.now(),
73
+ organizationId
74
+ });
75
+ }
76
+ function buildTruncatedPayload(eventName, data, organizationId) {
77
+ const entityRef = {
78
+ truncated: true
79
+ };
80
+ if (typeof data.id === "string" && data.id.trim().length > 0) {
81
+ entityRef.id = data.id.trim();
82
+ }
83
+ if (typeof data.entityId === "string" && data.entityId.trim().length > 0) {
84
+ entityRef.entityId = data.entityId.trim();
85
+ }
86
+ if (typeof data.entityType === "string" && data.entityType.trim().length > 0) {
87
+ entityRef.entityType = data.entityType.trim();
88
+ }
89
+ return JSON.stringify({
90
+ id: eventName,
91
+ payload: entityRef,
92
+ timestamp: Date.now(),
93
+ organizationId
94
+ });
95
+ }
96
+ function ensureGlobalTapSubscription() {
97
+ if (globalTapRegistered) return;
98
+ globalTapRegistered = true;
99
+ registerGlobalEventTap(async (eventName, payload) => {
100
+ if (!eventName || connections.size === 0) return;
101
+ if (!isBroadcastEvent(eventName)) return;
102
+ const data = payload ?? {};
103
+ const audience = normalizeAudience(data);
104
+ const organizationId = audience.organizationScopes[0] ?? "";
105
+ let ssePayload = buildSsePayload(eventName, data, organizationId);
106
+ if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
107
+ ssePayload = buildTruncatedPayload(eventName, data, organizationId);
108
+ if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
109
+ console.warn(`[events:stream] Event ${eventName} payload exceeds ${MAX_PAYLOAD_BYTES} bytes, skipping`);
110
+ return;
111
+ }
112
+ }
113
+ for (const conn of connections) {
114
+ if (!matchesAudience(conn, audience)) continue;
115
+ try {
116
+ conn.send(ssePayload);
117
+ } catch {
118
+ }
119
+ }
120
+ });
121
+ }
122
+ async function GET(req) {
123
+ const { ctx } = await resolveRequestContext(req);
124
+ if (!ctx.auth?.tenantId || !ctx.auth?.sub) {
125
+ return new Response("Unauthorized", { status: 401 });
126
+ }
127
+ const tenantId = ctx.auth.tenantId;
128
+ const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null;
129
+ const userId = ctx.auth.sub;
130
+ const roleIds = Array.isArray(ctx.auth.roles) ? ctx.auth.roles.filter((role) => typeof role === "string" && role.trim().length > 0) : [];
131
+ ensureGlobalTapSubscription();
132
+ const encoder = new TextEncoder();
133
+ let heartbeatTimer = null;
134
+ let connection = null;
135
+ const stream = new ReadableStream({
136
+ start(controller) {
137
+ const send = (data) => {
138
+ controller.enqueue(encoder.encode(`data: ${data}
139
+
140
+ `));
141
+ };
142
+ connection = {
143
+ tenantId,
144
+ organizationId,
145
+ userId,
146
+ roleIds,
147
+ send,
148
+ close: () => controller.close()
149
+ };
150
+ connections.add(connection);
151
+ heartbeatTimer = setInterval(() => {
152
+ try {
153
+ controller.enqueue(encoder.encode(":heartbeat\n\n"));
154
+ } catch {
155
+ }
156
+ }, HEARTBEAT_INTERVAL_MS);
157
+ },
158
+ cancel() {
159
+ cleanup();
160
+ }
161
+ });
162
+ function cleanup() {
163
+ if (heartbeatTimer) {
164
+ clearInterval(heartbeatTimer);
165
+ heartbeatTimer = null;
166
+ }
167
+ if (connection) {
168
+ connections.delete(connection);
169
+ connection = null;
170
+ }
171
+ }
172
+ req.signal.addEventListener("abort", cleanup);
173
+ return new Response(stream, {
174
+ status: 200,
175
+ headers: {
176
+ "Content-Type": "text/event-stream",
177
+ "Cache-Control": "no-cache, no-transform",
178
+ "Connection": "keep-alive",
179
+ "X-Accel-Buffering": "no"
180
+ }
181
+ });
182
+ }
183
+ const openApi = {
184
+ GET: {
185
+ summary: "Subscribe to server events via SSE (DOM Event Bridge)",
186
+ description: "Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.",
187
+ tags: ["Events"],
188
+ responses: {
189
+ 200: {
190
+ description: "Event stream (text/event-stream)",
191
+ content: {
192
+ "text/event-stream": {
193
+ schema: {
194
+ type: "object",
195
+ properties: {
196
+ id: { type: "string", description: "Event identifier (e.g., example.todo.created)" },
197
+ payload: { type: "object", description: "Event-specific data" },
198
+ timestamp: { type: "number", description: "Server timestamp (ms since epoch)" },
199
+ organizationId: { type: "string", description: "Organization scope" }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ },
205
+ 401: { description: "Not authenticated" }
206
+ }
207
+ }
208
+ };
209
+ export {
210
+ GET,
211
+ metadata,
212
+ openApi
213
+ };
214
+ //# sourceMappingURL=route.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/events/api/stream/route.ts"],
4
+ "sourcesContent": ["/**\n * SSE Event Stream \u2014 DOM Event Bridge\n *\n * Server-Sent Events endpoint that bridges server-side events to the browser.\n * Only events with `clientBroadcast: true` in their EventDefinition are sent.\n * Events are scoped to the authenticated user's tenant.\n *\n * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`\n */\n\nimport { resolveRequestContext } from '@open-mercato/shared/lib/api/context'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport { registerGlobalEventTap } from '../../../../bus'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\ntype SseConnection = {\n tenantId: string\n organizationId: string | null\n userId: string\n roleIds: string[]\n send: (data: string) => void\n close: () => void\n}\n\nfunction collectStringValues(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\nfunction normalizeAudience(data: Record<string, unknown>): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null\n const organizationScopes = new Set<string>()\n if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {\n organizationScopes.add(data.organizationId.trim())\n }\n for (const organizationId of collectStringValues(data.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n\n const recipientUserScopes = new Set<string>()\n if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {\n recipientUserScopes.add(data.recipientUserId.trim())\n }\n for (const userId of collectStringValues(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {\n recipientRoleScopes.add(data.recipientRoleId.trim())\n }\n for (const roleId of collectStringValues(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return {\n tenantId,\n organizationScopes: Array.from(organizationScopes),\n recipientUserScopes: Array.from(recipientUserScopes),\n recipientRoleScopes: Array.from(recipientRoleScopes),\n }\n}\n\nfunction matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {\n if (!audience.tenantId) return false\n if (conn.tenantId !== audience.tenantId) return false\n\n if (audience.organizationScopes.length > 0) {\n if (!conn.organizationId) return false\n if (!audience.organizationScopes.includes(conn.organizationId)) return false\n }\n\n if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {\n return false\n }\n\n if (audience.recipientRoleScopes.length > 0) {\n const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))\n if (!roleMatched) return false\n }\n\n return true\n}\n\n/**\n * Global connection registry.\n * All active SSE connections are tracked here.\n * The event bus subscriber iterates this set on each broadcast event.\n */\nconst connections = new Set<SseConnection>()\n\nlet globalTapRegistered = false\n\nfunction buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n return JSON.stringify({\n id: eventName,\n payload: data,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\nfunction buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n const entityRef: Record<string, unknown> = {\n truncated: true,\n }\n if (typeof data.id === 'string' && data.id.trim().length > 0) {\n entityRef.id = data.id.trim()\n }\n if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {\n entityRef.entityId = data.entityId.trim()\n }\n if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {\n entityRef.entityType = data.entityType.trim()\n }\n return JSON.stringify({\n id: eventName,\n payload: entityRef,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\n/**\n * Ensure a process-wide event tap is registered (once).\n * This captures emits from all request-scoped EventBus instances.\n */\nfunction ensureGlobalTapSubscription(): void {\n if (globalTapRegistered) return\n globalTapRegistered = true\n\n registerGlobalEventTap(async (eventName, payload) => {\n if (!eventName || connections.size === 0) return\n\n // Only bridge events with clientBroadcast: true\n if (!isBroadcastEvent(eventName)) return\n\n const data = (payload ?? {}) as Record<string, unknown>\n const audience = normalizeAudience(data)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\n // Enforce max payload size\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n ssePayload = buildTruncatedPayload(eventName, data, organizationId)\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n console.warn(`[events:stream] Event ${eventName} payload exceeds ${MAX_PAYLOAD_BYTES} bytes, skipping`)\n return\n }\n }\n\n for (const conn of connections) {\n if (!matchesAudience(conn, audience)) continue\n\n try {\n conn.send(ssePayload)\n } catch {\n // Connection may have been closed; cleanup happens via abort handler\n }\n }\n })\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { ctx } = await resolveRequestContext(req)\n\n if (!ctx.auth?.tenantId || !ctx.auth?.sub) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n const tenantId = ctx.auth.tenantId\n const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null\n const userId = ctx.auth.sub\n const roleIds = Array.isArray(ctx.auth.roles)\n ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)\n : []\n\n ensureGlobalTapSubscription()\n\n const encoder = new TextEncoder()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n let connection: SseConnection | null = null\n\n const stream = new ReadableStream({\n start(controller) {\n const send = (data: string) => {\n controller.enqueue(encoder.encode(`data: ${data}\\n\\n`))\n }\n\n connection = {\n tenantId,\n organizationId,\n userId,\n roleIds,\n send,\n close: () => controller.close(),\n }\n connections.add(connection)\n\n // Start heartbeat to keep connection alive\n heartbeatTimer = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(':heartbeat\\n\\n'))\n } catch {\n // Stream may have been closed\n }\n }, HEARTBEAT_INTERVAL_MS)\n },\n cancel() {\n cleanup()\n },\n })\n\n function cleanup() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n heartbeatTimer = null\n }\n if (connection) {\n connections.delete(connection)\n connection = null\n }\n }\n\n // Clean up when client disconnects\n req.signal.addEventListener('abort', cleanup)\n\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Subscribe to server events via SSE (DOM Event Bridge)',\n description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',\n tags: ['Events'],\n responses: {\n 200: {\n description: 'Event stream (text/event-stream)',\n content: {\n 'text/event-stream': {\n schema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },\n payload: { type: 'object', description: 'Event-specific data' },\n timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },\n organizationId: { type: 'string', description: 'Organization scope' },\n },\n },\n },\n },\n },\n 401: { description: 'Not authenticated' },\n },\n },\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/events",
3
- "version": "0.4.5-develop-6bdcebbece",
3
+ "version": "0.4.5-develop-0c30cb4b11",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@open-mercato/queue": "0.4.5-develop-6bdcebbece"
35
+ "@open-mercato/queue": "0.4.5-develop-0c30cb4b11"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/jest": "^30.0.0",
package/src/bus.ts CHANGED
@@ -13,6 +13,27 @@ import type {
13
13
  /** Queue name for persistent events */
14
14
  const EVENTS_QUEUE_NAME = 'events'
15
15
 
16
+ type GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>
17
+ const GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'
18
+
19
+ function getGlobalEventTaps(): Set<GlobalEventTap> {
20
+ const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]
21
+ if (existing instanceof Set) {
22
+ return existing as Set<GlobalEventTap>
23
+ }
24
+ const created = new Set<GlobalEventTap>()
25
+ ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created
26
+ return created
27
+ }
28
+
29
+ export function registerGlobalEventTap(handler: GlobalEventTap): () => void {
30
+ const taps = getGlobalEventTaps()
31
+ taps.add(handler)
32
+ return () => {
33
+ taps.delete(handler)
34
+ }
35
+ }
36
+
16
37
  /**
17
38
  * Match an event name against a pattern.
18
39
  *
@@ -161,6 +182,15 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
161
182
  payload: EventPayload,
162
183
  options?: EmitOptions
163
184
  ): Promise<void> {
185
+ const taps = getGlobalEventTaps()
186
+ for (const tap of taps) {
187
+ try {
188
+ await Promise.resolve(tap(event, payload, options))
189
+ } catch (error) {
190
+ console.error(`[events] Global tap error for "${event}":`, error)
191
+ }
192
+ }
193
+
164
194
  // Always deliver to in-memory handlers first
165
195
  await deliver(event, payload)
166
196
 
@@ -0,0 +1,283 @@
1
+ /**
2
+ * SSE Event Stream — DOM Event Bridge
3
+ *
4
+ * Server-Sent Events endpoint that bridges server-side events to the browser.
5
+ * Only events with `clientBroadcast: true` in their EventDefinition are sent.
6
+ * Events are scoped to the authenticated user's tenant.
7
+ *
8
+ * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`
9
+ */
10
+
11
+ import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
12
+ import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
13
+ import { registerGlobalEventTap } from '../../../../bus'
14
+
15
+ export const metadata = {
16
+ GET: { requireAuth: true },
17
+ }
18
+
19
+ const HEARTBEAT_INTERVAL_MS = 30_000
20
+ const MAX_PAYLOAD_BYTES = 4096
21
+
22
+ type SseConnection = {
23
+ tenantId: string
24
+ organizationId: string | null
25
+ userId: string
26
+ roleIds: string[]
27
+ send: (data: string) => void
28
+ close: () => void
29
+ }
30
+
31
+ function collectStringValues(input: unknown): string[] {
32
+ if (!Array.isArray(input)) return []
33
+ const values: string[] = []
34
+ for (const value of input) {
35
+ if (typeof value !== 'string') continue
36
+ const trimmed = value.trim()
37
+ if (!trimmed) continue
38
+ values.push(trimmed)
39
+ }
40
+ return values
41
+ }
42
+
43
+ function normalizeAudience(data: Record<string, unknown>): {
44
+ tenantId: string | null
45
+ organizationScopes: string[]
46
+ recipientUserScopes: string[]
47
+ recipientRoleScopes: string[]
48
+ } {
49
+ const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null
50
+ const organizationScopes = new Set<string>()
51
+ if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {
52
+ organizationScopes.add(data.organizationId.trim())
53
+ }
54
+ for (const organizationId of collectStringValues(data.organizationIds)) {
55
+ organizationScopes.add(organizationId)
56
+ }
57
+
58
+ const recipientUserScopes = new Set<string>()
59
+ if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {
60
+ recipientUserScopes.add(data.recipientUserId.trim())
61
+ }
62
+ for (const userId of collectStringValues(data.recipientUserIds)) {
63
+ recipientUserScopes.add(userId)
64
+ }
65
+
66
+ const recipientRoleScopes = new Set<string>()
67
+ if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {
68
+ recipientRoleScopes.add(data.recipientRoleId.trim())
69
+ }
70
+ for (const roleId of collectStringValues(data.recipientRoleIds)) {
71
+ recipientRoleScopes.add(roleId)
72
+ }
73
+
74
+ return {
75
+ tenantId,
76
+ organizationScopes: Array.from(organizationScopes),
77
+ recipientUserScopes: Array.from(recipientUserScopes),
78
+ recipientRoleScopes: Array.from(recipientRoleScopes),
79
+ }
80
+ }
81
+
82
+ function matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {
83
+ if (!audience.tenantId) return false
84
+ if (conn.tenantId !== audience.tenantId) return false
85
+
86
+ if (audience.organizationScopes.length > 0) {
87
+ if (!conn.organizationId) return false
88
+ if (!audience.organizationScopes.includes(conn.organizationId)) return false
89
+ }
90
+
91
+ if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {
92
+ return false
93
+ }
94
+
95
+ if (audience.recipientRoleScopes.length > 0) {
96
+ const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))
97
+ if (!roleMatched) return false
98
+ }
99
+
100
+ return true
101
+ }
102
+
103
+ /**
104
+ * Global connection registry.
105
+ * All active SSE connections are tracked here.
106
+ * The event bus subscriber iterates this set on each broadcast event.
107
+ */
108
+ const connections = new Set<SseConnection>()
109
+
110
+ let globalTapRegistered = false
111
+
112
+ function buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {
113
+ return JSON.stringify({
114
+ id: eventName,
115
+ payload: data,
116
+ timestamp: Date.now(),
117
+ organizationId,
118
+ })
119
+ }
120
+
121
+ function buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {
122
+ const entityRef: Record<string, unknown> = {
123
+ truncated: true,
124
+ }
125
+ if (typeof data.id === 'string' && data.id.trim().length > 0) {
126
+ entityRef.id = data.id.trim()
127
+ }
128
+ if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {
129
+ entityRef.entityId = data.entityId.trim()
130
+ }
131
+ if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {
132
+ entityRef.entityType = data.entityType.trim()
133
+ }
134
+ return JSON.stringify({
135
+ id: eventName,
136
+ payload: entityRef,
137
+ timestamp: Date.now(),
138
+ organizationId,
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Ensure a process-wide event tap is registered (once).
144
+ * This captures emits from all request-scoped EventBus instances.
145
+ */
146
+ function ensureGlobalTapSubscription(): void {
147
+ if (globalTapRegistered) return
148
+ globalTapRegistered = true
149
+
150
+ registerGlobalEventTap(async (eventName, payload) => {
151
+ if (!eventName || connections.size === 0) return
152
+
153
+ // Only bridge events with clientBroadcast: true
154
+ if (!isBroadcastEvent(eventName)) return
155
+
156
+ const data = (payload ?? {}) as Record<string, unknown>
157
+ const audience = normalizeAudience(data)
158
+
159
+ const organizationId = audience.organizationScopes[0] ?? ''
160
+ let ssePayload = buildSsePayload(eventName, data, organizationId)
161
+
162
+ // Enforce max payload size
163
+ if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
164
+ ssePayload = buildTruncatedPayload(eventName, data, organizationId)
165
+ if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {
166
+ console.warn(`[events:stream] Event ${eventName} payload exceeds ${MAX_PAYLOAD_BYTES} bytes, skipping`)
167
+ return
168
+ }
169
+ }
170
+
171
+ for (const conn of connections) {
172
+ if (!matchesAudience(conn, audience)) continue
173
+
174
+ try {
175
+ conn.send(ssePayload)
176
+ } catch {
177
+ // Connection may have been closed; cleanup happens via abort handler
178
+ }
179
+ }
180
+ })
181
+ }
182
+
183
+ export async function GET(req: Request): Promise<Response> {
184
+ const { ctx } = await resolveRequestContext(req)
185
+
186
+ if (!ctx.auth?.tenantId || !ctx.auth?.sub) {
187
+ return new Response('Unauthorized', { status: 401 })
188
+ }
189
+
190
+ const tenantId = ctx.auth.tenantId
191
+ const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null
192
+ const userId = ctx.auth.sub
193
+ const roleIds = Array.isArray(ctx.auth.roles)
194
+ ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)
195
+ : []
196
+
197
+ ensureGlobalTapSubscription()
198
+
199
+ const encoder = new TextEncoder()
200
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null
201
+ let connection: SseConnection | null = null
202
+
203
+ const stream = new ReadableStream({
204
+ start(controller) {
205
+ const send = (data: string) => {
206
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`))
207
+ }
208
+
209
+ connection = {
210
+ tenantId,
211
+ organizationId,
212
+ userId,
213
+ roleIds,
214
+ send,
215
+ close: () => controller.close(),
216
+ }
217
+ connections.add(connection)
218
+
219
+ // Start heartbeat to keep connection alive
220
+ heartbeatTimer = setInterval(() => {
221
+ try {
222
+ controller.enqueue(encoder.encode(':heartbeat\n\n'))
223
+ } catch {
224
+ // Stream may have been closed
225
+ }
226
+ }, HEARTBEAT_INTERVAL_MS)
227
+ },
228
+ cancel() {
229
+ cleanup()
230
+ },
231
+ })
232
+
233
+ function cleanup() {
234
+ if (heartbeatTimer) {
235
+ clearInterval(heartbeatTimer)
236
+ heartbeatTimer = null
237
+ }
238
+ if (connection) {
239
+ connections.delete(connection)
240
+ connection = null
241
+ }
242
+ }
243
+
244
+ // Clean up when client disconnects
245
+ req.signal.addEventListener('abort', cleanup)
246
+
247
+ return new Response(stream, {
248
+ status: 200,
249
+ headers: {
250
+ 'Content-Type': 'text/event-stream',
251
+ 'Cache-Control': 'no-cache, no-transform',
252
+ 'Connection': 'keep-alive',
253
+ 'X-Accel-Buffering': 'no',
254
+ },
255
+ })
256
+ }
257
+
258
+ export const openApi = {
259
+ GET: {
260
+ summary: 'Subscribe to server events via SSE (DOM Event Bridge)',
261
+ description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',
262
+ tags: ['Events'],
263
+ responses: {
264
+ 200: {
265
+ description: 'Event stream (text/event-stream)',
266
+ content: {
267
+ 'text/event-stream': {
268
+ schema: {
269
+ type: 'object',
270
+ properties: {
271
+ id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },
272
+ payload: { type: 'object', description: 'Event-specific data' },
273
+ timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },
274
+ organizationId: { type: 'string', description: 'Organization scope' },
275
+ },
276
+ },
277
+ },
278
+ },
279
+ },
280
+ 401: { description: 'Not authenticated' },
281
+ },
282
+ },
283
+ }