@open-mercato/events 0.6.8-develop.7099.1.7ba2771d06 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +0 -4
- package/dist/bridge.js +1 -14
- package/dist/bridge.js.map +2 -2
- package/dist/bus.js +6 -30
- package/dist/bus.js.map +2 -2
- package/dist/modules/events/api/route.js +0 -1
- package/dist/modules/events/api/route.js.map +2 -2
- package/dist/modules/events/api/stream/route.js +2 -9
- package/dist/modules/events/api/stream/route.js.map +2 -2
- package/package.json +4 -5
- package/src/bridge.ts +0 -21
- package/src/bus.ts +7 -49
- package/src/modules/events/api/route.ts +2 -3
- package/src/modules/events/api/stream/__tests__/route.test.ts +14 -261
- package/src/modules/events/api/stream/route.ts +2 -14
- package/src/types.ts +0 -12
- package/src/__tests__/cross-process-broadcast.test.ts +0 -170
- package/src/__tests__/cross-process-instance-id.test.ts +0 -31
package/AGENTS.md
CHANGED
|
@@ -184,7 +184,3 @@ useAppEvent('mymod.entity.created', (event) => {
|
|
|
184
184
|
- Client deduplicates events within a 500ms window
|
|
185
185
|
- `isBroadcastEvent(eventId)` checks if an event has `clientBroadcast: true`
|
|
186
186
|
- The `useEventBridge()` hook must be mounted once in the app shell to start receiving events
|
|
187
|
-
|
|
188
|
-
### Private cross-process coordination
|
|
189
|
-
|
|
190
|
-
Use `crossProcessBroadcast: true` for server-to-server invalidation or coordination that must cross process boundaries but must not be exposed through browser SSE. The event bus publishes both `clientBroadcast` and `crossProcessBroadcast` events to the cross-process bridge, while the DOM Event Bridge delivers only `clientBroadcast` events. Do not use `clientBroadcast` merely to reach another server process when the payload contains record-scoped identifiers or activity that the organization-level SSE audience cannot authorize.
|
package/dist/bridge.js
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { Client, Pool } from "pg";
|
|
3
2
|
import { getSslConfig } from "@open-mercato/shared/lib/db/ssl";
|
|
4
3
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
5
4
|
const BRIDGE_CHANNEL = "om_event_bridge";
|
|
6
5
|
const MAX_MESSAGE_BYTES = 7e3;
|
|
7
6
|
const RECONNECT_DELAY_MS = 1e3;
|
|
8
|
-
const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = "__openMercatoCrossProcessEventInstanceId__";
|
|
9
7
|
const logger = createLogger("events");
|
|
10
|
-
function getCrossProcessEventInstanceId() {
|
|
11
|
-
const globalScope = globalThis;
|
|
12
|
-
const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY];
|
|
13
|
-
if (typeof existing === "string" && existing.length > 0) return existing;
|
|
14
|
-
const created = randomUUID();
|
|
15
|
-
globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created;
|
|
16
|
-
return created;
|
|
17
|
-
}
|
|
18
|
-
const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId();
|
|
19
8
|
let publisherPool;
|
|
20
9
|
let listenerClient = null;
|
|
21
10
|
let listenerConnectPromise = null;
|
|
@@ -133,8 +122,7 @@ async function publishCrossProcessEvent(event, payload, options) {
|
|
|
133
122
|
event,
|
|
134
123
|
payload,
|
|
135
124
|
options: bridgeOptions,
|
|
136
|
-
originPid: process.pid
|
|
137
|
-
originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID
|
|
125
|
+
originPid: process.pid
|
|
138
126
|
};
|
|
139
127
|
const serialized = JSON.stringify(envelope);
|
|
140
128
|
if (Buffer.byteLength(serialized, "utf8") > MAX_MESSAGE_BYTES) {
|
|
@@ -155,7 +143,6 @@ function registerCrossProcessEventListener(listener) {
|
|
|
155
143
|
};
|
|
156
144
|
}
|
|
157
145
|
export {
|
|
158
|
-
CROSS_PROCESS_EVENT_INSTANCE_ID,
|
|
159
146
|
publishCrossProcessEvent,
|
|
160
147
|
registerCrossProcessEventListener
|
|
161
148
|
};
|
package/dist/bridge.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/bridge.ts"],
|
|
4
|
-
"sourcesContent": ["import {
|
|
5
|
-
"mappings": "AAAA,SAAS,
|
|
4
|
+
"sourcesContent": ["import { Client, Pool } from 'pg'\nimport { getSslConfig } from '@open-mercato/shared/lib/db/ssl'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { EmitOptions, EventPayload } from './types'\n\nconst BRIDGE_CHANNEL = 'om_event_bridge'\nconst MAX_MESSAGE_BYTES = 7_000\nconst RECONNECT_DELAY_MS = 1_000\n\nconst logger = createLogger('events')\n\ntype BridgeEnvelope = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n originPid: number\n}\n\ntype CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>\ntype PgNotificationMessage = {\n channel: string\n payload?: string\n}\n\nlet publisherPool: InstanceType<typeof Pool> | null | undefined\nlet listenerClient: InstanceType<typeof Client> | null = null\nlet listenerConnectPromise: Promise<void> | null = null\nlet listenerReconnectTimer: ReturnType<typeof setTimeout> | null = null\nconst listeners = new Set<CrossProcessEventListener>()\n\nfunction getDatabaseUrl(): string | null {\n const value = process.env.DATABASE_URL?.trim()\n return value && value.length > 0 ? value : null\n}\n\nfunction getPgBridgeConnectionString(): string | null {\n const connectionString = getDatabaseUrl()\n if (!connectionString) return null\n\n try {\n const url = new URL(connectionString)\n for (const key of ['sslmode', 'sslcert', 'sslkey', 'sslrootcert', 'sslcrl']) {\n url.searchParams.delete(key)\n }\n return url.toString()\n } catch {\n return connectionString\n }\n}\n\nfunction getPublisherPool(): InstanceType<typeof Pool> | null {\n if (publisherPool !== undefined) return publisherPool\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) {\n publisherPool = null\n return null\n }\n const ssl = getSslConfig()\n publisherPool = new Pool({\n connectionString,\n max: 2,\n ...(ssl ? { ssl } : {}),\n })\n return publisherPool\n}\n\nasync function dispatchEnvelope(envelope: BridgeEnvelope): Promise<void> {\n for (const listener of listeners) {\n try {\n await Promise.resolve(listener(envelope))\n } catch (error) {\n logger.error('Cross-process listener error', { event: envelope.event, err: error })\n }\n }\n}\n\nfunction clearReconnectTimer(): void {\n if (!listenerReconnectTimer) return\n clearTimeout(listenerReconnectTimer)\n listenerReconnectTimer = null\n}\n\nasync function closeListenerClient(): Promise<void> {\n if (!listenerClient) return\n const client = listenerClient\n listenerClient = null\n try {\n client.removeAllListeners('notification')\n client.removeAllListeners('error')\n client.removeAllListeners('end')\n await client.end()\n } catch {\n // Ignore shutdown errors.\n }\n}\n\nfunction scheduleReconnect(): void {\n if (listenerReconnectTimer || listeners.size === 0) return\n listenerReconnectTimer = setTimeout(() => {\n listenerReconnectTimer = null\n void ensureCrossProcessListener()\n }, RECONNECT_DELAY_MS)\n}\n\nasync function ensureCrossProcessListener(): Promise<void> {\n if (listenerClient || listenerConnectPromise || listeners.size === 0) return\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) return\n\n listenerConnectPromise = (async () => {\n const ssl = getSslConfig()\n const client = new Client({\n connectionString,\n ...(ssl ? { ssl } : {}),\n })\n\n client.on('notification', (message: PgNotificationMessage) => {\n if (message.channel !== BRIDGE_CHANNEL || !message.payload) return\n try {\n const parsed = JSON.parse(message.payload) as BridgeEnvelope\n if (!parsed || typeof parsed.event !== 'string') return\n void dispatchEnvelope(parsed)\n } catch (error) {\n logger.warn('Failed to parse cross-process bridge payload', { err: error })\n }\n })\n\n const handleDisconnect = () => {\n if (listenerClient === client) {\n listenerClient = null\n }\n scheduleReconnect()\n }\n\n client.on('error', handleDisconnect)\n client.on('end', handleDisconnect)\n\n await client.connect()\n await client.query(`LISTEN ${BRIDGE_CHANNEL}`)\n listenerClient = client\n })()\n .catch((error) => {\n logger.warn('Cross-process event bridge listener failed', { err: error })\n scheduleReconnect()\n })\n .finally(() => {\n listenerConnectPromise = null\n })\n\n await listenerConnectPromise\n}\n\nexport async function publishCrossProcessEvent(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): Promise<void> {\n const pool = getPublisherPool()\n if (!pool) return\n\n const bridgeOptions = options\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n && options.tenantId === undefined\n ? { ...options, tenantId: null }\n : options\n\n const envelope: BridgeEnvelope = {\n event,\n payload,\n options: bridgeOptions,\n originPid: process.pid,\n }\n\n const serialized = JSON.stringify(envelope)\n if (Buffer.byteLength(serialized, 'utf8') > MAX_MESSAGE_BYTES) {\n logger.warn('Cross-process event dropped: payload exceeds size limit', { event, maxBytes: MAX_MESSAGE_BYTES })\n return\n }\n\n await pool.query('SELECT pg_notify($1, $2)', [BRIDGE_CHANNEL, serialized])\n}\n\nexport function registerCrossProcessEventListener(listener: CrossProcessEventListener): () => void {\n listeners.add(listener)\n void ensureCrossProcessListener()\n\n return () => {\n listeners.delete(listener)\n if (listeners.size === 0) {\n clearReconnectTimer()\n void closeListenerClient()\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/bus.js
CHANGED
|
@@ -4,16 +4,12 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
|
4
4
|
import { isSingleDeliveryRequested } from "./single-delivery.js";
|
|
5
5
|
import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
|
|
6
6
|
import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
|
|
7
|
-
import {
|
|
8
|
-
isBroadcastEvent,
|
|
9
|
-
isCrossProcessBroadcastEvent,
|
|
10
|
-
isPrivateCrossProcessEventEmitter
|
|
11
|
-
} from "@open-mercato/shared/modules/events";
|
|
7
|
+
import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
|
|
12
8
|
import {
|
|
13
9
|
inferModuleIdFromResourceId,
|
|
14
10
|
withModuleResourceUsage
|
|
15
11
|
} from "@open-mercato/shared/lib/modules/resource-usage";
|
|
16
|
-
import { registerCrossProcessEventListener
|
|
12
|
+
import { registerCrossProcessEventListener } from "./bridge.js";
|
|
17
13
|
import { publishCrossProcessEvent } from "./bridge.js";
|
|
18
14
|
const EVENTS_QUEUE_NAME = "events";
|
|
19
15
|
const logger = createLogger("events");
|
|
@@ -21,26 +17,8 @@ function isSingleDeliveryEnabled() {
|
|
|
21
17
|
return isSingleDeliveryRequested();
|
|
22
18
|
}
|
|
23
19
|
const GLOBAL_EVENT_TAPS_KEY = "__openMercatoEventBusGlobalTaps__";
|
|
24
|
-
function
|
|
25
|
-
return typeof
|
|
26
|
-
}
|
|
27
|
-
function normalizePayloadScope(value) {
|
|
28
|
-
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
29
|
-
}
|
|
30
|
-
function resolveCrossProcessEmitOptions(event, payload, options) {
|
|
31
|
-
if (hasTrustedTenantScope(options)) return options;
|
|
32
|
-
if (!isBroadcastEvent(event)) return options;
|
|
33
|
-
const tenantId = normalizePayloadScope(payload?.tenantId);
|
|
34
|
-
if (!tenantId) return options;
|
|
35
|
-
const organizationId = normalizePayloadScope(payload?.organizationId);
|
|
36
|
-
const organizationIdsValue = payload?.organizationIds;
|
|
37
|
-
const organizationIds = Array.isArray(organizationIdsValue) ? organizationIdsValue.filter((value) => typeof value === "string") : [];
|
|
38
|
-
return {
|
|
39
|
-
...options,
|
|
40
|
-
tenantId,
|
|
41
|
-
...organizationId ? { organizationId } : {},
|
|
42
|
-
...organizationIds.length > 0 ? { organizationIds } : {}
|
|
43
|
-
};
|
|
20
|
+
function hasTenantScope(payload) {
|
|
21
|
+
return typeof payload?.tenantId === "string" && String(payload.tenantId).trim().length > 0;
|
|
44
22
|
}
|
|
45
23
|
function getGlobalEventTaps() {
|
|
46
24
|
const existing = globalThis[GLOBAL_EVENT_TAPS_KEY];
|
|
@@ -230,10 +208,9 @@ function createEventBus(opts) {
|
|
|
230
208
|
const delivered = await deliver(event, payload, options, skipPersistentInline);
|
|
231
209
|
inlinePersistentFailed = delivered.persistentFailures > 0;
|
|
232
210
|
}
|
|
233
|
-
|
|
234
|
-
if (isCrossProcessBroadcastEvent(event) && hasTrustedTenantScope(crossProcessOptions) && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)) {
|
|
211
|
+
if (isBroadcastEvent(event) && hasTenantScope(payload)) {
|
|
235
212
|
try {
|
|
236
|
-
await publishCrossProcessEvent(event, payload,
|
|
213
|
+
await publishCrossProcessEvent(event, payload, options);
|
|
237
214
|
} catch (error) {
|
|
238
215
|
logger.error("Cross-process publish error", { event, err: error });
|
|
239
216
|
}
|
|
@@ -271,7 +248,6 @@ function createEventBus(opts) {
|
|
|
271
248
|
};
|
|
272
249
|
}
|
|
273
250
|
export {
|
|
274
|
-
CROSS_PROCESS_EVENT_INSTANCE_ID,
|
|
275
251
|
createEventBus,
|
|
276
252
|
registerCrossProcessEventListener,
|
|
277
253
|
registerGlobalEventTap
|
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 { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport {\n isBroadcastEvent,\n isCrossProcessBroadcastEvent,\n isPrivateCrossProcessEventEmitter,\n} from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTrustedTenantScope(options?: EmitOptions): boolean {\n return typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n}\n\nfunction normalizePayloadScope(value: unknown): string | null {\n return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null\n}\n\nfunction resolveCrossProcessEmitOptions(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): EmitOptions | undefined {\n if (hasTrustedTenantScope(options)) return options\n // Preserve the public raw EventBus contract for legacy clientBroadcast\n // emitters. These callers are trusted server code and historically supplied\n // their scope in the typed payload. Tenant-managed workflow execution passes\n // scope in options, while private cross-process events never take this path.\n if (!isBroadcastEvent(event)) return options\n const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)\n if (!tenantId) return options\n const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)\n const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds\n const organizationIds = Array.isArray(organizationIdsValue)\n ? organizationIdsValue.filter((value): value is string => typeof value === 'string')\n : []\n return {\n ...options,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n ...(organizationIds.length > 0 ? { organizationIds } : {}),\n }\n}\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/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n /**\n * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\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<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\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 * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n *\n * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\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 subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\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, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\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 logger.error('Global tap error', { event, err: error })\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n // Private coordination always requires trusted envelope scope and module\n // provenance. Legacy declared browser events may promote their typed scope\n // for raw EventBus compatibility; tenant-managed workflow adapters pass\n // authoritative scope in options, which always wins over payload values.\n const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)\n if (\n isCrossProcessBroadcastEvent(event)\n && hasTrustedTenantScope(crossProcessOptions)\n && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)\n ) {\n try {\n await publishCrossProcessEvent(event, payload, crossProcessOptions)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\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 dispatchQueued,\n clearQueue,\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,mBAAmB;AAE5B,SAAS,+BAA+B;AACxC,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAC1C,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC
|
|
4
|
+
"sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\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/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n /**\n * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\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<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\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 * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n *\n * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\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 subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\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, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\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 logger.error('Global tap error', { event, err: error })\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\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 dispatchQueued,\n clearQueue,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -13,7 +13,6 @@ const eventDefinitionSchema = z.object({
|
|
|
13
13
|
entity: z.string().optional(),
|
|
14
14
|
excludeFromTriggers: z.boolean().optional(),
|
|
15
15
|
clientBroadcast: z.boolean().optional(),
|
|
16
|
-
crossProcessBroadcast: z.boolean().optional(),
|
|
17
16
|
portalBroadcast: z.boolean().optional()
|
|
18
17
|
});
|
|
19
18
|
const eventsResponseSchema = z.object({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/events/api/route.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids,
|
|
5
|
-
"mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,
|
|
4
|
+
"sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids, clientBroadcast/portalBroadcast\n * flags) is recon-grade and MUST NOT be served to anonymous callers; the route\n * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.\n * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,\n * business rules, webhook config).\n */\n\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\n\nexport const metadata = {\n path: '/events',\n GET: { requireAuth: true, requireFeatures: ['workflows.view'] },\n}\n\nconst eventDefinitionSchema = z.object({\n id: z.string(),\n label: z.string(),\n description: z.string().optional(),\n category: z.enum(['crud', 'lifecycle', 'system', 'custom']).optional(),\n module: z.string().optional(),\n entity: z.string().optional(),\n excludeFromTriggers: z.boolean().optional(),\n clientBroadcast: z.boolean().optional(),\n portalBroadcast: z.boolean().optional(),\n})\n\nconst eventsResponseSchema = z.object({\n data: z.array(eventDefinitionSchema),\n total: z.number().int().nonnegative(),\n})\n\nexport async function GET(req: Request): Promise<Response> {\n const { searchParams } = new URL(req.url)\n const category = searchParams.get('category')\n const moduleId = searchParams.get('module')\n const excludeTriggerExcluded = searchParams.get('excludeTriggerExcluded') !== 'false'\n\n let filteredEvents = getDeclaredEvents()\n\n if (excludeTriggerExcluded) {\n filteredEvents = filteredEvents.filter((e) => !e.excludeFromTriggers)\n }\n if (category) {\n filteredEvents = filteredEvents.filter((e) => e.category === category)\n }\n if (moduleId) {\n filteredEvents = filteredEvents.filter((e) => e.module === moduleId)\n }\n\n return Response.json({\n data: filteredEvents,\n total: filteredEvents.length,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'List declared platform events',\n description: 'Returns all declared platform events from every enabled module. Supports optional category and module filters.',\n methods: {\n GET: {\n summary: 'List declared events',\n description: 'Returns every declared event. Filters: category, module, excludeTriggerExcluded (default true).',\n responses: [{ status: 200, description: 'Declared events', schema: eventsResponseSchema }],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AACxC,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,MAAM,qBAAqB;AAAA,EACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY;AACtC,CAAC;AAED,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,IAAI,GAAG;AACxC,QAAM,WAAW,aAAa,IAAI,UAAU;AAC5C,QAAM,WAAW,aAAa,IAAI,QAAQ;AAC1C,QAAM,yBAAyB,aAAa,IAAI,wBAAwB,MAAM;AAE9E,MAAI,iBAAiB,kBAAkB;AAEvC,MAAI,wBAAwB;AAC1B,qBAAiB,eAAe,OAAO,CAAC,MAAM,CAAC,EAAE,mBAAmB;AAAA,EACtE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAAA,EACvE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,EACrE;AAEA,SAAO,SAAS,KAAK;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,eAAe;AAAA,EACxB,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA,IAC3F;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { resolveRequestContext } from "@open-mercato/shared/lib/api/context";
|
|
2
2
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
3
3
|
import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
|
|
4
|
-
import {
|
|
5
|
-
CROSS_PROCESS_EVENT_INSTANCE_ID,
|
|
6
|
-
registerCrossProcessEventListener,
|
|
7
|
-
registerGlobalEventTap
|
|
8
|
-
} from "../../../../bus.js";
|
|
4
|
+
import { registerCrossProcessEventListener, registerGlobalEventTap } from "../../../../bus.js";
|
|
9
5
|
const metadata = {
|
|
10
6
|
GET: { requireAuth: true }
|
|
11
7
|
};
|
|
@@ -31,9 +27,6 @@ function normalizeAudience(data, options) {
|
|
|
31
27
|
const trustedOrganizationId = typeof options?.organizationId === "string" && options.organizationId.trim().length > 0 ? options.organizationId.trim() : null;
|
|
32
28
|
if (hasTrustedScope) {
|
|
33
29
|
if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId);
|
|
34
|
-
for (const organizationId of collectStringValues(options?.organizationIds)) {
|
|
35
|
-
organizationScopes.add(organizationId);
|
|
36
|
-
}
|
|
37
30
|
} else {
|
|
38
31
|
if (typeof data.organizationId === "string" && data.organizationId.trim().length > 0) {
|
|
39
32
|
organizationScopes.add(data.organizationId.trim());
|
|
@@ -138,7 +131,7 @@ function ensureGlobalTapSubscription() {
|
|
|
138
131
|
await broadcastEventToConnections(eventName, payload ?? {}, options);
|
|
139
132
|
});
|
|
140
133
|
registerCrossProcessEventListener(async (envelope) => {
|
|
141
|
-
if (envelope.
|
|
134
|
+
if (envelope.originPid === process.pid) return;
|
|
142
135
|
await broadcastEventToConnections(
|
|
143
136
|
envelope.event,
|
|
144
137
|
envelope.payload ?? {},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
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 { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n CROSS_PROCESS_EVENT_INSTANCE_ID,\n registerCrossProcessEventListener,\n registerGlobalEventTap,\n} from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\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>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n for (const organizationId of collectStringValues(options?.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n } else {\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\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\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\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 logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\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\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, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n // Every envelope this process publishes carries originInstanceId, so an\n // envelope without one can only come from an older process during a\n // rolling deploy and is always foreign. Falling back to originPid would\n // discard it whenever both containers run under the same pid \u2014 commonly\n // pid 1 \u2014 silently dropping browser events across the upgrade window.\n if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\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 const onAbort = () => cleanup()\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 // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n controller.enqueue(encoder.encode(': connected\\n\\n'))\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 // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\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": "AAUA,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC
|
|
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 { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\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>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n } else {\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\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\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\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 logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\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\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, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n if (envelope.originPid === process.pid) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\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 const onAbort = () => cleanup()\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 // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n controller.enqueue(encoder.encode(': connected\\n\\n'))\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 // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\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": "AAUA,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC,SAAS,mCAAmC,8BAA8B;AAGnE,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,KAAK;AAC3B;AAEA,MAAM,wBAAwB;AAC9B,MAAM,oBAAoB;AAE1B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,SAAS,CAAC;AAWnE,SAAS,oBAAoB,OAA0B;AACrD,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC;AACnC,QAAM,SAAmB,CAAC;AAC1B,aAAW,SAAS,OAAO;AACzB,QAAI,OAAO,UAAU,SAAU;AAC/B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,QAAS;AACd,WAAO,KAAK,OAAO;AAAA,EACrB;AACA,SAAO;AACT;AAEA,SAAS,kBAAkB,MAA+B,SAKxD;AACA,QAAM,kBAAkB,WAAW,QAC9B,OAAO,UAAU,eAAe,KAAK,SAAS,UAAU;AAC7D,QAAM,kBAAkB,OAAO,SAAS,aAAa,YAAY,QAAQ,SAAS,KAAK,EAAE,SAAS,IAC9F,QAAQ,SAAS,KAAK,IACtB;AACJ,QAAM,WAAW,kBACb,kBACC,OAAO,KAAK,aAAa,WAAW,KAAK,WAAW;AACzD,QAAM,qBAAqB,oBAAI,IAAY;AAC3C,QAAM,wBAAwB,OAAO,SAAS,mBAAmB,YAAY,QAAQ,eAAe,KAAK,EAAE,SAAS,IAChH,QAAQ,eAAe,KAAK,IAC5B;AACJ,MAAI,iBAAiB;AACnB,QAAI,sBAAuB,oBAAmB,IAAI,qBAAqB;AAAA,EACzE,OAAO;AACL,QAAI,OAAO,KAAK,mBAAmB,YAAY,KAAK,eAAe,KAAK,EAAE,SAAS,GAAG;AACpF,yBAAmB,IAAI,KAAK,eAAe,KAAK,CAAC;AAAA,IACnD;AACA,eAAW,kBAAkB,oBAAoB,KAAK,eAAe,GAAG;AACtE,yBAAmB,IAAI,cAAc;AAAA,IACvC;AAAA,EACF;AAEA,QAAM,sBAAsB,oBAAI,IAAY;AAC5C,MAAI,OAAO,KAAK,oBAAoB,YAAY,KAAK,gBAAgB,KAAK,EAAE,SAAS,GAAG;AACtF,wBAAoB,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EACrD;AACA,aAAW,UAAU,oBAAoB,KAAK,gBAAgB,GAAG;AAC/D,wBAAoB,IAAI,MAAM;AAAA,EAChC;AAEA,QAAM,sBAAsB,oBAAI,IAAY;AAC5C,MAAI,OAAO,KAAK,oBAAoB,YAAY,KAAK,gBAAgB,KAAK,EAAE,SAAS,GAAG;AACtF,wBAAoB,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EACrD;AACA,aAAW,UAAU,oBAAoB,KAAK,gBAAgB,GAAG;AAC/D,wBAAoB,IAAI,MAAM;AAAA,EAChC;AAEA,SAAO;AAAA,IACL;AAAA,IACA,oBAAoB,MAAM,KAAK,kBAAkB;AAAA,IACjD,qBAAqB,MAAM,KAAK,mBAAmB;AAAA,IACnD,qBAAqB,MAAM,KAAK,mBAAmB;AAAA,EACrD;AACF;AAEA,SAAS,gBAAgB,MAAqB,UAAyD;AACrG,MAAI,CAAC,SAAS,SAAU,QAAO;AAC/B,MAAI,KAAK,aAAa,SAAS,SAAU,QAAO;AAEhD,MAAI,SAAS,mBAAmB,SAAS,GAAG;AAC1C,QAAI,CAAC,KAAK,eAAgB,QAAO;AACjC,QAAI,CAAC,SAAS,mBAAmB,SAAS,KAAK,cAAc,EAAG,QAAO;AAAA,EACzE;AAEA,MAAI,SAAS,oBAAoB,SAAS,KAAK,CAAC,SAAS,oBAAoB,SAAS,KAAK,MAAM,GAAG;AAClG,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,oBAAoB,SAAS,GAAG;AAC3C,UAAM,cAAc,KAAK,QAAQ,KAAK,CAAC,WAAW,SAAS,oBAAoB,SAAS,MAAM,CAAC;AAC/F,QAAI,CAAC,YAAa,QAAO;AAAA,EAC3B;AAEA,SAAO;AACT;AAOA,MAAM,cAAc,oBAAI,IAAmB;AAE3C,IAAI,sBAAsB;AAE1B,eAAe,4BACb,WACA,SACA,SACe;AACf,MAAI,CAAC,aAAa,YAAY,SAAS,EAAG;AAE1C,MAAI,CAAC,iBAAiB,SAAS,EAAG;AAElC,QAAM,OAAO,WAAW,CAAC;AACzB,QAAM,WAAW,kBAAkB,MAAM,OAAO;AAEhD,QAAM,iBAAiB,SAAS,mBAAmB,CAAC,KAAK;AACzD,MAAI,aAAa,gBAAgB,WAAW,MAAM,cAAc;AAEhE,MAAI,IAAI,YAAY,EAAE,OAAO,UAAU,EAAE,SAAS,mBAAmB;AACnE,iBAAa,sBAAsB,WAAW,MAAM,cAAc;AAClE,QAAI,IAAI,YAAY,EAAE,OAAO,UAAU,EAAE,SAAS,mBAAmB;AACnE,aAAO,KAAK,8CAA8C,EAAE,OAAO,WAAW,UAAU,kBAAkB,CAAC;AAC3G;AAAA,IACF;AAAA,EACF;AAEA,aAAW,QAAQ,aAAa;AAC9B,QAAI,CAAC,gBAAgB,MAAM,QAAQ,EAAG;AAEtC,QAAI;AACF,WAAK,KAAK,UAAU;AAAA,IACtB,QAAQ;AAAA,IAER;AAAA,EACF;AACF;AAEA,SAAS,gBAAgB,WAAmB,MAA+B,gBAAgC;AACzG,SAAO,KAAK,UAAU;AAAA,IACpB,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,WAAW,KAAK,IAAI;AAAA,IACpB;AAAA,EACF,CAAC;AACH;AAEA,SAAS,sBAAsB,WAAmB,MAA+B,gBAAgC;AAC/G,QAAM,YAAqC;AAAA,IACzC,WAAW;AAAA,EACb;AACA,MAAI,OAAO,KAAK,OAAO,YAAY,KAAK,GAAG,KAAK,EAAE,SAAS,GAAG;AAC5D,cAAU,KAAK,KAAK,GAAG,KAAK;AAAA,EAC9B;AACA,MAAI,OAAO,KAAK,aAAa,YAAY,KAAK,SAAS,KAAK,EAAE,SAAS,GAAG;AACxE,cAAU,WAAW,KAAK,SAAS,KAAK;AAAA,EAC1C;AACA,MAAI,OAAO,KAAK,eAAe,YAAY,KAAK,WAAW,KAAK,EAAE,SAAS,GAAG;AAC5E,cAAU,aAAa,KAAK,WAAW,KAAK;AAAA,EAC9C;AACA,SAAO,KAAK,UAAU;AAAA,IACpB,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,WAAW,KAAK,IAAI;AAAA,IACpB;AAAA,EACF,CAAC;AACH;AAMA,SAAS,8BAAoC;AAC3C,MAAI,oBAAqB;AACzB,wBAAsB;AAEtB,yBAAuB,OAAO,WAAW,SAAS,YAAY;AAC5D,UAAM,4BAA4B,WAAY,WAAW,CAAC,GAA+B,OAAO;AAAA,EAClG,CAAC;AAED,oCAAkC,OAAO,aAAa;AACpD,QAAI,SAAS,cAAc,QAAQ,IAAK;AACxC,UAAM;AAAA,MACJ,SAAS;AAAA,MACR,SAAS,WAAW,CAAC;AAAA,MACtB,SAAS;AAAA,IACX;AAAA,EACF,CAAC;AACH;AAEA,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,IAAI,IAAI,MAAM,sBAAsB,GAAG;AAE/C,MAAI,CAAC,IAAI,MAAM,YAAY,CAAC,IAAI,MAAM,KAAK;AACzC,WAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrD;AAEA,QAAM,WAAW,IAAI,KAAK;AAC1B,QAAM,iBAAkB,IAAI,0BAAqC,IAAI,KAAK,SAAS;AACnF,QAAM,SAAS,IAAI,KAAK;AACxB,QAAM,UAAU,MAAM,QAAQ,IAAI,KAAK,KAAK,IACxC,IAAI,KAAK,MAAM,OAAO,CAAC,SAAyB,OAAO,SAAS,YAAY,KAAK,KAAK,EAAE,SAAS,CAAC,IAClG,CAAC;AAEL,8BAA4B;AAE5B,QAAM,UAAU,IAAI,YAAY;AAChC,MAAI,iBAAwD;AAC5D,MAAI,aAAmC;AACvC,QAAM,UAAU,MAAM,QAAQ;AAE9B,QAAM,SAAS,IAAI,eAAe;AAAA,IAChC,MAAM,YAAY;AAChB,YAAM,OAAO,CAAC,SAAiB;AAC7B,mBAAW,QAAQ,QAAQ,OAAO,SAAS,IAAI;AAAA;AAAA,CAAM,CAAC;AAAA,MACxD;AAEA,mBAAa;AAAA,QACX;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,OAAO,MAAM,WAAW,MAAM;AAAA,MAChC;AACA,kBAAY,IAAI,UAAU;AAQ1B,iBAAW,QAAQ,QAAQ,OAAO,iBAAiB,CAAC;AAGpD,uBAAiB,YAAY,MAAM;AACjC,YAAI;AACF,qBAAW,QAAQ,QAAQ,OAAO,gBAAgB,CAAC;AAAA,QACrD,QAAQ;AAAA,QAER;AAAA,MACF,GAAG,qBAAqB;AAAA,IAC1B;AAAA,IACA,SAAS;AACP,cAAQ;AAAA,IACV;AAAA,EACF,CAAC;AAED,WAAS,UAAU;AACjB,QAAI,gBAAgB;AAClB,oBAAc,cAAc;AAC5B,uBAAiB;AAAA,IACnB;AACA,QAAI,YAAY;AACd,kBAAY,OAAO,UAAU;AAC7B,mBAAa;AAAA,IACf;AAGA,QAAI,OAAO,oBAAoB,SAAS,OAAO;AAAA,EACjD;AAEA,MAAI,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;AAE5D,SAAO,IAAI,SAAS,QAAQ;AAAA,IAC1B,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,gBAAgB;AAAA,MAChB,iBAAiB;AAAA,MACjB,cAAc;AAAA,MACd,qBAAqB;AAAA,IACvB;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;AAAA,EACrB,KAAK;AAAA,IACH,SAAS;AAAA,IACT,aAAa;AAAA,IACb,MAAM,CAAC,QAAQ;AAAA,IACf,WAAW;AAAA,MACT,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,qBAAqB;AAAA,YACnB,QAAQ;AAAA,cACN,MAAM;AAAA,cACN,YAAY;AAAA,gBACV,IAAI,EAAE,MAAM,UAAU,aAAa,gDAAgD;AAAA,gBACnF,SAAS,EAAE,MAAM,UAAU,aAAa,sBAAsB;AAAA,gBAC9D,WAAW,EAAE,MAAM,UAAU,aAAa,oCAAoC;AAAA,gBAC9E,gBAAgB,EAAE,MAAM,UAAU,aAAa,qBAAqB;AAAA,cACtE;AAAA,YACF;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,MACA,KAAK,EAAE,aAAa,oBAAoB;AAAA,IAC1C;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@open-mercato/queue": "0.
|
|
37
|
-
"@open-mercato/shared": "0.
|
|
36
|
+
"@open-mercato/queue": "0.7.0",
|
|
37
|
+
"@open-mercato/shared": "0.7.0",
|
|
38
38
|
"pg": "8.22.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
@@ -50,6 +50,5 @@
|
|
|
50
50
|
"type": "git",
|
|
51
51
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
52
52
|
"directory": "packages/events"
|
|
53
|
-
}
|
|
54
|
-
"stableVersion": "0.6.7"
|
|
53
|
+
}
|
|
55
54
|
}
|
package/src/bridge.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
1
|
import { Client, Pool } from 'pg'
|
|
3
2
|
import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
|
|
4
3
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -7,33 +6,14 @@ import type { EmitOptions, EventPayload } from './types'
|
|
|
7
6
|
const BRIDGE_CHANNEL = 'om_event_bridge'
|
|
8
7
|
const MAX_MESSAGE_BYTES = 7_000
|
|
9
8
|
const RECONNECT_DELAY_MS = 1_000
|
|
10
|
-
const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'
|
|
11
9
|
|
|
12
10
|
const logger = createLogger('events')
|
|
13
11
|
|
|
14
|
-
/**
|
|
15
|
-
* PIDs can collide across containers, so self-echo suppression must not rely
|
|
16
|
-
* on originPid alone. Every publisher stamps envelopes with this random
|
|
17
|
-
* per-process instance id; consumers prefer it and fall back to originPid
|
|
18
|
-
* only for envelopes published by older processes during a rolling deploy.
|
|
19
|
-
*/
|
|
20
|
-
function getCrossProcessEventInstanceId(): string {
|
|
21
|
-
const globalScope = globalThis as Record<string, unknown>
|
|
22
|
-
const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY]
|
|
23
|
-
if (typeof existing === 'string' && existing.length > 0) return existing
|
|
24
|
-
const created = randomUUID()
|
|
25
|
-
globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created
|
|
26
|
-
return created
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId()
|
|
30
|
-
|
|
31
12
|
type BridgeEnvelope = {
|
|
32
13
|
event: string
|
|
33
14
|
payload: EventPayload
|
|
34
15
|
options?: EmitOptions
|
|
35
16
|
originPid: number
|
|
36
|
-
originInstanceId?: string
|
|
37
17
|
}
|
|
38
18
|
|
|
39
19
|
type CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>
|
|
@@ -189,7 +169,6 @@ export async function publishCrossProcessEvent(
|
|
|
189
169
|
payload,
|
|
190
170
|
options: bridgeOptions,
|
|
191
171
|
originPid: process.pid,
|
|
192
|
-
originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,
|
|
193
172
|
}
|
|
194
173
|
|
|
195
174
|
const serialized = JSON.stringify(envelope)
|
package/src/bus.ts
CHANGED
|
@@ -5,16 +5,12 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
5
5
|
import { isSingleDeliveryRequested } from './single-delivery'
|
|
6
6
|
import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
|
|
7
7
|
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
8
|
-
import {
|
|
9
|
-
isBroadcastEvent,
|
|
10
|
-
isCrossProcessBroadcastEvent,
|
|
11
|
-
isPrivateCrossProcessEventEmitter,
|
|
12
|
-
} from '@open-mercato/shared/modules/events'
|
|
8
|
+
import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
|
|
13
9
|
import {
|
|
14
10
|
inferModuleIdFromResourceId,
|
|
15
11
|
withModuleResourceUsage,
|
|
16
12
|
} from '@open-mercato/shared/lib/modules/resource-usage'
|
|
17
|
-
export { registerCrossProcessEventListener
|
|
13
|
+
export { registerCrossProcessEventListener } from './bridge'
|
|
18
14
|
import { publishCrossProcessEvent } from './bridge'
|
|
19
15
|
import type {
|
|
20
16
|
EventBus,
|
|
@@ -61,38 +57,9 @@ function isSingleDeliveryEnabled(): boolean {
|
|
|
61
57
|
type GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>
|
|
62
58
|
const GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'
|
|
63
59
|
|
|
64
|
-
function
|
|
65
|
-
return typeof
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
function normalizePayloadScope(value: unknown): string | null {
|
|
69
|
-
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function resolveCrossProcessEmitOptions(
|
|
73
|
-
event: string,
|
|
74
|
-
payload: EventPayload,
|
|
75
|
-
options?: EmitOptions,
|
|
76
|
-
): EmitOptions | undefined {
|
|
77
|
-
if (hasTrustedTenantScope(options)) return options
|
|
78
|
-
// Preserve the public raw EventBus contract for legacy clientBroadcast
|
|
79
|
-
// emitters. These callers are trusted server code and historically supplied
|
|
80
|
-
// their scope in the typed payload. Tenant-managed workflow execution passes
|
|
81
|
-
// scope in options, while private cross-process events never take this path.
|
|
82
|
-
if (!isBroadcastEvent(event)) return options
|
|
83
|
-
const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)
|
|
84
|
-
if (!tenantId) return options
|
|
85
|
-
const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)
|
|
86
|
-
const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds
|
|
87
|
-
const organizationIds = Array.isArray(organizationIdsValue)
|
|
88
|
-
? organizationIdsValue.filter((value): value is string => typeof value === 'string')
|
|
89
|
-
: []
|
|
90
|
-
return {
|
|
91
|
-
...options,
|
|
92
|
-
tenantId,
|
|
93
|
-
...(organizationId ? { organizationId } : {}),
|
|
94
|
-
...(organizationIds.length > 0 ? { organizationIds } : {}),
|
|
95
|
-
}
|
|
60
|
+
function hasTenantScope(payload: EventPayload): boolean {
|
|
61
|
+
return typeof (payload as Record<string, unknown>)?.tenantId === 'string'
|
|
62
|
+
&& String((payload as Record<string, unknown>).tenantId).trim().length > 0
|
|
96
63
|
}
|
|
97
64
|
|
|
98
65
|
function getGlobalEventTaps(): Set<GlobalEventTap> {
|
|
@@ -468,18 +435,9 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
|
|
|
468
435
|
inlinePersistentFailed = delivered.persistentFailures > 0
|
|
469
436
|
}
|
|
470
437
|
|
|
471
|
-
|
|
472
|
-
// provenance. Legacy declared browser events may promote their typed scope
|
|
473
|
-
// for raw EventBus compatibility; tenant-managed workflow adapters pass
|
|
474
|
-
// authoritative scope in options, which always wins over payload values.
|
|
475
|
-
const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)
|
|
476
|
-
if (
|
|
477
|
-
isCrossProcessBroadcastEvent(event)
|
|
478
|
-
&& hasTrustedTenantScope(crossProcessOptions)
|
|
479
|
-
&& isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)
|
|
480
|
-
) {
|
|
438
|
+
if (isBroadcastEvent(event) && hasTenantScope(payload)) {
|
|
481
439
|
try {
|
|
482
|
-
await publishCrossProcessEvent(event, payload,
|
|
440
|
+
await publishCrossProcessEvent(event, payload, options)
|
|
483
441
|
} catch (error) {
|
|
484
442
|
logger.error('Cross-process publish error', { event, err: error })
|
|
485
443
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Events registry API — returns declared events from module `events.ts` files.
|
|
3
3
|
*
|
|
4
4
|
* Uses the globally registered event configs (populated during bootstrap). Data
|
|
5
|
-
* leaked here (event ids, module ids, entity ids,
|
|
6
|
-
* recon-grade and MUST NOT be served to anonymous callers; the route
|
|
5
|
+
* leaked here (event ids, module ids, entity ids, clientBroadcast/portalBroadcast
|
|
6
|
+
* flags) is recon-grade and MUST NOT be served to anonymous callers; the route
|
|
7
7
|
* is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.
|
|
8
8
|
* Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,
|
|
9
9
|
* business rules, webhook config).
|
|
@@ -27,7 +27,6 @@ const eventDefinitionSchema = z.object({
|
|
|
27
27
|
entity: z.string().optional(),
|
|
28
28
|
excludeFromTriggers: z.boolean().optional(),
|
|
29
29
|
clientBroadcast: z.boolean().optional(),
|
|
30
|
-
crossProcessBroadcast: z.boolean().optional(),
|
|
31
30
|
portalBroadcast: z.boolean().optional(),
|
|
32
31
|
})
|
|
33
32
|
|
|
@@ -11,7 +11,6 @@ jest.mock('@open-mercato/shared/lib/api/context', () => ({
|
|
|
11
11
|
type EmitOptions = {
|
|
12
12
|
tenantId?: string | null
|
|
13
13
|
organizationId?: string | null
|
|
14
|
-
organizationIds?: string[] | null
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
type GlobalEventTap = (
|
|
@@ -22,35 +21,18 @@ type GlobalEventTap = (
|
|
|
22
21
|
|
|
23
22
|
let mockGlobalEventTap: GlobalEventTap | undefined
|
|
24
23
|
|
|
25
|
-
const registerGlobalEventTapMock = jest.fn((handler: GlobalEventTap) => {
|
|
26
|
-
mockGlobalEventTap = handler
|
|
27
|
-
})
|
|
28
|
-
const registerCrossProcessEventListenerMock = jest.fn()
|
|
29
|
-
|
|
30
24
|
jest.mock('../../../../../bus', () => ({
|
|
31
|
-
registerGlobalEventTap: (handler: GlobalEventTap) =>
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
registerGlobalEventTap: jest.fn((handler: GlobalEventTap) => {
|
|
26
|
+
mockGlobalEventTap = handler
|
|
27
|
+
}),
|
|
28
|
+
registerCrossProcessEventListener: jest.fn(),
|
|
34
29
|
}))
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
jest.mock('@open-mercato/shared/modules/events', () => ({
|
|
32
|
+
isBroadcastEvent: jest.fn(() => true),
|
|
33
|
+
}))
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
moduleId: 'stream_privacy_test',
|
|
41
|
-
events: [
|
|
42
|
-
{
|
|
43
|
-
id: 'stream_privacy_test.browser',
|
|
44
|
-
label: 'Browser event',
|
|
45
|
-
clientBroadcast: true,
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
id: 'stream_privacy_test.private',
|
|
49
|
-
label: 'Private cross-process invalidation',
|
|
50
|
-
crossProcessBroadcast: true,
|
|
51
|
-
},
|
|
52
|
-
] as const,
|
|
53
|
-
})
|
|
35
|
+
import { GET } from '@open-mercato/events/modules/events/api/stream/route'
|
|
54
36
|
|
|
55
37
|
// req.signal is a linked/derived signal in Node, so we spy AFTER the
|
|
56
38
|
// Request is constructed to intercept the handler's real calls.
|
|
@@ -132,12 +114,12 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
132
114
|
|
|
133
115
|
expect(mockGlobalEventTap).toBeDefined()
|
|
134
116
|
await mockGlobalEventTap?.(
|
|
135
|
-
'
|
|
117
|
+
'progress.job.updated',
|
|
136
118
|
{ tenantId: 't1', marker: 'must-not-arrive' },
|
|
137
119
|
{ tenantId: 't1', organizationId: 'o2' },
|
|
138
120
|
)
|
|
139
121
|
await mockGlobalEventTap?.(
|
|
140
|
-
'
|
|
122
|
+
'progress.job.updated',
|
|
141
123
|
{ tenantId: 't1', marker: 'expected' },
|
|
142
124
|
{ tenantId: 't1', organizationId: 'o1' },
|
|
143
125
|
)
|
|
@@ -149,124 +131,6 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
149
131
|
try { await reader.cancel() } catch {}
|
|
150
132
|
})
|
|
151
133
|
|
|
152
|
-
it('honors a trusted multi-organization audience for a clientBroadcast event', async () => {
|
|
153
|
-
const { req } = makeTrackedRequest()
|
|
154
|
-
const res = await GET(req)
|
|
155
|
-
expect(res.status).toBe(200)
|
|
156
|
-
|
|
157
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
158
|
-
await reader.read()
|
|
159
|
-
|
|
160
|
-
expect(mockGlobalEventTap).toBeDefined()
|
|
161
|
-
// Connection is scoped to org o1; an audience array that omits it must not deliver.
|
|
162
|
-
await mockGlobalEventTap?.(
|
|
163
|
-
'stream_privacy_test.browser',
|
|
164
|
-
{ tenantId: 't1', marker: 'must-not-arrive' },
|
|
165
|
-
{ tenantId: 't1', organizationIds: ['o2', 'o3'] },
|
|
166
|
-
)
|
|
167
|
-
// An audience array that includes o1 must deliver, even without a singular organizationId.
|
|
168
|
-
await mockGlobalEventTap?.(
|
|
169
|
-
'stream_privacy_test.browser',
|
|
170
|
-
{ tenantId: 't1', marker: 'expected' },
|
|
171
|
-
{ tenantId: 't1', organizationIds: ['o1', 'o2'] },
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
const { value, done } = await reader.read()
|
|
175
|
-
expect(done).toBe(false)
|
|
176
|
-
expect(new TextDecoder().decode(value)).toContain('"marker":"expected"')
|
|
177
|
-
|
|
178
|
-
try { await reader.cancel() } catch {}
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('does not deliver a private cross-process event to a same-organization browser', async () => {
|
|
182
|
-
const { req } = makeTrackedRequest()
|
|
183
|
-
const res = await GET(req)
|
|
184
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
185
|
-
await reader.read()
|
|
186
|
-
|
|
187
|
-
const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
|
|
188
|
-
| ((envelope: Record<string, unknown>) => Promise<void>)
|
|
189
|
-
| undefined
|
|
190
|
-
expect(listener).toBeDefined()
|
|
191
|
-
|
|
192
|
-
await listener?.({
|
|
193
|
-
event: 'stream_privacy_test.private',
|
|
194
|
-
payload: {
|
|
195
|
-
id: 'private-record',
|
|
196
|
-
tenantId: 't1',
|
|
197
|
-
organizationId: 'o1',
|
|
198
|
-
},
|
|
199
|
-
originPid: process.pid + 1,
|
|
200
|
-
originInstanceId: 'other-instance',
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
const pendingRead = reader.read().then(() => 'delivered')
|
|
204
|
-
const result = await Promise.race([
|
|
205
|
-
pendingRead,
|
|
206
|
-
new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
|
|
207
|
-
])
|
|
208
|
-
expect(result).toBe('not-delivered')
|
|
209
|
-
|
|
210
|
-
try { await reader.cancel() } catch {}
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
it('does not let a forged global-tap payload override trusted tenant and organization scope', async () => {
|
|
214
|
-
const { req } = makeTrackedRequest()
|
|
215
|
-
const res = await GET(req)
|
|
216
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
217
|
-
await reader.read()
|
|
218
|
-
|
|
219
|
-
const tap = registerGlobalEventTapMock.mock.calls[0]?.[0] as
|
|
220
|
-
| ((
|
|
221
|
-
event: string,
|
|
222
|
-
payload: Record<string, unknown>,
|
|
223
|
-
options?: { tenantId?: string | null; organizationId?: string | null },
|
|
224
|
-
) => Promise<void>)
|
|
225
|
-
| undefined
|
|
226
|
-
expect(tap).toBeDefined()
|
|
227
|
-
|
|
228
|
-
await tap?.(
|
|
229
|
-
'stream_privacy_test.browser',
|
|
230
|
-
{ tenantId: 't1', organizationId: 'o1', marker: 'forged-payload' },
|
|
231
|
-
{ tenantId: 'attacker-tenant', organizationId: 'attacker-org' },
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
const pendingRead = reader.read().then(() => 'delivered')
|
|
235
|
-
const result = await Promise.race([
|
|
236
|
-
pendingRead,
|
|
237
|
-
new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
|
|
238
|
-
])
|
|
239
|
-
expect(result).toBe('not-delivered')
|
|
240
|
-
|
|
241
|
-
try { await reader.cancel() } catch {}
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
it('delivers a global-tap event when trusted scope matches despite forged payload scope', async () => {
|
|
245
|
-
const { req } = makeTrackedRequest()
|
|
246
|
-
const res = await GET(req)
|
|
247
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
248
|
-
await reader.read()
|
|
249
|
-
|
|
250
|
-
const tap = registerGlobalEventTapMock.mock.calls[0]?.[0] as
|
|
251
|
-
| ((
|
|
252
|
-
event: string,
|
|
253
|
-
payload: Record<string, unknown>,
|
|
254
|
-
options?: { tenantId?: string | null; organizationId?: string | null },
|
|
255
|
-
) => Promise<void>)
|
|
256
|
-
| undefined
|
|
257
|
-
|
|
258
|
-
await tap?.(
|
|
259
|
-
'stream_privacy_test.browser',
|
|
260
|
-
{ tenantId: 'forged-tenant', organizationId: 'forged-org', marker: 'trusted-delivery' },
|
|
261
|
-
{ tenantId: 't1', organizationId: 'o1' },
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
const delivered = await reader.read()
|
|
265
|
-
expect(new TextDecoder().decode(delivered.value)).toContain('trusted-delivery')
|
|
266
|
-
|
|
267
|
-
try { await reader.cancel() } catch {}
|
|
268
|
-
})
|
|
269
|
-
|
|
270
134
|
it('ignores conflicting payload scope when trusted scope matches the connection', async () => {
|
|
271
135
|
const { req } = makeTrackedRequest()
|
|
272
136
|
const res = await GET(req)
|
|
@@ -277,7 +141,7 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
277
141
|
|
|
278
142
|
expect(mockGlobalEventTap).toBeDefined()
|
|
279
143
|
await mockGlobalEventTap?.(
|
|
280
|
-
'
|
|
144
|
+
'progress.job.updated',
|
|
281
145
|
{ tenantId: 'forged-tenant', organizationId: 'forged-organization', marker: 'expected' },
|
|
282
146
|
{ tenantId: 't1', organizationId: 'o1' },
|
|
283
147
|
)
|
|
@@ -289,35 +153,6 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
289
153
|
try { await reader.cancel() } catch {}
|
|
290
154
|
})
|
|
291
155
|
|
|
292
|
-
it('does not let a forged cross-process payload override trusted envelope scope', async () => {
|
|
293
|
-
const { req } = makeTrackedRequest()
|
|
294
|
-
const res = await GET(req)
|
|
295
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
296
|
-
await reader.read()
|
|
297
|
-
|
|
298
|
-
const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
|
|
299
|
-
| ((envelope: Record<string, unknown>) => Promise<void>)
|
|
300
|
-
| undefined
|
|
301
|
-
expect(listener).toBeDefined()
|
|
302
|
-
|
|
303
|
-
await listener?.({
|
|
304
|
-
event: 'stream_privacy_test.browser',
|
|
305
|
-
payload: { tenantId: 't1', organizationId: 'o1', marker: 'forged-payload' },
|
|
306
|
-
options: { tenantId: 'attacker-tenant', organizationId: 'attacker-org' },
|
|
307
|
-
originPid: process.pid + 1,
|
|
308
|
-
originInstanceId: 'other-instance',
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
const pendingRead = reader.read().then(() => 'delivered')
|
|
312
|
-
const result = await Promise.race([
|
|
313
|
-
pendingRead,
|
|
314
|
-
new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
|
|
315
|
-
])
|
|
316
|
-
expect(result).toBe('not-delivered')
|
|
317
|
-
|
|
318
|
-
try { await reader.cancel() } catch {}
|
|
319
|
-
})
|
|
320
|
-
|
|
321
156
|
it('preserves payload-authored scope for legacy emitters without a trusted scope marker', async () => {
|
|
322
157
|
const { req } = makeTrackedRequest()
|
|
323
158
|
const res = await GET(req)
|
|
@@ -328,7 +163,7 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
328
163
|
|
|
329
164
|
expect(mockGlobalEventTap).toBeDefined()
|
|
330
165
|
await mockGlobalEventTap?.(
|
|
331
|
-
'
|
|
166
|
+
'progress.job.updated',
|
|
332
167
|
{ tenantId: 't1', organizationId: 'o1', marker: 'legacy-expected' },
|
|
333
168
|
)
|
|
334
169
|
|
|
@@ -339,88 +174,6 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
339
174
|
try { await reader.cancel() } catch {}
|
|
340
175
|
})
|
|
341
176
|
|
|
342
|
-
it('delivers a cross-process event when trusted envelope scope matches despite forged payload scope', async () => {
|
|
343
|
-
const { req } = makeTrackedRequest()
|
|
344
|
-
const res = await GET(req)
|
|
345
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
346
|
-
await reader.read()
|
|
347
|
-
|
|
348
|
-
const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
|
|
349
|
-
| ((envelope: Record<string, unknown>) => Promise<void>)
|
|
350
|
-
| undefined
|
|
351
|
-
|
|
352
|
-
await listener?.({
|
|
353
|
-
event: 'stream_privacy_test.browser',
|
|
354
|
-
payload: { tenantId: 'forged-tenant', organizationId: 'forged-org', marker: 'trusted-envelope' },
|
|
355
|
-
options: { tenantId: 't1', organizationId: 'o1' },
|
|
356
|
-
originPid: process.pid + 1,
|
|
357
|
-
originInstanceId: 'other-instance',
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
const delivered = await reader.read()
|
|
361
|
-
expect(new TextDecoder().decode(delivered.value)).toContain('trusted-envelope')
|
|
362
|
-
|
|
363
|
-
try { await reader.cancel() } catch {}
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
it('delivers a rolling-deploy envelope that omits the instance id and shares this pid', async () => {
|
|
367
|
-
const { req } = makeTrackedRequest()
|
|
368
|
-
const res = await GET(req)
|
|
369
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
370
|
-
await reader.read()
|
|
371
|
-
|
|
372
|
-
const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
|
|
373
|
-
| ((envelope: Record<string, unknown>) => Promise<void>)
|
|
374
|
-
| undefined
|
|
375
|
-
expect(listener).toBeDefined()
|
|
376
|
-
|
|
377
|
-
// Containers commonly run as pid 1, so an older replica publishing without
|
|
378
|
-
// an instance id must not be mistaken for this process.
|
|
379
|
-
await listener?.({
|
|
380
|
-
event: 'stream_privacy_test.browser',
|
|
381
|
-
payload: { marker: 'legacy-replica' },
|
|
382
|
-
options: { tenantId: 't1', organizationId: 'o1' },
|
|
383
|
-
originPid: process.pid,
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
const pendingRead = reader.read().then((chunk) => new TextDecoder().decode(chunk.value))
|
|
387
|
-
const result = await Promise.race([
|
|
388
|
-
pendingRead,
|
|
389
|
-
new Promise<'dropped'>((resolve) => setTimeout(() => resolve('dropped'), 50)),
|
|
390
|
-
])
|
|
391
|
-
expect(result).toContain('legacy-replica')
|
|
392
|
-
|
|
393
|
-
try { await reader.cancel() } catch {}
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
it('suppresses an envelope published by this instance even when the pid differs', async () => {
|
|
397
|
-
const { req } = makeTrackedRequest()
|
|
398
|
-
const res = await GET(req)
|
|
399
|
-
const reader = (res.body as ReadableStream<Uint8Array>).getReader()
|
|
400
|
-
await reader.read()
|
|
401
|
-
|
|
402
|
-
const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
|
|
403
|
-
| ((envelope: Record<string, unknown>) => Promise<void>)
|
|
404
|
-
| undefined
|
|
405
|
-
|
|
406
|
-
await listener?.({
|
|
407
|
-
event: 'stream_privacy_test.browser',
|
|
408
|
-
payload: { marker: 'self-echo' },
|
|
409
|
-
options: { tenantId: 't1', organizationId: 'o1' },
|
|
410
|
-
originPid: process.pid + 1,
|
|
411
|
-
originInstanceId: 'web-instance',
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
const pendingRead = reader.read().then(() => 'delivered')
|
|
415
|
-
const result = await Promise.race([
|
|
416
|
-
pendingRead,
|
|
417
|
-
new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
|
|
418
|
-
])
|
|
419
|
-
expect(result).toBe('not-delivered')
|
|
420
|
-
|
|
421
|
-
try { await reader.cancel() } catch {}
|
|
422
|
-
})
|
|
423
|
-
|
|
424
177
|
it('does not fall back to payload scope when the trusted tenant marker is empty', async () => {
|
|
425
178
|
const { req } = makeTrackedRequest()
|
|
426
179
|
const res = await GET(req)
|
|
@@ -431,12 +184,12 @@ describe('SSE event stream — abort listener hygiene', () => {
|
|
|
431
184
|
|
|
432
185
|
expect(mockGlobalEventTap).toBeDefined()
|
|
433
186
|
await mockGlobalEventTap?.(
|
|
434
|
-
'
|
|
187
|
+
'progress.job.updated',
|
|
435
188
|
{ tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
|
|
436
189
|
{ tenantId: null, organizationId: null },
|
|
437
190
|
)
|
|
438
191
|
await mockGlobalEventTap?.(
|
|
439
|
-
'
|
|
192
|
+
'progress.job.updated',
|
|
440
193
|
{ marker: 'expected' },
|
|
441
194
|
{ tenantId: 't1', organizationId: 'o1' },
|
|
442
195
|
)
|
|
@@ -11,11 +11,7 @@
|
|
|
11
11
|
import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
|
|
12
12
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
13
13
|
import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
|
|
14
|
-
import {
|
|
15
|
-
CROSS_PROCESS_EVENT_INSTANCE_ID,
|
|
16
|
-
registerCrossProcessEventListener,
|
|
17
|
-
registerGlobalEventTap,
|
|
18
|
-
} from '../../../../bus'
|
|
14
|
+
import { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'
|
|
19
15
|
import type { EmitOptions } from '../../../../types'
|
|
20
16
|
|
|
21
17
|
export const metadata = {
|
|
@@ -68,9 +64,6 @@ function normalizeAudience(data: Record<string, unknown>, options?: EmitOptions)
|
|
|
68
64
|
: null
|
|
69
65
|
if (hasTrustedScope) {
|
|
70
66
|
if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)
|
|
71
|
-
for (const organizationId of collectStringValues(options?.organizationIds)) {
|
|
72
|
-
organizationScopes.add(organizationId)
|
|
73
|
-
}
|
|
74
67
|
} else {
|
|
75
68
|
if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {
|
|
76
69
|
organizationScopes.add(data.organizationId.trim())
|
|
@@ -211,12 +204,7 @@ function ensureGlobalTapSubscription(): void {
|
|
|
211
204
|
})
|
|
212
205
|
|
|
213
206
|
registerCrossProcessEventListener(async (envelope) => {
|
|
214
|
-
|
|
215
|
-
// envelope without one can only come from an older process during a
|
|
216
|
-
// rolling deploy and is always foreign. Falling back to originPid would
|
|
217
|
-
// discard it whenever both containers run under the same pid — commonly
|
|
218
|
-
// pid 1 — silently dropping browser events across the upgrade window.
|
|
219
|
-
if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return
|
|
207
|
+
if (envelope.originPid === process.pid) return
|
|
220
208
|
await broadcastEventToConnections(
|
|
221
209
|
envelope.event,
|
|
222
210
|
(envelope.payload ?? {}) as Record<string, unknown>,
|
package/src/types.ts
CHANGED
|
@@ -100,18 +100,6 @@ export type EmitOptions = {
|
|
|
100
100
|
tenantId?: string | null
|
|
101
101
|
/** Trusted organization scope for subscribers that must not rely on payload scope */
|
|
102
102
|
organizationId?: string | null
|
|
103
|
-
/**
|
|
104
|
-
* Trusted multi-organization audience for subscribers that must not rely on
|
|
105
|
-
* payload scope. Mirrors the SSE audience contract where a clientBroadcast
|
|
106
|
-
* event may target several organizations at once.
|
|
107
|
-
*/
|
|
108
|
-
organizationIds?: string[] | null
|
|
109
|
-
/**
|
|
110
|
-
* Module provenance stamped by `createModuleEvents` for private
|
|
111
|
-
* cross-process coordination events. Never derive this from payload data.
|
|
112
|
-
* @internal
|
|
113
|
-
*/
|
|
114
|
-
emitterModuleId?: string
|
|
115
103
|
/** Opt-in path for callers that need inline subscriber failures to reject the emit. */
|
|
116
104
|
rethrowHandlerErrors?: boolean
|
|
117
105
|
}
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
const publishCrossProcessEventMock = jest.fn(async () => undefined)
|
|
2
|
-
|
|
3
|
-
jest.mock('../bridge', () => ({
|
|
4
|
-
publishCrossProcessEvent: (...args: unknown[]) => publishCrossProcessEventMock(...args),
|
|
5
|
-
registerCrossProcessEventListener: jest.fn(),
|
|
6
|
-
CROSS_PROCESS_EVENT_INSTANCE_ID: 'test-instance',
|
|
7
|
-
}))
|
|
8
|
-
|
|
9
|
-
import { createModuleEvents, setGlobalEventBus } from '@open-mercato/shared/modules/events'
|
|
10
|
-
import { createEventBus } from '@open-mercato/events/index'
|
|
11
|
-
|
|
12
|
-
const testEventsConfig = createModuleEvents({
|
|
13
|
-
moduleId: 'cross_process_test',
|
|
14
|
-
events: [
|
|
15
|
-
{ id: 'cross_process_test.browser', label: 'Browser', clientBroadcast: true },
|
|
16
|
-
{ id: 'cross_process_test.private', label: 'Private', crossProcessBroadcast: true },
|
|
17
|
-
{ id: 'cross_process_test.local', label: 'Local' },
|
|
18
|
-
] as const,
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
describe('cross-process event publication', () => {
|
|
22
|
-
const resolve = ((name: string) => name) as never
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
publishCrossProcessEventMock.mockClear()
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('publishes browser and private cross-process events without changing local events', async () => {
|
|
29
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
30
|
-
const payload = { id: 'record-1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
31
|
-
const trustedScope = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
32
|
-
const privateTrustedScope = {
|
|
33
|
-
...trustedScope,
|
|
34
|
-
emitterModuleId: 'cross_process_test',
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
await bus.emit('cross_process_test.browser', payload, trustedScope)
|
|
38
|
-
await bus.emit('cross_process_test.private', payload, privateTrustedScope)
|
|
39
|
-
await bus.emit('cross_process_test.local', payload)
|
|
40
|
-
|
|
41
|
-
expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(2)
|
|
42
|
-
expect(publishCrossProcessEventMock).toHaveBeenNthCalledWith(
|
|
43
|
-
1,
|
|
44
|
-
'cross_process_test.browser',
|
|
45
|
-
payload,
|
|
46
|
-
trustedScope,
|
|
47
|
-
)
|
|
48
|
-
expect(publishCrossProcessEventMock).toHaveBeenNthCalledWith(
|
|
49
|
-
2,
|
|
50
|
-
'cross_process_test.private',
|
|
51
|
-
payload,
|
|
52
|
-
privateTrustedScope,
|
|
53
|
-
)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('does not treat caller-controlled payload scope as trusted bridge scope', async () => {
|
|
57
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
58
|
-
|
|
59
|
-
await bus.emit('cross_process_test.private', {
|
|
60
|
-
id: 'record-1',
|
|
61
|
-
tenantId: 'payload-tenant',
|
|
62
|
-
organizationId: 'payload-org',
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('preserves raw EventBus cross-process delivery for declared browser events', async () => {
|
|
69
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
70
|
-
const payload = {
|
|
71
|
-
id: 'record-1',
|
|
72
|
-
tenantId: ' legacy-tenant ',
|
|
73
|
-
organizationId: ' legacy-org ',
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
await bus.emit('cross_process_test.browser', payload)
|
|
77
|
-
|
|
78
|
-
expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
|
|
79
|
-
'cross_process_test.browser',
|
|
80
|
-
payload,
|
|
81
|
-
{ tenantId: 'legacy-tenant', organizationId: 'legacy-org' },
|
|
82
|
-
)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('prefers trusted workflow scope over browser-event payload scope', async () => {
|
|
86
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
87
|
-
const trustedScope = { tenantId: 'workflow-tenant', organizationId: 'workflow-org' }
|
|
88
|
-
const payload = {
|
|
89
|
-
id: 'record-1',
|
|
90
|
-
tenantId: 'forged-tenant',
|
|
91
|
-
organizationId: 'forged-org',
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
await bus.emit('cross_process_test.browser', payload, trustedScope)
|
|
95
|
-
|
|
96
|
-
expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
|
|
97
|
-
'cross_process_test.browser',
|
|
98
|
-
payload,
|
|
99
|
-
trustedScope,
|
|
100
|
-
)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('publishes from trusted options even when the payload has no scope', async () => {
|
|
104
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
105
|
-
|
|
106
|
-
await bus.emit(
|
|
107
|
-
'cross_process_test.private',
|
|
108
|
-
{ id: 'record-1' },
|
|
109
|
-
{
|
|
110
|
-
tenantId: 'trusted-tenant',
|
|
111
|
-
organizationId: 'trusted-org',
|
|
112
|
-
emitterModuleId: 'cross_process_test',
|
|
113
|
-
},
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
|
|
117
|
-
'cross_process_test.private',
|
|
118
|
-
{ id: 'record-1' },
|
|
119
|
-
{
|
|
120
|
-
tenantId: 'trusted-tenant',
|
|
121
|
-
organizationId: 'trusted-org',
|
|
122
|
-
emitterModuleId: 'cross_process_test',
|
|
123
|
-
},
|
|
124
|
-
)
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it('rejects private-event scope without declaring-module provenance', async () => {
|
|
128
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
129
|
-
const scope = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
130
|
-
|
|
131
|
-
await bus.emit('cross_process_test.private', { id: 'record-1' }, scope)
|
|
132
|
-
await bus.emit('cross_process_test.private', { id: 'record-1' }, {
|
|
133
|
-
...scope,
|
|
134
|
-
emitterModuleId: 'workflows',
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('preserves typed browser-event scope at the trusted module boundary', async () => {
|
|
141
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
142
|
-
setGlobalEventBus(bus)
|
|
143
|
-
const payload = { id: 'record-1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
144
|
-
|
|
145
|
-
await testEventsConfig.emit('cross_process_test.browser', payload)
|
|
146
|
-
|
|
147
|
-
expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
|
|
148
|
-
'cross_process_test.browser',
|
|
149
|
-
payload,
|
|
150
|
-
{
|
|
151
|
-
tenantId: 'tenant-1',
|
|
152
|
-
organizationId: 'org-1',
|
|
153
|
-
emitterModuleId: 'cross_process_test',
|
|
154
|
-
},
|
|
155
|
-
)
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
it('does not promote private-event payload scope at the module boundary', async () => {
|
|
159
|
-
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
160
|
-
setGlobalEventBus(bus)
|
|
161
|
-
|
|
162
|
-
await testEventsConfig.emit('cross_process_test.private', {
|
|
163
|
-
id: 'record-1',
|
|
164
|
-
tenantId: 'payload-tenant',
|
|
165
|
-
organizationId: 'payload-org',
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
|
|
169
|
-
})
|
|
170
|
-
})
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
const INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'
|
|
2
|
-
|
|
3
|
-
describe('cross-process bridge instance identity', () => {
|
|
4
|
-
const globalScope = globalThis as Record<string, unknown>
|
|
5
|
-
const originalInstanceId = globalScope[INSTANCE_ID_KEY]
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
delete globalScope[INSTANCE_ID_KEY]
|
|
9
|
-
jest.resetModules()
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
afterAll(() => {
|
|
13
|
-
if (originalInstanceId === undefined) delete globalScope[INSTANCE_ID_KEY]
|
|
14
|
-
else globalScope[INSTANCE_ID_KEY] = originalInstanceId
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('reuses one process identity across isolated module copies', () => {
|
|
18
|
-
let first: string | undefined
|
|
19
|
-
let second: string | undefined
|
|
20
|
-
|
|
21
|
-
jest.isolateModules(() => {
|
|
22
|
-
first = require('../bridge').CROSS_PROCESS_EVENT_INSTANCE_ID as string
|
|
23
|
-
})
|
|
24
|
-
jest.isolateModules(() => {
|
|
25
|
-
second = require('../bridge').CROSS_PROCESS_EVENT_INSTANCE_ID as string
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
expect(first).toEqual(expect.any(String))
|
|
29
|
-
expect(second).toBe(first)
|
|
30
|
-
})
|
|
31
|
-
})
|