@open-mercato/events 0.10.1-develop.7579.1.3db6f78f16 → 0.10.1-develop.7581.1.b3eee6e717
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 +1 -1
- package/dist/bridge.js +36 -2
- package/dist/bridge.js.map +2 -2
- package/dist/bus.js +10 -2
- package/dist/bus.js.map +2 -2
- package/package.json +3 -3
- package/src/__tests__/bridge-oversize-envelope.test.ts +162 -0
- package/src/__tests__/bridge-scope-serialization.test.ts +13 -6
- package/src/__tests__/cross-process-broadcast.test.ts +91 -0
- package/src/bridge.ts +40 -2
- package/src/bus.ts +18 -6
package/AGENTS.md
CHANGED
|
@@ -205,4 +205,4 @@ Every emit of a `clientBroadcast: true` event costs a serialized `pg_notify` rou
|
|
|
205
205
|
|
|
206
206
|
### Private cross-process coordination
|
|
207
207
|
|
|
208
|
-
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
|
|
208
|
+
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 `clientBroadcast`, `portalBroadcast` and `crossProcessBroadcast` events to the cross-process bridge (only with trusted tenant scope), while the DOM Event Bridge delivers only `clientBroadcast` events and the portal stream only `portalBroadcast` events — each consumer filters by its own flag and skips its own echo via `CROSS_PROCESS_EVENT_INSTANCE_ID` (never `originPid`). An envelope over the 7 KB `pg_notify` cap is truncated to `{ truncated: true, id/entityId/entityType }` plus its audience-scope fields (consumers re-fetch); private `crossProcessBroadcast` envelopes are never reshaped and are dropped when oversize. 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
|
@@ -2,9 +2,13 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { Client, Pool } from "pg";
|
|
3
3
|
import { getSslConfig } from "@open-mercato/shared/lib/db/ssl";
|
|
4
4
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
5
|
+
import { isPrivateCrossProcessBroadcastEvent } from "@open-mercato/shared/modules/events";
|
|
5
6
|
const BRIDGE_CHANNEL = "om_event_bridge";
|
|
6
7
|
const MAX_MESSAGE_BYTES = 7e3;
|
|
7
8
|
const RECONNECT_DELAY_MS = 1e3;
|
|
9
|
+
const TRUNCATED_ENTITY_REF_KEYS = ["id", "entityId", "entityType"];
|
|
10
|
+
const TRUNCATED_AUDIENCE_SCALAR_KEYS = ["tenantId", "organizationId", "recipientUserId", "recipientRoleId"];
|
|
11
|
+
const TRUNCATED_AUDIENCE_LIST_KEYS = ["organizationIds", "recipientUserIds", "recipientRoleIds"];
|
|
8
12
|
const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = "__openMercatoCrossProcessEventInstanceId__";
|
|
9
13
|
const logger = createLogger("events");
|
|
10
14
|
function getCrossProcessEventInstanceId() {
|
|
@@ -136,13 +140,43 @@ async function publishCrossProcessEvent(event, payload, options) {
|
|
|
136
140
|
originPid: process.pid,
|
|
137
141
|
originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID
|
|
138
142
|
};
|
|
139
|
-
const serialized =
|
|
140
|
-
if (
|
|
143
|
+
const serialized = serializeWithinLimit(envelope);
|
|
144
|
+
if (!serialized) {
|
|
141
145
|
logger.warn("Cross-process event dropped: payload exceeds size limit", { event, maxBytes: MAX_MESSAGE_BYTES });
|
|
142
146
|
return;
|
|
143
147
|
}
|
|
144
148
|
await pool.query("SELECT pg_notify($1, $2)", [BRIDGE_CHANNEL, serialized]);
|
|
145
149
|
}
|
|
150
|
+
function fitsMessageLimit(serialized) {
|
|
151
|
+
return Buffer.byteLength(serialized, "utf8") <= MAX_MESSAGE_BYTES;
|
|
152
|
+
}
|
|
153
|
+
function serializeWithinLimit(envelope) {
|
|
154
|
+
const serialized = JSON.stringify(envelope);
|
|
155
|
+
if (fitsMessageLimit(serialized)) return serialized;
|
|
156
|
+
if (isPrivateCrossProcessBroadcastEvent(envelope.event)) return null;
|
|
157
|
+
const truncated = JSON.stringify({ ...envelope, payload: buildTruncatedPayload(envelope.payload) });
|
|
158
|
+
if (!fitsMessageLimit(truncated)) return null;
|
|
159
|
+
logger.warn("Cross-process event truncated: payload exceeds size limit", {
|
|
160
|
+
event: envelope.event,
|
|
161
|
+
maxBytes: MAX_MESSAGE_BYTES
|
|
162
|
+
});
|
|
163
|
+
return truncated;
|
|
164
|
+
}
|
|
165
|
+
function buildTruncatedPayload(payload) {
|
|
166
|
+
const source = payload ?? {};
|
|
167
|
+
const truncated = { truncated: true };
|
|
168
|
+
for (const key of [...TRUNCATED_ENTITY_REF_KEYS, ...TRUNCATED_AUDIENCE_SCALAR_KEYS]) {
|
|
169
|
+
const value = source[key];
|
|
170
|
+
if (typeof value === "string" && value.trim().length > 0) truncated[key] = value.trim();
|
|
171
|
+
}
|
|
172
|
+
for (const key of TRUNCATED_AUDIENCE_LIST_KEYS) {
|
|
173
|
+
const value = source[key];
|
|
174
|
+
if (!Array.isArray(value)) continue;
|
|
175
|
+
const scopes = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
176
|
+
if (scopes.length > 0) truncated[key] = scopes;
|
|
177
|
+
}
|
|
178
|
+
return truncated;
|
|
179
|
+
}
|
|
146
180
|
function registerCrossProcessEventListener(listener) {
|
|
147
181
|
listeners.add(listener);
|
|
148
182
|
void ensureCrossProcessListener();
|
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 { randomUUID } from 'node:crypto'\nimport { 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\nconst CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'\n\nconst logger = createLogger('events')\n\n/**\n * PIDs can collide across containers, so self-echo suppression must not rely\n * on originPid alone. Every publisher stamps envelopes with this random\n * per-process instance id; consumers prefer it and fall back to originPid\n * only for envelopes published by older processes during a rolling deploy.\n */\nfunction getCrossProcessEventInstanceId(): string {\n const globalScope = globalThis as Record<string, unknown>\n const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY]\n if (typeof existing === 'string' && existing.length > 0) return existing\n const created = randomUUID()\n globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created\n return created\n}\n\nexport const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId()\n\ntype BridgeEnvelope = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n originPid: number\n originInstanceId?: string\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 originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,\n }\n\n const serialized =
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { Client, Pool } from 'pg'\nimport { getSslConfig } from '@open-mercato/shared/lib/db/ssl'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isPrivateCrossProcessBroadcastEvent } from '@open-mercato/shared/modules/events'\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\nconst TRUNCATED_ENTITY_REF_KEYS = ['id', 'entityId', 'entityType'] as const\nconst TRUNCATED_AUDIENCE_SCALAR_KEYS = ['tenantId', 'organizationId', 'recipientUserId', 'recipientRoleId'] as const\nconst TRUNCATED_AUDIENCE_LIST_KEYS = ['organizationIds', 'recipientUserIds', 'recipientRoleIds'] as const\nconst CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'\n\nconst logger = createLogger('events')\n\n/**\n * PIDs can collide across containers, so self-echo suppression must not rely\n * on originPid alone. Every publisher stamps envelopes with this random\n * per-process instance id; consumers prefer it and fall back to originPid\n * only for envelopes published by older processes during a rolling deploy.\n */\nfunction getCrossProcessEventInstanceId(): string {\n const globalScope = globalThis as Record<string, unknown>\n const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY]\n if (typeof existing === 'string' && existing.length > 0) return existing\n const created = randomUUID()\n globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created\n return created\n}\n\nexport const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId()\n\ntype BridgeEnvelope = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n originPid: number\n originInstanceId?: string\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 originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,\n }\n\n const serialized = serializeWithinLimit(envelope)\n if (!serialized) {\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\nfunction fitsMessageLimit(serialized: string): boolean {\n return Buffer.byteLength(serialized, 'utf8') <= MAX_MESSAGE_BYTES\n}\n\n/** Oversize browser-facing envelopes degrade to a scoped entity ref; private coordination is never reshaped, only dropped. */\nfunction serializeWithinLimit(envelope: BridgeEnvelope): string | null {\n const serialized = JSON.stringify(envelope)\n if (fitsMessageLimit(serialized)) return serialized\n if (isPrivateCrossProcessBroadcastEvent(envelope.event)) return null\n const truncated = JSON.stringify({ ...envelope, payload: buildTruncatedPayload(envelope.payload) })\n if (!fitsMessageLimit(truncated)) return null\n logger.warn('Cross-process event truncated: payload exceeds size limit', {\n event: envelope.event,\n maxBytes: MAX_MESSAGE_BYTES,\n })\n return truncated\n}\n\nfunction buildTruncatedPayload(payload: EventPayload): EventPayload {\n const source = (payload ?? {}) as Record<string, unknown>\n const truncated: Record<string, unknown> = { truncated: true }\n for (const key of [...TRUNCATED_ENTITY_REF_KEYS, ...TRUNCATED_AUDIENCE_SCALAR_KEYS]) {\n const value = source[key]\n if (typeof value === 'string' && value.trim().length > 0) truncated[key] = value.trim()\n }\n for (const key of TRUNCATED_AUDIENCE_LIST_KEYS) {\n const value = source[key]\n if (!Array.isArray(value)) continue\n const scopes = value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)\n if (scopes.length > 0) truncated[key] = scopes\n }\n return truncated\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
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
isBroadcastEvent,
|
|
9
9
|
isCoalescedBroadcastEvent,
|
|
10
10
|
isCrossProcessBroadcastEvent,
|
|
11
|
+
isPortalBroadcastEvent,
|
|
12
|
+
isPrivateCrossProcessBroadcastEvent,
|
|
11
13
|
isPrivateCrossProcessEventEmitter
|
|
12
14
|
} from "@open-mercato/shared/modules/events";
|
|
13
15
|
import {
|
|
@@ -34,9 +36,15 @@ function hasTrustedTenantScope(options) {
|
|
|
34
36
|
function normalizePayloadScope(value) {
|
|
35
37
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
36
38
|
}
|
|
39
|
+
function isBrowserBroadcastEvent(event) {
|
|
40
|
+
return isBroadcastEvent(event) || isPortalBroadcastEvent(event);
|
|
41
|
+
}
|
|
42
|
+
function shouldPublishCrossProcess(event) {
|
|
43
|
+
return isCrossProcessBroadcastEvent(event) || isPortalBroadcastEvent(event);
|
|
44
|
+
}
|
|
37
45
|
function resolveCrossProcessEmitOptions(event, payload, options) {
|
|
38
46
|
if (hasTrustedTenantScope(options)) return options;
|
|
39
|
-
if (!
|
|
47
|
+
if (!isBrowserBroadcastEvent(event) || isPrivateCrossProcessBroadcastEvent(event)) return options;
|
|
40
48
|
const tenantId = normalizePayloadScope(payload?.tenantId);
|
|
41
49
|
if (!tenantId) return options;
|
|
42
50
|
const organizationId = normalizePayloadScope(payload?.organizationId);
|
|
@@ -305,7 +313,7 @@ function createEventBus(opts) {
|
|
|
305
313
|
};
|
|
306
314
|
const publishToOtherProcesses = async () => {
|
|
307
315
|
const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options);
|
|
308
|
-
if (!
|
|
316
|
+
if (!shouldPublishCrossProcess(event) || !hasTrustedTenantScope(crossProcessOptions) || !isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)) return;
|
|
309
317
|
try {
|
|
310
318
|
await publishCrossProcessEvent(event, payload, crossProcessOptions);
|
|
311
319
|
} catch (error) {
|
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 isCoalescedBroadcastEvent,\n isCrossProcessBroadcastEvent,\n isPrivateCrossProcessEventEmitter,\n} from '@open-mercato/shared/modules/events'\nimport {\n BROADCAST_FLUSH_DEADLINE_MS,\n flushPendingBroadcasts,\n resolveBroadcastCoalesceIntervalMs,\n submitBroadcast,\n} from './broadcast-coalescer'\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 collectStringScopes(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\n// Sorted so an audience keys identically however the caller ordered it, and\n// explicitly comparator-ed per the #3620 guard.\nfunction sortScopes(scopes: Set<string>): string[] {\n return Array.from(scopes).sort((left, right) => left.localeCompare(right))\n}\n\n/**\n * Coalescing key for the browser dispatch of one event.\n *\n * Built from the raw union of every dimension the SSE audience filter\n * (`matchesAudience` in `events/api/stream/route.ts`) reads \u2014 tenant,\n * organization, recipient user and recipient role \u2014 taken from BOTH the\n * trusted `options` and the payload, without calling `resolveCrossProcessEmitOptions`\n * or any other resolver. Those resolvers pick one source to trust for delivery\n * semantics; the key instead has to key on every dimension a connection can be\n * filtered by, or one audience's emit would silently replace another\n * audience's pending delivery \u2014 recipient/role targeting and a portal-only\n * event (which carries no trusted `options` scope at all) included.\n *\n * Options and payload scope stay in separate segments, never merged: the three\n * filters each read a different source, so `options` org A + payload org B must\n * not key like the reverse. The options tenant segment encodes PRESENCE, not\n * just value \u2014 the backoffice `hasTrustedScope` is a presence test, so `{}` and\n * `{ tenantId: undefined }` take different filter branches and must not share\n * a key.\n *\n * Scope is part of the key by necessity, not for granularity: a key with no\n * scope segments would let a burst in one tenant suppress another tenant's\n * delivery and then deliver the first tenant's payload in its place.\n */\nfunction buildBroadcastCoalesceKey(event: string, payload: EventPayload, options?: EmitOptions): string {\n const data = (payload ?? {}) as Record<string, unknown>\n\n const payloadTenantId = normalizePayloadScope(data.tenantId) ?? ''\n const optionsTenantToken = options && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n ? `=${normalizePayloadScope(options.tenantId) ?? ''}`\n : 'absent'\n\n const optionsOrganizationScopes = new Set<string>()\n const optionsOrganizationId = normalizePayloadScope(options?.organizationId)\n if (optionsOrganizationId) optionsOrganizationScopes.add(optionsOrganizationId)\n for (const organizationId of collectStringScopes(options?.organizationIds)) {\n optionsOrganizationScopes.add(organizationId)\n }\n\n const payloadOrganizationScopes = new Set<string>()\n const payloadOrganizationId = normalizePayloadScope(data.organizationId)\n if (payloadOrganizationId) payloadOrganizationScopes.add(payloadOrganizationId)\n for (const organizationId of collectStringScopes(data.organizationIds)) {\n payloadOrganizationScopes.add(organizationId)\n }\n\n const recipientUserScopes = new Set<string>()\n const recipientUserId = normalizePayloadScope(data.recipientUserId)\n if (recipientUserId) recipientUserScopes.add(recipientUserId)\n for (const userId of collectStringScopes(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n const recipientRoleId = normalizePayloadScope(data.recipientRoleId)\n if (recipientRoleId) recipientRoleScopes.add(recipientRoleId)\n for (const roleId of collectStringScopes(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return [\n event,\n payloadTenantId,\n optionsTenantToken,\n sortScopes(payloadOrganizationScopes).join(','),\n sortScopes(optionsOrganizationScopes).join(','),\n sortScopes(recipientUserScopes).join(','),\n sortScopes(recipientRoleScopes).join(','),\n ].join('::')\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__'\nconst BROADCAST_COALESCER_SHUTDOWN_KEY = '__openMercatoBroadcastCoalescerShutdown__'\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 * Deliver the tail of any in-flight coalesced burst on the way out, so stopping\n * a worker mid-import does not leave browsers a window behind. Registered\n * independently of the producer hook: coalescing is in play whether or not this\n * process ever opened an async producer queue.\n *\n * `beforeExit` matters as much as the signals here. The trailing timer is\n * unref'd so a pending browser delivery never holds a process open, which means\n * a short-lived emitter \u2014 a CLI import, a one-shot script \u2014 reaches a natural\n * exit with the tail still queued and no signal ever fired. `beforeExit` runs\n * exactly when the loop has drained, which is precisely that case.\n *\n * `createEventBus` calls this in every process that bootstraps DI \u2014 the app,\n * every worker, and every `mercato` CLI command \u2014 so a bare `process.once`\n * listener here would take away Node's default SIGINT/SIGTERM exit process-wide:\n * once ANY listener is registered for a signal, Node stops exiting on it, and\n * this listener only flushes, never exits. A one-shot CLI command (seed,\n * import, reindex) with no graceful-shutdown handler of its own would then\n * swallow the first Ctrl+C or SIGTERM and keep running until SIGKILL. The\n * per-signal handler below flushes, then restores the default behavior itself\n * \u2014 by re-raising the signal \u2014 but only when it was the last listener\n * (`once` already removed itself before this body runs): a process with its\n * own graceful-shutdown handler (the queue worker, `mercato server`) keeps\n * that handler in charge of the actual exit, and this hook just adds the flush\n * alongside it. Every flush here is capped at BROADCAST_FLUSH_DEADLINE_MS, so a\n * hung dispatch cannot hold a stop until SIGKILL.\n */\nfunction registerBroadcastCoalescerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[BROADCAST_COALESCER_SHUTDOWN_KEY]) return\n const handleSignal = (signal: NodeJS.Signals) => {\n void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS })\n .catch(() => {})\n .finally(() => {\n if (process.listenerCount(signal) === 0) {\n process.kill(process.pid, signal)\n }\n })\n }\n process.once('SIGTERM', () => handleSignal('SIGTERM'))\n process.once('SIGINT', () => handleSignal('SIGINT'))\n process.once('beforeExit', () => {\n void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS }).catch(() => {})\n })\n ;(globalThis as Record<string, unknown>)[BROADCAST_COALESCER_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 registerBroadcastCoalescerShutdownHook()\n\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 runGlobalTaps = async (): 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\n // Resolved at call time, never hoisted. Private coordination always requires\n // trusted envelope scope and module provenance. Legacy declared browser events\n // may promote their typed scope for raw EventBus compatibility; tenant-managed\n // workflow adapters pass authoritative scope in options, which always wins over\n // payload values. Computing it lazily keeps the non-coalesced path resolving\n // scope at exactly the point it always did \u2014 after inline delivery \u2014 so an\n // event that did not opt in cannot observe this change at all.\n const publishToOtherProcesses = async (): Promise<void> => {\n const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)\n if (\n !isCrossProcessBroadcastEvent(event)\n || !hasTrustedTenantScope(crossProcessOptions)\n || !isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)\n ) return\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 // Global taps are not a browser-only sink \u2014 `registerGlobalEventTap` is a\n // public export that any process-local listener can use (the create-app\n // template's qa-events route captures events for integration tests through\n // it, and third-party modules can too), not just the two SSE endpoints. What\n // both taps and the SSE bridges have in common is that neither carries domain\n // meaning on its own, so an opted-in event may coalesce this dispatch \u2014 and\n // with it every tap's delivery \u2014 without touching subscriber, webhook or\n // queue semantics below. A tap on a coalesced event sees the same suppressed,\n // trailing-flush-guaranteed stream the browser does, not one call per record.\n const coalesceBrowserDelivery = isCoalescedBroadcastEvent(event)\n && resolveBroadcastCoalesceIntervalMs() > 0\n if (coalesceBrowserDelivery) {\n await submitBroadcast(\n buildBroadcastCoalesceKey(event, payload, options),\n async () => {\n await runGlobalTaps()\n await publishToOtherProcesses()\n },\n )\n } else {\n await runGlobalTaps()\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 // Already dispatched with the taps when this event coalesces its browser\n // delivery; otherwise it publishes here, exactly where it always has.\n if (!coalesceBrowserDelivery) {\n await publishToOtherProcesses()\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;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,mCAAmC,uCAAuC;AACnF,SAAS,gCAAgC;AAYzC,MAAM,oBAAoB;AAE1B,MAAM,SAAS,aAAa,QAAQ;AAyBpC,SAAS,0BAAmC;AAC1C,SAAO,0BAA0B;AACnC;AAGA,MAAM,wBAAwB;AAE9B,SAAS,sBAAsB,SAAgC;AAC7D,SAAO,OAAO,SAAS,aAAa,YAAY,QAAQ,SAAS,KAAK,EAAE,SAAS;AACnF;AAEA,SAAS,sBAAsB,OAA+B;AAC5D,SAAO,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,IAAI,MAAM,KAAK,IAAI;AAC/E;AAEA,SAAS,+BACP,OACA,SACA,SACyB;AACzB,MAAI,sBAAsB,OAAO,EAAG,QAAO;
|
|
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 isCoalescedBroadcastEvent,\n isCrossProcessBroadcastEvent,\n isPortalBroadcastEvent,\n isPrivateCrossProcessBroadcastEvent,\n isPrivateCrossProcessEventEmitter,\n} from '@open-mercato/shared/modules/events'\nimport {\n BROADCAST_FLUSH_DEADLINE_MS,\n flushPendingBroadcasts,\n resolveBroadcastCoalesceIntervalMs,\n submitBroadcast,\n} from './broadcast-coalescer'\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 isBrowserBroadcastEvent(event: string): boolean {\n return isBroadcastEvent(event) || isPortalBroadcastEvent(event)\n}\n\n/** Relayed over the bridge: both browser audiences plus private coordination; each consumer filters by its own flag. */\nfunction shouldPublishCrossProcess(event: string): boolean {\n return isCrossProcessBroadcastEvent(event) || isPortalBroadcastEvent(event)\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 and\n // portalBroadcast emitters. These callers are trusted server code and\n // historically supplied their scope in the typed payload. Tenant-managed\n // workflow execution passes scope in options, while private cross-process\n // events never take this path.\n if (!isBrowserBroadcastEvent(event) || isPrivateCrossProcessBroadcastEvent(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 collectStringScopes(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\n// Sorted so an audience keys identically however the caller ordered it, and\n// explicitly comparator-ed per the #3620 guard.\nfunction sortScopes(scopes: Set<string>): string[] {\n return Array.from(scopes).sort((left, right) => left.localeCompare(right))\n}\n\n/**\n * Coalescing key for the browser dispatch of one event.\n *\n * Built from the raw union of every dimension the SSE audience filter\n * (`matchesAudience` in `events/api/stream/route.ts`) reads \u2014 tenant,\n * organization, recipient user and recipient role \u2014 taken from BOTH the\n * trusted `options` and the payload, without calling `resolveCrossProcessEmitOptions`\n * or any other resolver. Those resolvers pick one source to trust for delivery\n * semantics; the key instead has to key on every dimension a connection can be\n * filtered by, or one audience's emit would silently replace another\n * audience's pending delivery \u2014 recipient/role targeting and a portal-only\n * event (which carries no trusted `options` scope at all) included.\n *\n * Options and payload scope stay in separate segments, never merged: the three\n * filters each read a different source, so `options` org A + payload org B must\n * not key like the reverse. The options tenant segment encodes PRESENCE, not\n * just value \u2014 the backoffice `hasTrustedScope` is a presence test, so `{}` and\n * `{ tenantId: undefined }` take different filter branches and must not share\n * a key.\n *\n * Scope is part of the key by necessity, not for granularity: a key with no\n * scope segments would let a burst in one tenant suppress another tenant's\n * delivery and then deliver the first tenant's payload in its place.\n */\nfunction buildBroadcastCoalesceKey(event: string, payload: EventPayload, options?: EmitOptions): string {\n const data = (payload ?? {}) as Record<string, unknown>\n\n const payloadTenantId = normalizePayloadScope(data.tenantId) ?? ''\n const optionsTenantToken = options && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n ? `=${normalizePayloadScope(options.tenantId) ?? ''}`\n : 'absent'\n\n const optionsOrganizationScopes = new Set<string>()\n const optionsOrganizationId = normalizePayloadScope(options?.organizationId)\n if (optionsOrganizationId) optionsOrganizationScopes.add(optionsOrganizationId)\n for (const organizationId of collectStringScopes(options?.organizationIds)) {\n optionsOrganizationScopes.add(organizationId)\n }\n\n const payloadOrganizationScopes = new Set<string>()\n const payloadOrganizationId = normalizePayloadScope(data.organizationId)\n if (payloadOrganizationId) payloadOrganizationScopes.add(payloadOrganizationId)\n for (const organizationId of collectStringScopes(data.organizationIds)) {\n payloadOrganizationScopes.add(organizationId)\n }\n\n const recipientUserScopes = new Set<string>()\n const recipientUserId = normalizePayloadScope(data.recipientUserId)\n if (recipientUserId) recipientUserScopes.add(recipientUserId)\n for (const userId of collectStringScopes(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n const recipientRoleId = normalizePayloadScope(data.recipientRoleId)\n if (recipientRoleId) recipientRoleScopes.add(recipientRoleId)\n for (const roleId of collectStringScopes(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return [\n event,\n payloadTenantId,\n optionsTenantToken,\n sortScopes(payloadOrganizationScopes).join(','),\n sortScopes(optionsOrganizationScopes).join(','),\n sortScopes(recipientUserScopes).join(','),\n sortScopes(recipientRoleScopes).join(','),\n ].join('::')\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__'\nconst BROADCAST_COALESCER_SHUTDOWN_KEY = '__openMercatoBroadcastCoalescerShutdown__'\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 * Deliver the tail of any in-flight coalesced burst on the way out, so stopping\n * a worker mid-import does not leave browsers a window behind. Registered\n * independently of the producer hook: coalescing is in play whether or not this\n * process ever opened an async producer queue.\n *\n * `beforeExit` matters as much as the signals here. The trailing timer is\n * unref'd so a pending browser delivery never holds a process open, which means\n * a short-lived emitter \u2014 a CLI import, a one-shot script \u2014 reaches a natural\n * exit with the tail still queued and no signal ever fired. `beforeExit` runs\n * exactly when the loop has drained, which is precisely that case.\n *\n * `createEventBus` calls this in every process that bootstraps DI \u2014 the app,\n * every worker, and every `mercato` CLI command \u2014 so a bare `process.once`\n * listener here would take away Node's default SIGINT/SIGTERM exit process-wide:\n * once ANY listener is registered for a signal, Node stops exiting on it, and\n * this listener only flushes, never exits. A one-shot CLI command (seed,\n * import, reindex) with no graceful-shutdown handler of its own would then\n * swallow the first Ctrl+C or SIGTERM and keep running until SIGKILL. The\n * per-signal handler below flushes, then restores the default behavior itself\n * \u2014 by re-raising the signal \u2014 but only when it was the last listener\n * (`once` already removed itself before this body runs): a process with its\n * own graceful-shutdown handler (the queue worker, `mercato server`) keeps\n * that handler in charge of the actual exit, and this hook just adds the flush\n * alongside it. Every flush here is capped at BROADCAST_FLUSH_DEADLINE_MS, so a\n * hung dispatch cannot hold a stop until SIGKILL.\n */\nfunction registerBroadcastCoalescerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[BROADCAST_COALESCER_SHUTDOWN_KEY]) return\n const handleSignal = (signal: NodeJS.Signals) => {\n void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS })\n .catch(() => {})\n .finally(() => {\n if (process.listenerCount(signal) === 0) {\n process.kill(process.pid, signal)\n }\n })\n }\n process.once('SIGTERM', () => handleSignal('SIGTERM'))\n process.once('SIGINT', () => handleSignal('SIGINT'))\n process.once('beforeExit', () => {\n void flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS }).catch(() => {})\n })\n ;(globalThis as Record<string, unknown>)[BROADCAST_COALESCER_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 registerBroadcastCoalescerShutdownHook()\n\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 runGlobalTaps = async (): 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\n // Resolved at call time, never hoisted. Private coordination always requires\n // trusted envelope scope and module provenance. Legacy declared browser events\n // may promote their typed scope for raw EventBus compatibility; tenant-managed\n // workflow adapters pass authoritative scope in options, which always wins over\n // payload values. Computing it lazily keeps the non-coalesced path resolving\n // scope at exactly the point it always did \u2014 after inline delivery \u2014 so an\n // event that did not opt in cannot observe this change at all.\n const publishToOtherProcesses = async (): Promise<void> => {\n const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)\n if (\n !shouldPublishCrossProcess(event)\n || !hasTrustedTenantScope(crossProcessOptions)\n || !isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)\n ) return\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 // Global taps are not a browser-only sink \u2014 `registerGlobalEventTap` is a\n // public export that any process-local listener can use (the create-app\n // template's qa-events route captures events for integration tests through\n // it, and third-party modules can too), not just the two SSE endpoints. What\n // both taps and the SSE bridges have in common is that neither carries domain\n // meaning on its own, so an opted-in event may coalesce this dispatch \u2014 and\n // with it every tap's delivery \u2014 without touching subscriber, webhook or\n // queue semantics below. A tap on a coalesced event sees the same suppressed,\n // trailing-flush-guaranteed stream the browser does, not one call per record.\n const coalesceBrowserDelivery = isCoalescedBroadcastEvent(event)\n && resolveBroadcastCoalesceIntervalMs() > 0\n if (coalesceBrowserDelivery) {\n await submitBroadcast(\n buildBroadcastCoalesceKey(event, payload, options),\n async () => {\n await runGlobalTaps()\n await publishToOtherProcesses()\n },\n )\n } else {\n await runGlobalTaps()\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 // Already dispatched with the taps when this event coalesces its browser\n // delivery; otherwise it publishes here, exactly where it always has.\n if (!coalesceBrowserDelivery) {\n await publishToOtherProcesses()\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;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,mCAAmC,uCAAuC;AACnF,SAAS,gCAAgC;AAYzC,MAAM,oBAAoB;AAE1B,MAAM,SAAS,aAAa,QAAQ;AAyBpC,SAAS,0BAAmC;AAC1C,SAAO,0BAA0B;AACnC;AAGA,MAAM,wBAAwB;AAE9B,SAAS,sBAAsB,SAAgC;AAC7D,SAAO,OAAO,SAAS,aAAa,YAAY,QAAQ,SAAS,KAAK,EAAE,SAAS;AACnF;AAEA,SAAS,sBAAsB,OAA+B;AAC5D,SAAO,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,IAAI,MAAM,KAAK,IAAI;AAC/E;AAEA,SAAS,wBAAwB,OAAwB;AACvD,SAAO,iBAAiB,KAAK,KAAK,uBAAuB,KAAK;AAChE;AAGA,SAAS,0BAA0B,OAAwB;AACzD,SAAO,6BAA6B,KAAK,KAAK,uBAAuB,KAAK;AAC5E;AAEA,SAAS,+BACP,OACA,SACA,SACyB;AACzB,MAAI,sBAAsB,OAAO,EAAG,QAAO;AAM3C,MAAI,CAAC,wBAAwB,KAAK,KAAK,oCAAoC,KAAK,EAAG,QAAO;AAC1F,QAAM,WAAW,sBAAuB,SAAqC,QAAQ;AACrF,MAAI,CAAC,SAAU,QAAO;AACtB,QAAM,iBAAiB,sBAAuB,SAAqC,cAAc;AACjG,QAAM,uBAAwB,SAAqC;AACnE,QAAM,kBAAkB,MAAM,QAAQ,oBAAoB,IACtD,qBAAqB,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ,IACjF,CAAC;AACL,SAAO;AAAA,IACL,GAAG;AAAA,IACH;AAAA,IACA,GAAI,iBAAiB,EAAE,eAAe,IAAI,CAAC;AAAA,IAC3C,GAAI,gBAAgB,SAAS,IAAI,EAAE,gBAAgB,IAAI,CAAC;AAAA,EAC1D;AACF;AAEA,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;AAIA,SAAS,WAAW,QAA+B;AACjD,SAAO,MAAM,KAAK,MAAM,EAAE,KAAK,CAAC,MAAM,UAAU,KAAK,cAAc,KAAK,CAAC;AAC3E;AA0BA,SAAS,0BAA0B,OAAe,SAAuB,SAA+B;AACtG,QAAM,OAAQ,WAAW,CAAC;AAE1B,QAAM,kBAAkB,sBAAsB,KAAK,QAAQ,KAAK;AAChE,QAAM,qBAAqB,WAAW,OAAO,UAAU,eAAe,KAAK,SAAS,UAAU,IAC1F,IAAI,sBAAsB,QAAQ,QAAQ,KAAK,EAAE,KACjD;AAEJ,QAAM,4BAA4B,oBAAI,IAAY;AAClD,QAAM,wBAAwB,sBAAsB,SAAS,cAAc;AAC3E,MAAI,sBAAuB,2BAA0B,IAAI,qBAAqB;AAC9E,aAAW,kBAAkB,oBAAoB,SAAS,eAAe,GAAG;AAC1E,8BAA0B,IAAI,cAAc;AAAA,EAC9C;AAEA,QAAM,4BAA4B,oBAAI,IAAY;AAClD,QAAM,wBAAwB,sBAAsB,KAAK,cAAc;AACvE,MAAI,sBAAuB,2BAA0B,IAAI,qBAAqB;AAC9E,aAAW,kBAAkB,oBAAoB,KAAK,eAAe,GAAG;AACtE,8BAA0B,IAAI,cAAc;AAAA,EAC9C;AAEA,QAAM,sBAAsB,oBAAI,IAAY;AAC5C,QAAM,kBAAkB,sBAAsB,KAAK,eAAe;AAClE,MAAI,gBAAiB,qBAAoB,IAAI,eAAe;AAC5D,aAAW,UAAU,oBAAoB,KAAK,gBAAgB,GAAG;AAC/D,wBAAoB,IAAI,MAAM;AAAA,EAChC;AAEA,QAAM,sBAAsB,oBAAI,IAAY;AAC5C,QAAM,kBAAkB,sBAAsB,KAAK,eAAe;AAClE,MAAI,gBAAiB,qBAAoB,IAAI,eAAe;AAC5D,aAAW,UAAU,oBAAoB,KAAK,gBAAgB,GAAG;AAC/D,wBAAoB,IAAI,MAAM;AAAA,EAChC;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW,yBAAyB,EAAE,KAAK,GAAG;AAAA,IAC9C,WAAW,yBAAyB,EAAE,KAAK,GAAG;AAAA,IAC9C,WAAW,mBAAmB,EAAE,KAAK,GAAG;AAAA,IACxC,WAAW,mBAAmB,EAAE,KAAK,GAAG;AAAA,EAC1C,EAAE,KAAK,IAAI;AACb;AAEA,SAAS,qBAA0C;AACjD,QAAM,WAAY,WAAuC,qBAAqB;AAC9E,MAAI,oBAAoB,KAAK;AAC3B,WAAO;AAAA,EACT;AACA,QAAM,UAAU,oBAAI,IAAoB;AACvC,EAAC,WAAuC,qBAAqB,IAAI;AAClE,SAAO;AACT;AAEO,SAAS,uBAAuB,SAAqC;AAC1E,QAAM,OAAO,mBAAmB;AAChC,OAAK,IAAI,OAAO;AAChB,SAAO,MAAM;AACX,SAAK,OAAO,OAAO;AAAA,EACrB;AACF;AA0BA,MAAM,4BAA4B;AAClC,MAAM,+BAA+B;AACrC,MAAM,mCAAmC;AAEzC,SAAS,0BAAmC;AAC1C,SAAO,wBAAwB,QAAQ,IAAI,2BAA2B,IAAI;AAC5E;AAEA,SAAS,2BAA6D;AACpE,QAAM,WAAY,WAAuC,yBAAyB;AAClF,MAAI,oBAAoB,KAAK;AAC3B,WAAO;AAAA,EACT;AACA,QAAM,UAAU,oBAAI,IAAiC;AACpD,EAAC,WAAuC,yBAAyB,IAAI;AACtE,SAAO;AACT;AAEA,SAAS,+BAAqC;AAC5C,MAAK,WAAuC,4BAA4B,EAAG;AAC3E,QAAM,WAAW,MAAM;AACrB,UAAM,WAAW,yBAAyB;AAC1C,eAAW,eAAe,SAAS,OAAO,GAAG;AAC3C,cAAQ,QAAQ,YAAY,MAAM,CAAC,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACrD;AACA,aAAS,MAAM;AAAA,EACjB;AACA,UAAQ,KAAK,WAAW,QAAQ;AAChC,UAAQ,KAAK,UAAU,QAAQ;AAC9B,EAAC,WAAuC,4BAA4B,IAAI;AAC3E;AA6BA,SAAS,yCAA+C;AACtD,MAAK,WAAuC,gCAAgC,EAAG;AAC/E,QAAM,eAAe,CAAC,WAA2B;AAC/C,SAAK,uBAAuB,EAAE,YAAY,4BAA4B,CAAC,EACpE,MAAM,MAAM;AAAA,IAAC,CAAC,EACd,QAAQ,MAAM;AACb,UAAI,QAAQ,cAAc,MAAM,MAAM,GAAG;AACvC,gBAAQ,KAAK,QAAQ,KAAK,MAAM;AAAA,MAClC;AAAA,IACF,CAAC;AAAA,EACL;AACA,UAAQ,KAAK,WAAW,MAAM,aAAa,SAAS,CAAC;AACrD,UAAQ,KAAK,UAAU,MAAM,aAAa,QAAQ,CAAC;AACnD,UAAQ,KAAK,cAAc,MAAM;AAC/B,SAAK,uBAAuB,EAAE,YAAY,4BAA4B,CAAC,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACzF,CAAC;AACA,EAAC,WAAuC,gCAAgC,IAAI;AAC/E;AAgCO,SAAS,eAAe,MAAkC;AAC/D,yCAAuC;AAGvC,QAAM,YAAY,oBAAI,IAAuC;AAG7D,QAAM,wBAAwB,oBAAI,IAA0B;AAG5D,QAAM,gBAAgB,KAAK,kBACxB,QAAQ,IAAI,mBAAmB,UAAU,UAAU;AAGtD,MAAI,QAAoC;AAUxC,WAAS,WAAgC;AACvC,QAAI,kBAAkB,WAAW,CAAC,wBAAwB,GAAG;AAC3D,UAAI,CAAC,OAAO;AACV,gBAAQ,kBAAkB,UACtB,YAA0B,mBAAmB,SAAS;AAAA,UACpD,YAAY,EAAE,KAAK,mBAAmB,OAAO,EAAE;AAAA,QACjD,CAAC,IACD,YAA0B,mBAAmB,OAAO;AAAA,MAC1D;AACA,aAAO;AAAA,IACT;AAEA,UAAM,WAAW,mBAAmB,OAAO;AAC3C,UAAM,WAAW,yBAAyB;AAC1C,UAAM,WAAW,SAAS,QAAQ;AAClC,QAAI,SAAS,SAAS,IAAI,QAAQ;AAClC,QAAI,CAAC,QAAQ;AACX,eAAS,YAA0B,mBAAmB,SAAS;AAAA,QAC7D,YAAY,EAAE,KAAK,SAAS;AAAA,MAC9B,CAAC;AACD,eAAS,IAAI,UAAU,MAAM;AAC7B,mCAA6B;AAAA,IAC/B;AACA,WAAO;AAAA,EACT;AAYA,iBAAe,QACb,OACA,SACA,SACA,iBAAiB,OACwB;AACzC,QAAI,qBAAqB;AAEzB,eAAW,CAAC,SAAS,QAAQ,KAAK,WAAW;AAC3C,UAAI,CAAC,kBAAkB,OAAO,OAAO,EAAG;AACxC,UAAI,CAAC,YAAY,SAAS,SAAS,EAAG;AAEtC,iBAAW,cAAc,UAAU;AAGjC,YAAI,kBAAkB,sBAAsB,IAAI,UAAU,EAAG;AAC7D,YAAI;AAEF,gBAAM,WAAW,WAAW,YAAY,4BAA4B,WAAW,EAAE;AACjF,gBAAM;AAAA,YACJ;AAAA,cACE;AAAA,cACA,SAAS;AAAA,cACT,WAAW,GAAG,KAAK,OAAO,WAAW,MAAM,WAAW,KAAK;AAAA,cAC3D,YAAY,WAAW,MAAM,WAAW;AAAA,YAC1C;AAAA,YACA,MAAM,WAAW,QAAQ,SAAS;AAAA,cAChC,SAAS,KAAK;AAAA,cACd,WAAW;AAAA,cACX,UAAU,SAAS,YAAY;AAAA,cAC/B,gBAAgB,SAAS,kBAAkB;AAAA,YAC7C,CAAC;AAAA,UACH;AAAA,QACF,SAAS,OAAO;AACd,cAAI,sBAAsB,IAAI,UAAU,GAAG;AACzC,kCAAsB;AAAA,UACxB;AACA,iBAAO,MAAM,iBAAiB,EAAE,OAAO,SAAS,KAAK,MAAM,CAAC;AAC5D,cAAI,SAAS,sBAAsB;AACjC,kBAAM;AAAA,UACR;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,EAAE,mBAAmB;AAAA,EAC9B;AAqBA,WAAS,wBAAwB,OAAuC;AACtE,UAAM,UAAkC,CAAC;AACzC,eAAW,CAAC,SAAS,QAAQ,KAAK,WAAW;AAC3C,UAAI,CAAC,kBAAkB,OAAO,OAAO,GAAG;AACtC;AAAA,MACF;AACA,iBAAW,cAAc,UAAU;AACjC,YAAI,WAAW,YAAY;AACzB,kBAAQ,KAAK,UAAU;AAAA,QACzB;AAAA,MACF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAkBA,iBAAe,eACb,OACA,SACA,SACA,SACiC;AACjC,UAAM,cAAc,wBAAwB,KAAK;AACjD,QAAI,YAAY,WAAW,GAAG;AAC5B,aAAO,CAAC;AAAA,IACV;AAEA,UAAM,WAAW,WAAW,KAAK;AAEjC,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC5B,YAAY;AAAA,QAAI,CAAC,eACf;AAAA,UACE;AAAA,YACE,UAAU,WAAW,YAAY,4BAA4B,WAAW,EAAE;AAAA,YAC1E,SAAS;AAAA,YACT,WAAW,GAAG,KAAK,OAAO,WAAW,MAAM,WAAW,KAAK;AAAA,YAC3D,YAAY,WAAW,MAAM,WAAW;AAAA,UAC1C;AAAA,UACA,MAAM,WAAW,QAAQ,SAAS;AAAA,YAChC,SAAS;AAAA,YACT,WAAW;AAAA,YACX,UAAU,SAAS,YAAY;AAAA,YAC/B,gBAAgB,SAAS,kBAAkB;AAAA,UAC7C,CAAC;AAAA,QACH;AAAA,MACF;AAAA,IACF;AAEA,WAAO,QAAQ,IAAI,CAAC,QAAQ,UAAU;AACpC,YAAM,eAAe,YAAY,KAAK,EAAE,MAAM,YAAY,KAAK,EAAE;AACjE,aAAO,OAAO,WAAW,aACrB,EAAE,cAAc,IAAI,OAAO,OAAO,OAAO,OAAO,IAChD,EAAE,cAAc,IAAI,KAAK;AAAA,IAC/B,CAAC;AAAA,EACH;AAQA,WAAS,GACP,OACA,SACA,SACM;AACN,QAAI,CAAC,UAAU,IAAI,KAAK,GAAG;AACzB,gBAAU,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IAChC;AACA,UAAM,aAAmC;AAAA,MACvC,IAAI,SAAS;AAAA,MACb,UAAU,SAAS;AAAA,MACnB;AAAA,MACA;AAAA,MACA,YAAY,SAAS;AAAA,IACvB;AACA,cAAU,IAAI,KAAK,EAAG,IAAI,UAAU;AACpC,QAAI,SAAS,YAAY;AACvB,4BAAsB,IAAI,UAAU;AAAA,IACtC;AAAA,EACF;AAKA,WAAS,0BAA0B,MAAoC;AACrE,eAAW,OAAO,MAAM;AACtB,SAAG,IAAI,OAAO,IAAI,SAAS,EAAE,YAAY,IAAI,YAAY,UAAU,IAAI,UAAU,IAAI,IAAI,GAAG,CAAC;AAAA,IAC/F;AAAA,EACF;AAOA,iBAAe,KACb,OACA,SACA,SACe;AACf,UAAM,gBAAgB,YAA2B;AAC/C,YAAM,OAAO,mBAAmB;AAChC,iBAAW,OAAO,MAAM;AACtB,YAAI;AACF,gBAAM,QAAQ,QAAQ,IAAI,OAAO,SAAS,OAAO,CAAC;AAAA,QACpD,SAAS,OAAO;AACd,iBAAO,MAAM,oBAAoB,EAAE,OAAO,KAAK,MAAM,CAAC;AAAA,QACxD;AAAA,MACF;AAAA,IACF;AASA,UAAM,0BAA0B,YAA2B;AACzD,YAAM,sBAAsB,+BAA+B,OAAO,SAAS,OAAO;AAClF,UACE,CAAC,0BAA0B,KAAK,KAC7B,CAAC,sBAAsB,mBAAmB,KAC1C,CAAC,kCAAkC,OAAO,qBAAqB,eAAe,EACjF;AACF,UAAI;AACF,cAAM,yBAAyB,OAAO,SAAS,mBAAmB;AAAA,MACpE,SAAS,OAAO;AACd,eAAO,MAAM,+BAA+B,EAAE,OAAO,KAAK,MAAM,CAAC;AAAA,MACnE;AAAA,IACF;AAWA,UAAM,0BAA0B,0BAA0B,KAAK,KAC1D,mCAAmC,IAAI;AAC5C,QAAI,yBAAyB;AAC3B,YAAM;AAAA,QACJ,0BAA0B,OAAO,SAAS,OAAO;AAAA,QACjD,YAAY;AACV,gBAAM,cAAc;AACpB,gBAAM,wBAAwB;AAAA,QAChC;AAAA,MACF;AAAA,IACF,OAAO;AACL,YAAM,cAAc;AAAA,IACtB;AAWA,UAAM,cAAc,QAAQ,SAAS,UAAU,KAAK,SAAS,kBAAkB;AAE/E,UAAM,iBAAiB,wBAAwB;AAC/C,UAAM,uBAAuB,SAAS,oCAAoC,QACpE,QAAQ,SAAS,UAAU,KAAK;AACtC,QAAI,yBAAyB;AAC7B,QAAI,CAAC,aAAa;AAChB,YAAM,YAAY,MAAM,QAAQ,OAAO,SAAS,SAAS,oBAAoB;AAC7E,+BAAyB,UAAU,qBAAqB;AAAA,IAC1D;AAIA,QAAI,CAAC,yBAAyB;AAC5B,YAAM,wBAAwB;AAAA,IAChC;AAGA,QAAI,SAAS,YAAY;AACvB,YAAM,IAAI,SAAS;AACnB,YAAM,EAAE,QAAQ;AAAA,QACd;AAAA,QACA;AAAA,QACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAQA,2BAA2B,CAAC,eAAe,CAAC,wBAAwB,CAAC;AAAA,MACvE,CAAC;AAAA,IACH;AAAA,EACF;AAKA,iBAAe,aAA2C;AACxD,UAAM,IAAI,SAAS;AACnB,WAAO,EAAE,MAAM;AAAA,EACjB;AAGA,QAAM,YAAY;AAElB,SAAO;AAAA,IACL;AAAA,IACA;AAAA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;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.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7581.1.b3eee6e717",
|
|
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.10.1-develop.
|
|
37
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
36
|
+
"@open-mercato/queue": "0.10.1-develop.7581.1.b3eee6e717",
|
|
37
|
+
"@open-mercato/shared": "0.10.1-develop.7581.1.b3eee6e717",
|
|
38
38
|
"pg": "8.23.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const query = jest.fn(async () => undefined)
|
|
2
|
+
const warn = jest.fn()
|
|
3
|
+
|
|
4
|
+
jest.mock('pg', () => ({
|
|
5
|
+
Client: jest.fn(),
|
|
6
|
+
Pool: jest.fn(() => ({ query })),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/db/ssl', () => ({
|
|
10
|
+
getSslConfig: jest.fn(() => null),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
14
|
+
const createMockLogger = (): Record<string, unknown> => {
|
|
15
|
+
const mockLogger: Record<string, unknown> = {
|
|
16
|
+
error: jest.fn(),
|
|
17
|
+
warn: (...args: unknown[]) => warn(...args),
|
|
18
|
+
info: jest.fn(),
|
|
19
|
+
debug: jest.fn(),
|
|
20
|
+
}
|
|
21
|
+
mockLogger.child = () => mockLogger
|
|
22
|
+
return mockLogger
|
|
23
|
+
}
|
|
24
|
+
return { createLogger: jest.fn(() => createMockLogger()) }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
import { createModuleEvents } from '@open-mercato/shared/modules/events'
|
|
28
|
+
import { publishCrossProcessEvent } from '../bridge'
|
|
29
|
+
|
|
30
|
+
createModuleEvents({
|
|
31
|
+
moduleId: 'bridge_oversize_test',
|
|
32
|
+
events: [
|
|
33
|
+
{ id: 'bridge_oversize_test.portal', label: 'Portal', portalBroadcast: true },
|
|
34
|
+
{ id: 'bridge_oversize_test.browser', label: 'Browser', clientBroadcast: true },
|
|
35
|
+
{ id: 'bridge_oversize_test.private', label: 'Private', crossProcessBroadcast: true },
|
|
36
|
+
] as const,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
type SerializedEnvelope = {
|
|
40
|
+
event: string
|
|
41
|
+
payload: Record<string, unknown>
|
|
42
|
+
options?: Record<string, unknown>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lastPublishedEnvelope(): SerializedEnvelope {
|
|
46
|
+
const serialized = query.mock.calls.at(-1)?.[1]?.[1]
|
|
47
|
+
expect(typeof serialized).toBe('string')
|
|
48
|
+
return JSON.parse(serialized as string) as SerializedEnvelope
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const oversizeBody = 'x'.repeat(10_000)
|
|
52
|
+
|
|
53
|
+
describe('cross-process bridge oversize envelopes', () => {
|
|
54
|
+
const originalDatabaseUrl = process.env.DATABASE_URL
|
|
55
|
+
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
process.env.DATABASE_URL = 'postgres://postgres:postgres@localhost:5432/mercato'
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL
|
|
62
|
+
else process.env.DATABASE_URL = originalDatabaseUrl
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
query.mockClear()
|
|
67
|
+
warn.mockClear()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('publishes envelopes within the limit unchanged', async () => {
|
|
71
|
+
const payload = { id: 'ticket-1', tenantId: 'tenant-1', body: 'short' }
|
|
72
|
+
|
|
73
|
+
await publishCrossProcessEvent('bridge_oversize_test.portal', payload, { tenantId: 'tenant-1' })
|
|
74
|
+
|
|
75
|
+
expect(lastPublishedEnvelope().payload).toEqual(payload)
|
|
76
|
+
expect(warn).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('truncates an oversize portal envelope to an entity ref that keeps the audience scope', async () => {
|
|
80
|
+
const options = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
81
|
+
|
|
82
|
+
await publishCrossProcessEvent(
|
|
83
|
+
'bridge_oversize_test.portal',
|
|
84
|
+
{
|
|
85
|
+
id: ' ticket-1 ',
|
|
86
|
+
entityType: 'staff:support_ticket',
|
|
87
|
+
tenantId: 'tenant-1',
|
|
88
|
+
organizationId: 'org-1',
|
|
89
|
+
organizationIds: ['org-1', 42, ''],
|
|
90
|
+
recipientUserId: 'customer-1',
|
|
91
|
+
recipientUserIds: ['customer-1', 'customer-2'],
|
|
92
|
+
recipientRoleIds: ['role-1'],
|
|
93
|
+
body: oversizeBody,
|
|
94
|
+
},
|
|
95
|
+
options,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(query).toHaveBeenCalledTimes(1)
|
|
99
|
+
const envelope = lastPublishedEnvelope()
|
|
100
|
+
expect(envelope.event).toBe('bridge_oversize_test.portal')
|
|
101
|
+
expect(envelope.options).toEqual(options)
|
|
102
|
+
expect(envelope.payload).toEqual({
|
|
103
|
+
truncated: true,
|
|
104
|
+
id: 'ticket-1',
|
|
105
|
+
entityType: 'staff:support_ticket',
|
|
106
|
+
tenantId: 'tenant-1',
|
|
107
|
+
organizationId: 'org-1',
|
|
108
|
+
organizationIds: ['org-1'],
|
|
109
|
+
recipientUserId: 'customer-1',
|
|
110
|
+
recipientUserIds: ['customer-1', 'customer-2'],
|
|
111
|
+
recipientRoleIds: ['role-1'],
|
|
112
|
+
})
|
|
113
|
+
expect(warn).toHaveBeenCalledWith(
|
|
114
|
+
'Cross-process event truncated: payload exceeds size limit',
|
|
115
|
+
expect.objectContaining({ event: 'bridge_oversize_test.portal' }),
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('truncates an oversize clientBroadcast envelope the same way', async () => {
|
|
120
|
+
await publishCrossProcessEvent(
|
|
121
|
+
'bridge_oversize_test.browser',
|
|
122
|
+
{ entityId: 'record-1', tenantId: 'tenant-1', body: oversizeBody },
|
|
123
|
+
{ tenantId: 'tenant-1' },
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
expect(lastPublishedEnvelope().payload).toEqual({
|
|
127
|
+
truncated: true,
|
|
128
|
+
entityId: 'record-1',
|
|
129
|
+
tenantId: 'tenant-1',
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('drops an oversize private crossProcessBroadcast envelope instead of reshaping it', async () => {
|
|
134
|
+
await publishCrossProcessEvent(
|
|
135
|
+
'bridge_oversize_test.private',
|
|
136
|
+
{ id: 'record-1', tenantId: 'tenant-1', body: oversizeBody },
|
|
137
|
+
{ tenantId: 'tenant-1', emitterModuleId: 'bridge_oversize_test' },
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
expect(query).not.toHaveBeenCalled()
|
|
141
|
+
expect(warn).toHaveBeenCalledWith(
|
|
142
|
+
'Cross-process event dropped: payload exceeds size limit',
|
|
143
|
+
expect.objectContaining({ event: 'bridge_oversize_test.private' }),
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('drops an envelope that still exceeds the limit after truncation', async () => {
|
|
148
|
+
const recipientUserIds = Array.from({ length: 600 }, (entry, index) => `customer-${index}`)
|
|
149
|
+
|
|
150
|
+
await publishCrossProcessEvent(
|
|
151
|
+
'bridge_oversize_test.portal',
|
|
152
|
+
{ id: 'ticket-1', tenantId: 'tenant-1', recipientUserIds },
|
|
153
|
+
{ tenantId: 'tenant-1' },
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
expect(query).not.toHaveBeenCalled()
|
|
157
|
+
expect(warn).toHaveBeenCalledWith(
|
|
158
|
+
'Cross-process event dropped: payload exceeds size limit',
|
|
159
|
+
expect.objectContaining({ event: 'bridge_oversize_test.portal' }),
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -9,12 +9,19 @@ jest.mock('@open-mercato/shared/lib/db/ssl', () => ({
|
|
|
9
9
|
getSslConfig: jest.fn(() => null),
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
|
-
jest.mock('@open-mercato/shared/lib/logger', () =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
13
|
+
const createMockLogger = (): Record<string, unknown> => {
|
|
14
|
+
const mockLogger: Record<string, unknown> = {
|
|
15
|
+
error: jest.fn(),
|
|
16
|
+
warn: jest.fn(),
|
|
17
|
+
info: jest.fn(),
|
|
18
|
+
debug: jest.fn(),
|
|
19
|
+
}
|
|
20
|
+
mockLogger.child = () => mockLogger
|
|
21
|
+
return mockLogger
|
|
22
|
+
}
|
|
23
|
+
return { createLogger: jest.fn(() => createMockLogger()) }
|
|
24
|
+
})
|
|
18
25
|
|
|
19
26
|
import { publishCrossProcessEvent } from '../bridge'
|
|
20
27
|
|
|
@@ -14,6 +14,8 @@ const testEventsConfig = createModuleEvents({
|
|
|
14
14
|
events: [
|
|
15
15
|
{ id: 'cross_process_test.browser', label: 'Browser', clientBroadcast: true },
|
|
16
16
|
{ id: 'cross_process_test.private', label: 'Private', crossProcessBroadcast: true },
|
|
17
|
+
{ id: 'cross_process_test.portal', label: 'Portal', portalBroadcast: true },
|
|
18
|
+
{ id: 'cross_process_test.portal_private', label: 'Portal private', portalBroadcast: true, crossProcessBroadcast: true },
|
|
17
19
|
{ id: 'cross_process_test.local', label: 'Local' },
|
|
18
20
|
] as const,
|
|
19
21
|
})
|
|
@@ -167,4 +169,93 @@ describe('cross-process event publication', () => {
|
|
|
167
169
|
|
|
168
170
|
expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
|
|
169
171
|
})
|
|
172
|
+
describe('portalBroadcast-only events', () => {
|
|
173
|
+
it('publishes a portal-only event cross-process with trusted options scope', async () => {
|
|
174
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
175
|
+
const trustedScope = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
176
|
+
const payload = { id: 'ticket-1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
177
|
+
|
|
178
|
+
await bus.emit('cross_process_test.portal', payload, trustedScope)
|
|
179
|
+
|
|
180
|
+
expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(1)
|
|
181
|
+
expect(publishCrossProcessEventMock).toHaveBeenCalledWith('cross_process_test.portal', payload, trustedScope)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('promotes typed payload scope for raw portal-only emits, like legacy clientBroadcast', async () => {
|
|
185
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
186
|
+
const payload = {
|
|
187
|
+
id: 'ticket-1',
|
|
188
|
+
tenantId: ' tenant-1 ',
|
|
189
|
+
organizationId: ' org-1 ',
|
|
190
|
+
recipientUserId: 'customer-1',
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await bus.emit('cross_process_test.portal', payload)
|
|
194
|
+
|
|
195
|
+
expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
|
|
196
|
+
'cross_process_test.portal',
|
|
197
|
+
payload,
|
|
198
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('publishes portal-only events emitted through the module emitter (e.g. from a worker subscriber)', async () => {
|
|
203
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
204
|
+
setGlobalEventBus(bus)
|
|
205
|
+
const payload = { id: 'ticket-1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
206
|
+
|
|
207
|
+
await testEventsConfig.emit('cross_process_test.portal', payload)
|
|
208
|
+
|
|
209
|
+
expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
|
|
210
|
+
'cross_process_test.portal',
|
|
211
|
+
payload,
|
|
212
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('prefers trusted options over a forged portal payload scope', async () => {
|
|
217
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
218
|
+
const trustedScope = { tenantId: 'workflow-tenant', organizationId: 'workflow-org' }
|
|
219
|
+
const payload = { id: 'ticket-1', tenantId: 'forged-tenant', organizationId: 'forged-org' }
|
|
220
|
+
|
|
221
|
+
await bus.emit('cross_process_test.portal', payload, trustedScope)
|
|
222
|
+
|
|
223
|
+
expect(publishCrossProcessEventMock).toHaveBeenCalledWith('cross_process_test.portal', payload, trustedScope)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('does not publish a portal-only event without tenant scope', async () => {
|
|
227
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
228
|
+
|
|
229
|
+
await bus.emit('cross_process_test.portal', { id: 'ticket-1', organizationId: 'org-1' })
|
|
230
|
+
await bus.emit('cross_process_test.portal', { id: 'ticket-1', tenantId: ' ' }, { organizationId: 'org-1' })
|
|
231
|
+
|
|
232
|
+
expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('treats a portal + crossProcessBroadcast event as private: no payload-scope promotion, no foreign emitter', async () => {
|
|
236
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
237
|
+
|
|
238
|
+
await bus.emit('cross_process_test.portal_private', {
|
|
239
|
+
id: 'ticket-1',
|
|
240
|
+
tenantId: 'payload-tenant',
|
|
241
|
+
organizationId: 'payload-org',
|
|
242
|
+
})
|
|
243
|
+
await bus.emit('cross_process_test.portal_private', { id: 'ticket-1' }, {
|
|
244
|
+
tenantId: 'tenant-1',
|
|
245
|
+
emitterModuleId: 'workflows',
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('does not publish undeclared or local-only events', async () => {
|
|
252
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
253
|
+
const payload = { id: 'record-1', tenantId: 'tenant-1' }
|
|
254
|
+
|
|
255
|
+
await bus.emit('cross_process_test.local', payload, { tenantId: 'tenant-1' })
|
|
256
|
+
await bus.emit('cross_process_test.undeclared', payload, { tenantId: 'tenant-1' })
|
|
257
|
+
|
|
258
|
+
expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
|
|
259
|
+
})
|
|
260
|
+
})
|
|
170
261
|
})
|
package/src/bridge.ts
CHANGED
|
@@ -2,11 +2,15 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { Client, Pool } from 'pg'
|
|
3
3
|
import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
|
|
4
4
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
|
+
import { isPrivateCrossProcessBroadcastEvent } from '@open-mercato/shared/modules/events'
|
|
5
6
|
import type { EmitOptions, EventPayload } from './types'
|
|
6
7
|
|
|
7
8
|
const BRIDGE_CHANNEL = 'om_event_bridge'
|
|
8
9
|
const MAX_MESSAGE_BYTES = 7_000
|
|
9
10
|
const RECONNECT_DELAY_MS = 1_000
|
|
11
|
+
const TRUNCATED_ENTITY_REF_KEYS = ['id', 'entityId', 'entityType'] as const
|
|
12
|
+
const TRUNCATED_AUDIENCE_SCALAR_KEYS = ['tenantId', 'organizationId', 'recipientUserId', 'recipientRoleId'] as const
|
|
13
|
+
const TRUNCATED_AUDIENCE_LIST_KEYS = ['organizationIds', 'recipientUserIds', 'recipientRoleIds'] as const
|
|
10
14
|
const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'
|
|
11
15
|
|
|
12
16
|
const logger = createLogger('events')
|
|
@@ -192,8 +196,8 @@ export async function publishCrossProcessEvent(
|
|
|
192
196
|
originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
const serialized =
|
|
196
|
-
if (
|
|
199
|
+
const serialized = serializeWithinLimit(envelope)
|
|
200
|
+
if (!serialized) {
|
|
197
201
|
logger.warn('Cross-process event dropped: payload exceeds size limit', { event, maxBytes: MAX_MESSAGE_BYTES })
|
|
198
202
|
return
|
|
199
203
|
}
|
|
@@ -201,6 +205,40 @@ export async function publishCrossProcessEvent(
|
|
|
201
205
|
await pool.query('SELECT pg_notify($1, $2)', [BRIDGE_CHANNEL, serialized])
|
|
202
206
|
}
|
|
203
207
|
|
|
208
|
+
function fitsMessageLimit(serialized: string): boolean {
|
|
209
|
+
return Buffer.byteLength(serialized, 'utf8') <= MAX_MESSAGE_BYTES
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Oversize browser-facing envelopes degrade to a scoped entity ref; private coordination is never reshaped, only dropped. */
|
|
213
|
+
function serializeWithinLimit(envelope: BridgeEnvelope): string | null {
|
|
214
|
+
const serialized = JSON.stringify(envelope)
|
|
215
|
+
if (fitsMessageLimit(serialized)) return serialized
|
|
216
|
+
if (isPrivateCrossProcessBroadcastEvent(envelope.event)) return null
|
|
217
|
+
const truncated = JSON.stringify({ ...envelope, payload: buildTruncatedPayload(envelope.payload) })
|
|
218
|
+
if (!fitsMessageLimit(truncated)) return null
|
|
219
|
+
logger.warn('Cross-process event truncated: payload exceeds size limit', {
|
|
220
|
+
event: envelope.event,
|
|
221
|
+
maxBytes: MAX_MESSAGE_BYTES,
|
|
222
|
+
})
|
|
223
|
+
return truncated
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildTruncatedPayload(payload: EventPayload): EventPayload {
|
|
227
|
+
const source = (payload ?? {}) as Record<string, unknown>
|
|
228
|
+
const truncated: Record<string, unknown> = { truncated: true }
|
|
229
|
+
for (const key of [...TRUNCATED_ENTITY_REF_KEYS, ...TRUNCATED_AUDIENCE_SCALAR_KEYS]) {
|
|
230
|
+
const value = source[key]
|
|
231
|
+
if (typeof value === 'string' && value.trim().length > 0) truncated[key] = value.trim()
|
|
232
|
+
}
|
|
233
|
+
for (const key of TRUNCATED_AUDIENCE_LIST_KEYS) {
|
|
234
|
+
const value = source[key]
|
|
235
|
+
if (!Array.isArray(value)) continue
|
|
236
|
+
const scopes = value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
|
237
|
+
if (scopes.length > 0) truncated[key] = scopes
|
|
238
|
+
}
|
|
239
|
+
return truncated
|
|
240
|
+
}
|
|
241
|
+
|
|
204
242
|
export function registerCrossProcessEventListener(listener: CrossProcessEventListener): () => void {
|
|
205
243
|
listeners.add(listener)
|
|
206
244
|
void ensureCrossProcessListener()
|
package/src/bus.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
isBroadcastEvent,
|
|
10
10
|
isCoalescedBroadcastEvent,
|
|
11
11
|
isCrossProcessBroadcastEvent,
|
|
12
|
+
isPortalBroadcastEvent,
|
|
13
|
+
isPrivateCrossProcessBroadcastEvent,
|
|
12
14
|
isPrivateCrossProcessEventEmitter,
|
|
13
15
|
} from '@open-mercato/shared/modules/events'
|
|
14
16
|
import {
|
|
@@ -76,17 +78,27 @@ function normalizePayloadScope(value: unknown): string | null {
|
|
|
76
78
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
function isBrowserBroadcastEvent(event: string): boolean {
|
|
82
|
+
return isBroadcastEvent(event) || isPortalBroadcastEvent(event)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Relayed over the bridge: both browser audiences plus private coordination; each consumer filters by its own flag. */
|
|
86
|
+
function shouldPublishCrossProcess(event: string): boolean {
|
|
87
|
+
return isCrossProcessBroadcastEvent(event) || isPortalBroadcastEvent(event)
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
function resolveCrossProcessEmitOptions(
|
|
80
91
|
event: string,
|
|
81
92
|
payload: EventPayload,
|
|
82
93
|
options?: EmitOptions,
|
|
83
94
|
): EmitOptions | undefined {
|
|
84
95
|
if (hasTrustedTenantScope(options)) return options
|
|
85
|
-
// Preserve the public raw EventBus contract for legacy clientBroadcast
|
|
86
|
-
// emitters. These callers are trusted server code and
|
|
87
|
-
// their scope in the typed payload. Tenant-managed
|
|
88
|
-
// scope in options, while private cross-process
|
|
89
|
-
|
|
96
|
+
// Preserve the public raw EventBus contract for legacy clientBroadcast and
|
|
97
|
+
// portalBroadcast emitters. These callers are trusted server code and
|
|
98
|
+
// historically supplied their scope in the typed payload. Tenant-managed
|
|
99
|
+
// workflow execution passes scope in options, while private cross-process
|
|
100
|
+
// events never take this path.
|
|
101
|
+
if (!isBrowserBroadcastEvent(event) || isPrivateCrossProcessBroadcastEvent(event)) return options
|
|
90
102
|
const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)
|
|
91
103
|
if (!tenantId) return options
|
|
92
104
|
const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)
|
|
@@ -605,7 +617,7 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
|
|
|
605
617
|
const publishToOtherProcesses = async (): Promise<void> => {
|
|
606
618
|
const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)
|
|
607
619
|
if (
|
|
608
|
-
!
|
|
620
|
+
!shouldPublishCrossProcess(event)
|
|
609
621
|
|| !hasTrustedTenantScope(crossProcessOptions)
|
|
610
622
|
|| !isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)
|
|
611
623
|
) return
|