@open-mercato/events 0.4.5-develop-03023b2707 → 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 +51 -0
- package/dist/bus.js +27 -1
- package/dist/bus.js.map +2 -2
- package/dist/modules/events/api/stream/route.js +214 -0
- package/dist/modules/events/api/stream/route.js.map +7 -0
- package/package.json +2 -2
- package/src/bus.ts +30 -0
- package/src/modules/events/api/stream/route.ts +283 -0
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;
|
|
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-
|
|
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-
|
|
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
|
+
}
|