@open-mercato/events 0.8.1-develop.7296.1.2111d779db → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { resolveRequestContext } from "@open-mercato/shared/lib/api/context";
2
2
  import { createLogger } from "@open-mercato/shared/lib/logger";
3
+ import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
3
4
  import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
4
5
  import {
5
6
  CROSS_PROCESS_EVENT_INSTANCE_ID,
@@ -10,8 +11,88 @@ const metadata = {
10
11
  GET: { requireAuth: true }
11
12
  };
12
13
  const HEARTBEAT_INTERVAL_MS = 3e4;
14
+ const DEFAULT_AUTH_REVALIDATION_INTERVAL_MS = 3e4;
15
+ const DEFAULT_CONNECTION_MAX_AGE_MS = 5 * 6e4;
16
+ const MIN_AUTH_REVALIDATION_INTERVAL_MS = 1e3;
17
+ const MIN_CONNECTION_MAX_AGE_MS = 1e3;
18
+ const CONNECTION_MAX_AGE_JITTER_RATIO = 0.15;
19
+ const MAX_TIMER_DELAY_MS = 2147483647;
13
20
  const MAX_PAYLOAD_BYTES = 4096;
14
21
  const logger = createLogger("events").child({ component: "stream" });
22
+ function resolveTimingConfig() {
23
+ return {
24
+ authRevalidationIntervalMs: Math.min(
25
+ parseNumberWithDefault(
26
+ process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS,
27
+ DEFAULT_AUTH_REVALIDATION_INTERVAL_MS,
28
+ { integer: true, min: MIN_AUTH_REVALIDATION_INTERVAL_MS }
29
+ ),
30
+ MAX_TIMER_DELAY_MS
31
+ ),
32
+ connectionMaxAgeMs: applyConnectionMaxAgeJitter(
33
+ parseNumberWithDefault(
34
+ process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS,
35
+ DEFAULT_CONNECTION_MAX_AGE_MS,
36
+ { integer: true, min: MIN_CONNECTION_MAX_AGE_MS }
37
+ )
38
+ )
39
+ };
40
+ }
41
+ function applyConnectionMaxAgeJitter(configuredMs) {
42
+ const jitterFactor = 1 + (Math.random() * 2 - 1) * CONNECTION_MAX_AGE_JITTER_RATIO;
43
+ const jitteredMs = Math.round(configuredMs * jitterFactor);
44
+ return Math.min(Math.max(jitteredMs, MIN_CONNECTION_MAX_AGE_MS), MAX_TIMER_DELAY_MS);
45
+ }
46
+ function normalizeRoleIds(input) {
47
+ if (!Array.isArray(input)) return [];
48
+ return Array.from(new Set(
49
+ input.filter((role) => typeof role === "string").map((role) => role.trim()).filter((role) => role.length > 0)
50
+ )).sort((left, right) => left.localeCompare(right));
51
+ }
52
+ function resolveOrganizationScopeService(ctx) {
53
+ try {
54
+ const service = ctx.container.resolve("organizationScopeService");
55
+ return service && typeof service.resolveForRequest === "function" ? service : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ function normalizeId(input) {
61
+ return typeof input === "string" && input.trim().length > 0 ? input.trim() : null;
62
+ }
63
+ async function resolveConnectionIdentity(ctx, request) {
64
+ const canonicalTenantId = normalizeId(ctx.auth?.tenantId);
65
+ if (!canonicalTenantId || !ctx.auth?.sub) return null;
66
+ const organizationScopeService = resolveOrganizationScopeService(ctx);
67
+ if (!organizationScopeService) return null;
68
+ const scope = await organizationScopeService.resolveForRequest({ auth: ctx.auth, request });
69
+ const tenantId = normalizeId(scope.tenantId);
70
+ const organizationId = normalizeId(scope.selectedId) ?? normalizeId(ctx.auth.orgId);
71
+ if (!tenantId || tenantId !== canonicalTenantId || scope.selectionRejected) return null;
72
+ if (organizationId && Array.isArray(scope.allowedIds) && !scope.allowedIds.includes(organizationId)) {
73
+ return null;
74
+ }
75
+ return {
76
+ tenantId,
77
+ organizationId,
78
+ userId: ctx.auth.sub,
79
+ roleIds: normalizeRoleIds(ctx.auth.roles)
80
+ };
81
+ }
82
+ function identitiesMatch(left, right) {
83
+ return left.tenantId === right.tenantId && left.organizationId === right.organizationId && left.userId === right.userId && left.roleIds.length === right.roleIds.length && left.roleIds.every((roleId, index) => roleId === right.roleIds[index]);
84
+ }
85
+ function hasApiKeyCredentials(req) {
86
+ if ((req.headers.get("x-api-key") ?? "").trim().length > 0) return true;
87
+ const authorization = (req.headers.get("authorization") ?? "").trim();
88
+ return /^apikey(?:\s|$)/i.test(authorization);
89
+ }
90
+ function createAuthRevalidationRequest(req) {
91
+ return new Request(req.url, {
92
+ method: req.method,
93
+ headers: req.headers
94
+ });
95
+ }
15
96
  function collectStringValues(input) {
16
97
  if (!Array.isArray(input)) return [];
17
98
  const values = [];
@@ -65,16 +146,16 @@ function normalizeAudience(data, options) {
65
146
  }
66
147
  function matchesAudience(conn, audience) {
67
148
  if (!audience.tenantId) return false;
68
- if (conn.tenantId !== audience.tenantId) return false;
149
+ if (conn.identity.tenantId !== audience.tenantId) return false;
69
150
  if (audience.organizationScopes.length > 0) {
70
- if (!conn.organizationId) return false;
71
- if (!audience.organizationScopes.includes(conn.organizationId)) return false;
151
+ if (!conn.identity.organizationId) return false;
152
+ if (!audience.organizationScopes.includes(conn.identity.organizationId)) return false;
72
153
  }
73
- if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {
154
+ if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.identity.userId)) {
74
155
  return false;
75
156
  }
76
157
  if (audience.recipientRoleScopes.length > 0) {
77
- const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId));
158
+ const roleMatched = conn.identity.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId));
78
159
  if (!roleMatched) return false;
79
160
  }
80
161
  return true;
@@ -147,59 +228,119 @@ function ensureGlobalTapSubscription() {
147
228
  });
148
229
  }
149
230
  async function GET(req) {
231
+ if (hasApiKeyCredentials(req)) {
232
+ return new Response("Unauthorized", { status: 401 });
233
+ }
150
234
  const { ctx } = await resolveRequestContext(req);
151
- if (!ctx.auth?.tenantId || !ctx.auth?.sub) {
235
+ if (ctx.auth?.isApiKey === true) {
152
236
  return new Response("Unauthorized", { status: 401 });
153
237
  }
154
- const tenantId = ctx.auth.tenantId;
155
- const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null;
156
- const userId = ctx.auth.sub;
157
- const roleIds = Array.isArray(ctx.auth.roles) ? ctx.auth.roles.filter((role) => typeof role === "string" && role.trim().length > 0) : [];
238
+ const initialIdentity = await resolveConnectionIdentity(ctx, req);
239
+ if (!initialIdentity) {
240
+ return new Response("Unauthorized", { status: 401 });
241
+ }
242
+ const authorizedIdentity = initialIdentity;
243
+ const { authRevalidationIntervalMs, connectionMaxAgeMs } = resolveTimingConfig();
158
244
  ensureGlobalTapSubscription();
159
245
  const encoder = new TextEncoder();
160
246
  let heartbeatTimer = null;
247
+ let authRevalidationTimer = null;
248
+ let maxAgeTimer = null;
249
+ let streamController = null;
161
250
  let connection = null;
162
- const onAbort = () => cleanup();
251
+ let authRevalidationInFlight = false;
252
+ let cleanedUp = false;
253
+ const onAbort = () => closeConnection();
163
254
  const stream = new ReadableStream({
164
255
  start(controller) {
256
+ streamController = controller;
165
257
  const send = (data) => {
166
- controller.enqueue(encoder.encode(`data: ${data}
258
+ if (cleanedUp) return;
259
+ try {
260
+ streamController?.enqueue(encoder.encode(`data: ${data}
167
261
 
168
262
  `));
263
+ } catch {
264
+ closeConnection();
265
+ }
169
266
  };
170
267
  connection = {
171
- tenantId,
172
- organizationId,
173
- userId,
174
- roleIds,
268
+ identity: authorizedIdentity,
175
269
  send,
176
- close: () => controller.close()
270
+ close: closeConnection
177
271
  };
178
272
  connections.add(connection);
179
- controller.enqueue(encoder.encode(": connected\n\n"));
273
+ streamController.enqueue(encoder.encode(": connected\n\n"));
180
274
  heartbeatTimer = setInterval(() => {
181
275
  try {
182
- controller.enqueue(encoder.encode(":heartbeat\ndata: :heartbeat\n\n"));
276
+ streamController?.enqueue(encoder.encode(":heartbeat\ndata: :heartbeat\n\n"));
183
277
  } catch {
278
+ closeConnection();
184
279
  }
185
280
  }, HEARTBEAT_INTERVAL_MS);
281
+ authRevalidationTimer = setInterval(() => {
282
+ void revalidateAuthorization();
283
+ }, authRevalidationIntervalMs);
284
+ maxAgeTimer = setTimeout(() => {
285
+ closeConnection();
286
+ }, connectionMaxAgeMs);
186
287
  },
187
288
  cancel() {
188
289
  cleanup();
189
290
  }
190
291
  });
292
+ async function revalidateAuthorization() {
293
+ if (cleanedUp || authRevalidationInFlight) return;
294
+ authRevalidationInFlight = true;
295
+ try {
296
+ const validationRequest = createAuthRevalidationRequest(req);
297
+ const { ctx: freshCtx } = await resolveRequestContext(validationRequest);
298
+ if (cleanedUp) return;
299
+ const freshIdentity = await resolveConnectionIdentity(freshCtx, validationRequest);
300
+ if (!freshIdentity || !identitiesMatch(authorizedIdentity, freshIdentity)) {
301
+ closeConnection();
302
+ return;
303
+ }
304
+ if (connection) connection.identity = freshIdentity;
305
+ } catch {
306
+ closeConnection();
307
+ } finally {
308
+ authRevalidationInFlight = false;
309
+ }
310
+ }
311
+ function closeConnection() {
312
+ if (cleanedUp) return;
313
+ const controller = streamController;
314
+ cleanup();
315
+ try {
316
+ controller?.close();
317
+ } catch {
318
+ }
319
+ }
191
320
  function cleanup() {
321
+ if (cleanedUp) return;
322
+ cleanedUp = true;
192
323
  if (heartbeatTimer) {
193
324
  clearInterval(heartbeatTimer);
194
325
  heartbeatTimer = null;
195
326
  }
327
+ if (authRevalidationTimer) {
328
+ clearInterval(authRevalidationTimer);
329
+ authRevalidationTimer = null;
330
+ }
331
+ if (maxAgeTimer) {
332
+ clearTimeout(maxAgeTimer);
333
+ maxAgeTimer = null;
334
+ }
196
335
  if (connection) {
197
336
  connections.delete(connection);
198
337
  connection = null;
199
338
  }
339
+ streamController = null;
200
340
  req.signal.removeEventListener("abort", onAbort);
201
341
  }
202
342
  req.signal.addEventListener("abort", onAbort, { once: true });
343
+ if (req.signal.aborted) onAbort();
203
344
  return new Response(stream, {
204
345
  status: 200,
205
346
  headers: {
@@ -213,7 +354,7 @@ async function GET(req) {
213
354
  const openApi = {
214
355
  GET: {
215
356
  summary: "Subscribe to server events via SSE (DOM Event Bridge)",
216
- description: "Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.",
357
+ description: "Server-bounded SSE connection for staff cookie or Bearer authentication that receives server-side events marked with clientBroadcast: true. API-key credentials are not accepted. Events are server-filtered by tenant, organization, recipient user, and recipient role, with periodic canonical authorization revalidation.",
217
358
  tags: ["Events"],
218
359
  responses: {
219
360
  200: {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/events/api/stream/route.ts"],
4
- "sourcesContent": ["/**\n * SSE Event Stream \u2014 DOM Event Bridge\n *\n * Server-Sent Events endpoint that bridges server-side events to the browser.\n * Only events with `clientBroadcast: true` in their EventDefinition are sent.\n * Events are scoped to the authenticated user's tenant.\n *\n * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`\n */\n\nimport { resolveRequestContext } from '@open-mercato/shared/lib/api/context'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n CROSS_PROCESS_EVENT_INSTANCE_ID,\n registerCrossProcessEventListener,\n registerGlobalEventTap,\n} from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\n\ntype SseConnection = {\n tenantId: string\n organizationId: string | null\n userId: string\n roleIds: string[]\n send: (data: string) => void\n close: () => void\n}\n\nfunction collectStringValues(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\nfunction normalizeAudience(data: Record<string, unknown>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n for (const organizationId of collectStringValues(options?.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n } else {\n if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {\n organizationScopes.add(data.organizationId.trim())\n }\n for (const organizationId of collectStringValues(data.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n }\n\n const recipientUserScopes = new Set<string>()\n if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {\n recipientUserScopes.add(data.recipientUserId.trim())\n }\n for (const userId of collectStringValues(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {\n recipientRoleScopes.add(data.recipientRoleId.trim())\n }\n for (const roleId of collectStringValues(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return {\n tenantId,\n organizationScopes: Array.from(organizationScopes),\n recipientUserScopes: Array.from(recipientUserScopes),\n recipientRoleScopes: Array.from(recipientRoleScopes),\n }\n}\n\nfunction matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {\n if (!audience.tenantId) return false\n if (conn.tenantId !== audience.tenantId) return false\n\n if (audience.organizationScopes.length > 0) {\n if (!conn.organizationId) return false\n if (!audience.organizationScopes.includes(conn.organizationId)) return false\n }\n\n if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {\n return false\n }\n\n if (audience.recipientRoleScopes.length > 0) {\n const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))\n if (!roleMatched) return false\n }\n\n return true\n}\n\n/**\n * Global connection registry.\n * All active SSE connections are tracked here.\n * The event bus subscriber iterates this set on each broadcast event.\n */\nconst connections = new Set<SseConnection>()\n\nlet globalTapRegistered = false\n\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n ssePayload = buildTruncatedPayload(eventName, data, organizationId)\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\n return\n }\n }\n\n for (const conn of connections) {\n if (!matchesAudience(conn, audience)) continue\n\n try {\n conn.send(ssePayload)\n } catch {\n // Connection may have been closed; cleanup happens via abort handler\n }\n }\n}\n\nfunction buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n return JSON.stringify({\n id: eventName,\n payload: data,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\nfunction buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n const entityRef: Record<string, unknown> = {\n truncated: true,\n }\n if (typeof data.id === 'string' && data.id.trim().length > 0) {\n entityRef.id = data.id.trim()\n }\n if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {\n entityRef.entityId = data.entityId.trim()\n }\n if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {\n entityRef.entityType = data.entityType.trim()\n }\n return JSON.stringify({\n id: eventName,\n payload: entityRef,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\n/**\n * Ensure a process-wide event tap is registered (once).\n * This captures emits from all request-scoped EventBus instances.\n */\nfunction ensureGlobalTapSubscription(): void {\n if (globalTapRegistered) return\n globalTapRegistered = true\n\n registerGlobalEventTap(async (eventName, payload, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n // Every envelope this process publishes carries originInstanceId, so an\n // envelope without one can only come from an older process during a\n // rolling deploy and is always foreign. Falling back to originPid would\n // discard it whenever both containers run under the same pid \u2014 commonly\n // pid 1 \u2014 silently dropping browser events across the upgrade window.\n if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\n )\n })\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { ctx } = await resolveRequestContext(req)\n\n if (!ctx.auth?.tenantId || !ctx.auth?.sub) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n const tenantId = ctx.auth.tenantId\n const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null\n const userId = ctx.auth.sub\n const roleIds = Array.isArray(ctx.auth.roles)\n ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)\n : []\n\n ensureGlobalTapSubscription()\n\n const encoder = new TextEncoder()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n let connection: SseConnection | null = null\n const onAbort = () => cleanup()\n\n const stream = new ReadableStream({\n start(controller) {\n const send = (data: string) => {\n controller.enqueue(encoder.encode(`data: ${data}\\n\\n`))\n }\n\n connection = {\n tenantId,\n organizationId,\n userId,\n roleIds,\n send,\n close: () => controller.close(),\n }\n connections.add(connection)\n\n // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n controller.enqueue(encoder.encode(': connected\\n\\n'))\n\n // Start heartbeat to keep connection alive\n heartbeatTimer = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(':heartbeat\\ndata: :heartbeat\\n\\n'))\n } catch {\n // Stream may have been closed\n }\n }, HEARTBEAT_INTERVAL_MS)\n },\n cancel() {\n cleanup()\n },\n })\n\n function cleanup() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n heartbeatTimer = null\n }\n if (connection) {\n connections.delete(connection)\n connection = null\n }\n // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\n\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Subscribe to server events via SSE (DOM Event Bridge)',\n description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',\n tags: ['Events'],\n responses: {\n 200: {\n description: 'Event stream (text/event-stream)',\n content: {\n 'text/event-stream': {\n schema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },\n payload: { type: 'object', description: 'Event-specific data' },\n timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },\n organizationId: { type: 'string', description: 'Organization scope' },\n },\n },\n },\n },\n },\n 401: { description: 'Not authenticated' },\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * SSE Event Stream \u2014 DOM Event Bridge\n *\n * Server-Sent Events endpoint that bridges server-side events to the browser.\n * Only events with `clientBroadcast: true` in their EventDefinition are sent.\n * Events are scoped to the authenticated user's tenant.\n *\n * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`\n */\n\nimport { resolveRequestContext } from '@open-mercato/shared/lib/api/context'\nimport type { OrganizationScopeService } from '@open-mercato/shared/lib/auth/principal-service'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n CROSS_PROCESS_EVENT_INSTANCE_ID,\n registerCrossProcessEventListener,\n registerGlobalEventTap,\n} from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst DEFAULT_AUTH_REVALIDATION_INTERVAL_MS = 30_000\nconst DEFAULT_CONNECTION_MAX_AGE_MS = 5 * 60_000\nconst MIN_AUTH_REVALIDATION_INTERVAL_MS = 1_000\nconst MIN_CONNECTION_MAX_AGE_MS = 1_000\nconst CONNECTION_MAX_AGE_JITTER_RATIO = 0.15\nconst MAX_TIMER_DELAY_MS = 2_147_483_647\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\n\ntype SseConnectionIdentity = {\n tenantId: string\n organizationId: string | null\n userId: string\n roleIds: string[]\n}\n\ntype SseConnection = {\n identity: SseConnectionIdentity\n send: (data: string) => void\n close: () => void\n}\n\nfunction resolveTimingConfig(): {\n authRevalidationIntervalMs: number\n connectionMaxAgeMs: number\n} {\n return {\n authRevalidationIntervalMs: Math.min(\n parseNumberWithDefault(\n process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS,\n DEFAULT_AUTH_REVALIDATION_INTERVAL_MS,\n { integer: true, min: MIN_AUTH_REVALIDATION_INTERVAL_MS },\n ),\n MAX_TIMER_DELAY_MS,\n ),\n connectionMaxAgeMs: applyConnectionMaxAgeJitter(\n parseNumberWithDefault(\n process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS,\n DEFAULT_CONNECTION_MAX_AGE_MS,\n { integer: true, min: MIN_CONNECTION_MAX_AGE_MS },\n ),\n ),\n }\n}\n\nfunction applyConnectionMaxAgeJitter(configuredMs: number): number {\n const jitterFactor = 1 + (Math.random() * 2 - 1) * CONNECTION_MAX_AGE_JITTER_RATIO\n const jitteredMs = Math.round(configuredMs * jitterFactor)\n return Math.min(Math.max(jitteredMs, MIN_CONNECTION_MAX_AGE_MS), MAX_TIMER_DELAY_MS)\n}\n\nfunction normalizeRoleIds(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n return Array.from(new Set(\n input\n .filter((role): role is string => typeof role === 'string')\n .map((role) => role.trim())\n .filter((role) => role.length > 0),\n )).sort((left, right) => left.localeCompare(right))\n}\n\nfunction resolveOrganizationScopeService(\n ctx: Awaited<ReturnType<typeof resolveRequestContext>>['ctx'],\n): OrganizationScopeService | null {\n try {\n const service = ctx.container.resolve<OrganizationScopeService>('organizationScopeService')\n return service && typeof service.resolveForRequest === 'function' ? service : null\n } catch {\n return null\n }\n}\n\nfunction normalizeId(input: unknown): string | null {\n return typeof input === 'string' && input.trim().length > 0 ? input.trim() : null\n}\n\nasync function resolveConnectionIdentity(\n ctx: Awaited<ReturnType<typeof resolveRequestContext>>['ctx'],\n request: Request,\n): Promise<SseConnectionIdentity | null> {\n const canonicalTenantId = normalizeId(ctx.auth?.tenantId)\n if (!canonicalTenantId || !ctx.auth?.sub) return null\n const organizationScopeService = resolveOrganizationScopeService(ctx)\n if (!organizationScopeService) return null\n const scope = await organizationScopeService.resolveForRequest({ auth: ctx.auth, request })\n const tenantId = normalizeId(scope.tenantId)\n // `selectedId` is null whenever the caller has \"All organizations\" selected \u2014\n // the default an unrestricted admin's organization switcher persists \u2014 so using\n // it alone leaves the connection with no organization and `matchesAudience`\n // then rejects EVERY organization-scoped event. Keep the home-organization\n // fallback the bridge has always used; the allowed-set check below still\n // refuses an organization the principal may not see.\n const organizationId = normalizeId(scope.selectedId) ?? normalizeId(ctx.auth.orgId)\n if (!tenantId || tenantId !== canonicalTenantId || scope.selectionRejected) return null\n if (organizationId && Array.isArray(scope.allowedIds) && !scope.allowedIds.includes(organizationId)) {\n return null\n }\n return {\n tenantId,\n organizationId,\n userId: ctx.auth.sub,\n roleIds: normalizeRoleIds(ctx.auth.roles),\n }\n}\n\nfunction identitiesMatch(left: SseConnectionIdentity, right: SseConnectionIdentity): boolean {\n return left.tenantId === right.tenantId\n && left.organizationId === right.organizationId\n && left.userId === right.userId\n && left.roleIds.length === right.roleIds.length\n && left.roleIds.every((roleId, index) => roleId === right.roleIds[index])\n}\n\nfunction hasApiKeyCredentials(req: Request): boolean {\n if ((req.headers.get('x-api-key') ?? '').trim().length > 0) return true\n const authorization = (req.headers.get('authorization') ?? '').trim()\n return /^apikey(?:\\s|$)/i.test(authorization)\n}\n\nfunction createAuthRevalidationRequest(req: Request): Request {\n return new Request(req.url, {\n method: req.method,\n headers: req.headers,\n })\n}\n\nfunction collectStringValues(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\nfunction normalizeAudience(data: Record<string, unknown>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n for (const organizationId of collectStringValues(options?.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n } else {\n if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {\n organizationScopes.add(data.organizationId.trim())\n }\n for (const organizationId of collectStringValues(data.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n }\n\n const recipientUserScopes = new Set<string>()\n if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {\n recipientUserScopes.add(data.recipientUserId.trim())\n }\n for (const userId of collectStringValues(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {\n recipientRoleScopes.add(data.recipientRoleId.trim())\n }\n for (const roleId of collectStringValues(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return {\n tenantId,\n organizationScopes: Array.from(organizationScopes),\n recipientUserScopes: Array.from(recipientUserScopes),\n recipientRoleScopes: Array.from(recipientRoleScopes),\n }\n}\n\nfunction matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {\n if (!audience.tenantId) return false\n if (conn.identity.tenantId !== audience.tenantId) return false\n\n if (audience.organizationScopes.length > 0) {\n if (!conn.identity.organizationId) return false\n if (!audience.organizationScopes.includes(conn.identity.organizationId)) return false\n }\n\n if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.identity.userId)) {\n return false\n }\n\n if (audience.recipientRoleScopes.length > 0) {\n const roleMatched = conn.identity.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))\n if (!roleMatched) return false\n }\n\n return true\n}\n\n/**\n * Global connection registry.\n * All active SSE connections are tracked here.\n * The event bus subscriber iterates this set on each broadcast event.\n */\nconst connections = new Set<SseConnection>()\n\nlet globalTapRegistered = false\n\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n ssePayload = buildTruncatedPayload(eventName, data, organizationId)\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\n return\n }\n }\n\n for (const conn of connections) {\n if (!matchesAudience(conn, audience)) continue\n\n try {\n conn.send(ssePayload)\n } catch {\n // Connection may have been closed; cleanup happens via abort handler\n }\n }\n}\n\nfunction buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n return JSON.stringify({\n id: eventName,\n payload: data,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\nfunction buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n const entityRef: Record<string, unknown> = {\n truncated: true,\n }\n if (typeof data.id === 'string' && data.id.trim().length > 0) {\n entityRef.id = data.id.trim()\n }\n if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {\n entityRef.entityId = data.entityId.trim()\n }\n if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {\n entityRef.entityType = data.entityType.trim()\n }\n return JSON.stringify({\n id: eventName,\n payload: entityRef,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\n/**\n * Ensure a process-wide event tap is registered (once).\n * This captures emits from all request-scoped EventBus instances.\n */\nfunction ensureGlobalTapSubscription(): void {\n if (globalTapRegistered) return\n globalTapRegistered = true\n\n registerGlobalEventTap(async (eventName, payload, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n // Every envelope this process publishes carries originInstanceId, so an\n // envelope without one can only come from an older process during a\n // rolling deploy and is always foreign. Falling back to originPid would\n // discard it whenever both containers run under the same pid \u2014 commonly\n // pid 1 \u2014 silently dropping browser events across the upgrade window.\n if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\n )\n })\n}\n\nexport async function GET(req: Request): Promise<Response> {\n if (hasApiKeyCredentials(req)) {\n return new Response('Unauthorized', { status: 401 })\n }\n const { ctx } = await resolveRequestContext(req)\n if (ctx.auth?.isApiKey === true) {\n return new Response('Unauthorized', { status: 401 })\n }\n const initialIdentity = await resolveConnectionIdentity(ctx, req)\n if (!initialIdentity) {\n return new Response('Unauthorized', { status: 401 })\n }\n const authorizedIdentity = initialIdentity\n const { authRevalidationIntervalMs, connectionMaxAgeMs } = resolveTimingConfig()\n\n ensureGlobalTapSubscription()\n\n const encoder = new TextEncoder()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n let authRevalidationTimer: ReturnType<typeof setInterval> | null = null\n let maxAgeTimer: ReturnType<typeof setTimeout> | null = null\n let streamController: ReadableStreamDefaultController<Uint8Array> | null = null\n let connection: SseConnection | null = null\n let authRevalidationInFlight = false\n let cleanedUp = false\n const onAbort = () => closeConnection()\n\n const stream = new ReadableStream({\n start(controller) {\n streamController = controller\n const send = (data: string) => {\n if (cleanedUp) return\n try {\n streamController?.enqueue(encoder.encode(`data: ${data}\\n\\n`))\n } catch {\n closeConnection()\n }\n }\n\n connection = {\n identity: authorizedIdentity,\n send,\n close: closeConnection,\n }\n connections.add(connection)\n\n // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n streamController.enqueue(encoder.encode(': connected\\n\\n'))\n\n // Start heartbeat to keep connection alive\n heartbeatTimer = setInterval(() => {\n try {\n streamController?.enqueue(encoder.encode(':heartbeat\\ndata: :heartbeat\\n\\n'))\n } catch {\n closeConnection()\n }\n }, HEARTBEAT_INTERVAL_MS)\n\n authRevalidationTimer = setInterval(() => {\n void revalidateAuthorization()\n }, authRevalidationIntervalMs)\n\n maxAgeTimer = setTimeout(() => {\n closeConnection()\n }, connectionMaxAgeMs)\n },\n cancel() {\n cleanup()\n },\n })\n\n async function revalidateAuthorization(): Promise<void> {\n if (cleanedUp || authRevalidationInFlight) return\n authRevalidationInFlight = true\n try {\n const validationRequest = createAuthRevalidationRequest(req)\n const { ctx: freshCtx } = await resolveRequestContext(validationRequest)\n if (cleanedUp) return\n const freshIdentity = await resolveConnectionIdentity(freshCtx, validationRequest)\n if (!freshIdentity || !identitiesMatch(authorizedIdentity, freshIdentity)) {\n closeConnection()\n return\n }\n if (connection) connection.identity = freshIdentity\n } catch {\n closeConnection()\n } finally {\n authRevalidationInFlight = false\n }\n }\n\n function closeConnection(): void {\n if (cleanedUp) return\n const controller = streamController\n cleanup()\n try {\n controller?.close()\n } catch {}\n }\n\n function cleanup() {\n if (cleanedUp) return\n cleanedUp = true\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n heartbeatTimer = null\n }\n if (authRevalidationTimer) {\n clearInterval(authRevalidationTimer)\n authRevalidationTimer = null\n }\n if (maxAgeTimer) {\n clearTimeout(maxAgeTimer)\n maxAgeTimer = null\n }\n if (connection) {\n connections.delete(connection)\n connection = null\n }\n streamController = null\n // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\n if (req.signal.aborted) onAbort()\n\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Subscribe to server events via SSE (DOM Event Bridge)',\n description: 'Server-bounded SSE connection for staff cookie or Bearer authentication that receives server-side events marked with clientBroadcast: true. API-key credentials are not accepted. Events are server-filtered by tenant, organization, recipient user, and recipient role, with periodic canonical authorization revalidation.',\n tags: ['Events'],\n responses: {\n 200: {\n description: 'Event stream (text/event-stream)',\n content: {\n 'text/event-stream': {\n schema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },\n payload: { type: 'object', description: 'Event-specific data' },\n timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },\n organizationId: { type: 'string', description: 'Organization scope' },\n },\n },\n },\n },\n },\n 401: { description: 'Not authenticated' },\n },\n },\n}\n"],
5
+ "mappings": "AAUA,SAAS,6BAA6B;AAEtC,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,wBAAwB;AACjC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGA,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,KAAK;AAC3B;AAEA,MAAM,wBAAwB;AAC9B,MAAM,wCAAwC;AAC9C,MAAM,gCAAgC,IAAI;AAC1C,MAAM,oCAAoC;AAC1C,MAAM,4BAA4B;AAClC,MAAM,kCAAkC;AACxC,MAAM,qBAAqB;AAC3B,MAAM,oBAAoB;AAE1B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,SAAS,CAAC;AAenE,SAAS,sBAGP;AACA,SAAO;AAAA,IACL,4BAA4B,KAAK;AAAA,MAC/B;AAAA,QACE,QAAQ,IAAI;AAAA,QACZ;AAAA,QACA,EAAE,SAAS,MAAM,KAAK,kCAAkC;AAAA,MAC1D;AAAA,MACA;AAAA,IACF;AAAA,IACA,oBAAoB;AAAA,MAClB;AAAA,QACE,QAAQ,IAAI;AAAA,QACZ;AAAA,QACA,EAAE,SAAS,MAAM,KAAK,0BAA0B;AAAA,MAClD;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,4BAA4B,cAA8B;AACjE,QAAM,eAAe,KAAK,KAAK,OAAO,IAAI,IAAI,KAAK;AACnD,QAAM,aAAa,KAAK,MAAM,eAAe,YAAY;AACzD,SAAO,KAAK,IAAI,KAAK,IAAI,YAAY,yBAAyB,GAAG,kBAAkB;AACrF;AAEA,SAAS,iBAAiB,OAA0B;AAClD,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC;AACnC,SAAO,MAAM,KAAK,IAAI;AAAA,IACpB,MACG,OAAO,CAAC,SAAyB,OAAO,SAAS,QAAQ,EACzD,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EACzB,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AAAA,EACrC,CAAC,EAAE,KAAK,CAAC,MAAM,UAAU,KAAK,cAAc,KAAK,CAAC;AACpD;AAEA,SAAS,gCACP,KACiC;AACjC,MAAI;AACF,UAAM,UAAU,IAAI,UAAU,QAAkC,0BAA0B;AAC1F,WAAO,WAAW,OAAO,QAAQ,sBAAsB,aAAa,UAAU;AAAA,EAChF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,YAAY,OAA+B;AAClD,SAAO,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,IAAI,MAAM,KAAK,IAAI;AAC/E;AAEA,eAAe,0BACb,KACA,SACuC;AACvC,QAAM,oBAAoB,YAAY,IAAI,MAAM,QAAQ;AACxD,MAAI,CAAC,qBAAqB,CAAC,IAAI,MAAM,IAAK,QAAO;AACjD,QAAM,2BAA2B,gCAAgC,GAAG;AACpE,MAAI,CAAC,yBAA0B,QAAO;AACtC,QAAM,QAAQ,MAAM,yBAAyB,kBAAkB,EAAE,MAAM,IAAI,MAAM,QAAQ,CAAC;AAC1F,QAAM,WAAW,YAAY,MAAM,QAAQ;AAO3C,QAAM,iBAAiB,YAAY,MAAM,UAAU,KAAK,YAAY,IAAI,KAAK,KAAK;AAClF,MAAI,CAAC,YAAY,aAAa,qBAAqB,MAAM,kBAAmB,QAAO;AACnF,MAAI,kBAAkB,MAAM,QAAQ,MAAM,UAAU,KAAK,CAAC,MAAM,WAAW,SAAS,cAAc,GAAG;AACnG,WAAO;AAAA,EACT;AACA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,QAAQ,IAAI,KAAK;AAAA,IACjB,SAAS,iBAAiB,IAAI,KAAK,KAAK;AAAA,EAC1C;AACF;AAEA,SAAS,gBAAgB,MAA6B,OAAuC;AAC3F,SAAO,KAAK,aAAa,MAAM,YAC1B,KAAK,mBAAmB,MAAM,kBAC9B,KAAK,WAAW,MAAM,UACtB,KAAK,QAAQ,WAAW,MAAM,QAAQ,UACtC,KAAK,QAAQ,MAAM,CAAC,QAAQ,UAAU,WAAW,MAAM,QAAQ,KAAK,CAAC;AAC5E;AAEA,SAAS,qBAAqB,KAAuB;AACnD,OAAK,IAAI,QAAQ,IAAI,WAAW,KAAK,IAAI,KAAK,EAAE,SAAS,EAAG,QAAO;AACnE,QAAM,iBAAiB,IAAI,QAAQ,IAAI,eAAe,KAAK,IAAI,KAAK;AACpE,SAAO,mBAAmB,KAAK,aAAa;AAC9C;AAEA,SAAS,8BAA8B,KAAuB;AAC5D,SAAO,IAAI,QAAQ,IAAI,KAAK;AAAA,IAC1B,QAAQ,IAAI;AAAA,IACZ,SAAS,IAAI;AAAA,EACf,CAAC;AACH;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;AAEA,SAAS,kBAAkB,MAA+B,SAKxD;AACA,QAAM,kBAAkB,WAAW,QAC9B,OAAO,UAAU,eAAe,KAAK,SAAS,UAAU;AAC7D,QAAM,kBAAkB,OAAO,SAAS,aAAa,YAAY,QAAQ,SAAS,KAAK,EAAE,SAAS,IAC9F,QAAQ,SAAS,KAAK,IACtB;AACJ,QAAM,WAAW,kBACb,kBACC,OAAO,KAAK,aAAa,WAAW,KAAK,WAAW;AACzD,QAAM,qBAAqB,oBAAI,IAAY;AAC3C,QAAM,wBAAwB,OAAO,SAAS,mBAAmB,YAAY,QAAQ,eAAe,KAAK,EAAE,SAAS,IAChH,QAAQ,eAAe,KAAK,IAC5B;AACJ,MAAI,iBAAiB;AACnB,QAAI,sBAAuB,oBAAmB,IAAI,qBAAqB;AACvE,eAAW,kBAAkB,oBAAoB,SAAS,eAAe,GAAG;AAC1E,yBAAmB,IAAI,cAAc;AAAA,IACvC;AAAA,EACF,OAAO;AACL,QAAI,OAAO,KAAK,mBAAmB,YAAY,KAAK,eAAe,KAAK,EAAE,SAAS,GAAG;AACpF,yBAAmB,IAAI,KAAK,eAAe,KAAK,CAAC;AAAA,IACnD;AACA,eAAW,kBAAkB,oBAAoB,KAAK,eAAe,GAAG;AACtE,yBAAmB,IAAI,cAAc;AAAA,IACvC;AAAA,EACF;AAEA,QAAM,sBAAsB,oBAAI,IAAY;AAC5C,MAAI,OAAO,KAAK,oBAAoB,YAAY,KAAK,gBAAgB,KAAK,EAAE,SAAS,GAAG;AACtF,wBAAoB,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EACrD;AACA,aAAW,UAAU,oBAAoB,KAAK,gBAAgB,GAAG;AAC/D,wBAAoB,IAAI,MAAM;AAAA,EAChC;AAEA,QAAM,sBAAsB,oBAAI,IAAY;AAC5C,MAAI,OAAO,KAAK,oBAAoB,YAAY,KAAK,gBAAgB,KAAK,EAAE,SAAS,GAAG;AACtF,wBAAoB,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EACrD;AACA,aAAW,UAAU,oBAAoB,KAAK,gBAAgB,GAAG;AAC/D,wBAAoB,IAAI,MAAM;AAAA,EAChC;AAEA,SAAO;AAAA,IACL;AAAA,IACA,oBAAoB,MAAM,KAAK,kBAAkB;AAAA,IACjD,qBAAqB,MAAM,KAAK,mBAAmB;AAAA,IACnD,qBAAqB,MAAM,KAAK,mBAAmB;AAAA,EACrD;AACF;AAEA,SAAS,gBAAgB,MAAqB,UAAyD;AACrG,MAAI,CAAC,SAAS,SAAU,QAAO;AAC/B,MAAI,KAAK,SAAS,aAAa,SAAS,SAAU,QAAO;AAEzD,MAAI,SAAS,mBAAmB,SAAS,GAAG;AAC1C,QAAI,CAAC,KAAK,SAAS,eAAgB,QAAO;AAC1C,QAAI,CAAC,SAAS,mBAAmB,SAAS,KAAK,SAAS,cAAc,EAAG,QAAO;AAAA,EAClF;AAEA,MAAI,SAAS,oBAAoB,SAAS,KAAK,CAAC,SAAS,oBAAoB,SAAS,KAAK,SAAS,MAAM,GAAG;AAC3G,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,oBAAoB,SAAS,GAAG;AAC3C,UAAM,cAAc,KAAK,SAAS,QAAQ,KAAK,CAAC,WAAW,SAAS,oBAAoB,SAAS,MAAM,CAAC;AACxG,QAAI,CAAC,YAAa,QAAO;AAAA,EAC3B;AAEA,SAAO;AACT;AAOA,MAAM,cAAc,oBAAI,IAAmB;AAE3C,IAAI,sBAAsB;AAE1B,eAAe,4BACb,WACA,SACA,SACe;AACf,MAAI,CAAC,aAAa,YAAY,SAAS,EAAG;AAE1C,MAAI,CAAC,iBAAiB,SAAS,EAAG;AAElC,QAAM,OAAO,WAAW,CAAC;AACzB,QAAM,WAAW,kBAAkB,MAAM,OAAO;AAEhD,QAAM,iBAAiB,SAAS,mBAAmB,CAAC,KAAK;AACzD,MAAI,aAAa,gBAAgB,WAAW,MAAM,cAAc;AAEhE,MAAI,IAAI,YAAY,EAAE,OAAO,UAAU,EAAE,SAAS,mBAAmB;AACnE,iBAAa,sBAAsB,WAAW,MAAM,cAAc;AAClE,QAAI,IAAI,YAAY,EAAE,OAAO,UAAU,EAAE,SAAS,mBAAmB;AACnE,aAAO,KAAK,8CAA8C,EAAE,OAAO,WAAW,UAAU,kBAAkB,CAAC;AAC3G;AAAA,IACF;AAAA,EACF;AAEA,aAAW,QAAQ,aAAa;AAC9B,QAAI,CAAC,gBAAgB,MAAM,QAAQ,EAAG;AAEtC,QAAI;AACF,WAAK,KAAK,UAAU;AAAA,IACtB,QAAQ;AAAA,IAER;AAAA,EACF;AACF;AAEA,SAAS,gBAAgB,WAAmB,MAA+B,gBAAgC;AACzG,SAAO,KAAK,UAAU;AAAA,IACpB,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,WAAW,KAAK,IAAI;AAAA,IACpB;AAAA,EACF,CAAC;AACH;AAEA,SAAS,sBAAsB,WAAmB,MAA+B,gBAAgC;AAC/G,QAAM,YAAqC;AAAA,IACzC,WAAW;AAAA,EACb;AACA,MAAI,OAAO,KAAK,OAAO,YAAY,KAAK,GAAG,KAAK,EAAE,SAAS,GAAG;AAC5D,cAAU,KAAK,KAAK,GAAG,KAAK;AAAA,EAC9B;AACA,MAAI,OAAO,KAAK,aAAa,YAAY,KAAK,SAAS,KAAK,EAAE,SAAS,GAAG;AACxE,cAAU,WAAW,KAAK,SAAS,KAAK;AAAA,EAC1C;AACA,MAAI,OAAO,KAAK,eAAe,YAAY,KAAK,WAAW,KAAK,EAAE,SAAS,GAAG;AAC5E,cAAU,aAAa,KAAK,WAAW,KAAK;AAAA,EAC9C;AACA,SAAO,KAAK,UAAU;AAAA,IACpB,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,WAAW,KAAK,IAAI;AAAA,IACpB;AAAA,EACF,CAAC;AACH;AAMA,SAAS,8BAAoC;AAC3C,MAAI,oBAAqB;AACzB,wBAAsB;AAEtB,yBAAuB,OAAO,WAAW,SAAS,YAAY;AAC5D,UAAM,4BAA4B,WAAY,WAAW,CAAC,GAA+B,OAAO;AAAA,EAClG,CAAC;AAED,oCAAkC,OAAO,aAAa;AAMpD,QAAI,SAAS,qBAAqB,gCAAiC;AACnE,UAAM;AAAA,MACJ,SAAS;AAAA,MACR,SAAS,WAAW,CAAC;AAAA,MACtB,SAAS;AAAA,IACX;AAAA,EACF,CAAC;AACH;AAEA,eAAsB,IAAI,KAAiC;AACzD,MAAI,qBAAqB,GAAG,GAAG;AAC7B,WAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrD;AACA,QAAM,EAAE,IAAI,IAAI,MAAM,sBAAsB,GAAG;AAC/C,MAAI,IAAI,MAAM,aAAa,MAAM;AAC/B,WAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrD;AACA,QAAM,kBAAkB,MAAM,0BAA0B,KAAK,GAAG;AAChE,MAAI,CAAC,iBAAiB;AACpB,WAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrD;AACA,QAAM,qBAAqB;AAC3B,QAAM,EAAE,4BAA4B,mBAAmB,IAAI,oBAAoB;AAE/E,8BAA4B;AAE5B,QAAM,UAAU,IAAI,YAAY;AAChC,MAAI,iBAAwD;AAC5D,MAAI,wBAA+D;AACnE,MAAI,cAAoD;AACxD,MAAI,mBAAuE;AAC3E,MAAI,aAAmC;AACvC,MAAI,2BAA2B;AAC/B,MAAI,YAAY;AAChB,QAAM,UAAU,MAAM,gBAAgB;AAEtC,QAAM,SAAS,IAAI,eAAe;AAAA,IAChC,MAAM,YAAY;AAChB,yBAAmB;AACnB,YAAM,OAAO,CAAC,SAAiB;AAC7B,YAAI,UAAW;AACf,YAAI;AACF,4BAAkB,QAAQ,QAAQ,OAAO,SAAS,IAAI;AAAA;AAAA,CAAM,CAAC;AAAA,QAC/D,QAAQ;AACN,0BAAgB;AAAA,QAClB;AAAA,MACF;AAEA,mBAAa;AAAA,QACX,UAAU;AAAA,QACV;AAAA,QACA,OAAO;AAAA,MACT;AACA,kBAAY,IAAI,UAAU;AAQ1B,uBAAiB,QAAQ,QAAQ,OAAO,iBAAiB,CAAC;AAG1D,uBAAiB,YAAY,MAAM;AACjC,YAAI;AACF,4BAAkB,QAAQ,QAAQ,OAAO,kCAAkC,CAAC;AAAA,QAC9E,QAAQ;AACN,0BAAgB;AAAA,QAClB;AAAA,MACF,GAAG,qBAAqB;AAExB,8BAAwB,YAAY,MAAM;AACxC,aAAK,wBAAwB;AAAA,MAC/B,GAAG,0BAA0B;AAE7B,oBAAc,WAAW,MAAM;AAC7B,wBAAgB;AAAA,MAClB,GAAG,kBAAkB;AAAA,IACvB;AAAA,IACA,SAAS;AACP,cAAQ;AAAA,IACV;AAAA,EACF,CAAC;AAED,iBAAe,0BAAyC;AACtD,QAAI,aAAa,yBAA0B;AAC3C,+BAA2B;AAC3B,QAAI;AACF,YAAM,oBAAoB,8BAA8B,GAAG;AAC3D,YAAM,EAAE,KAAK,SAAS,IAAI,MAAM,sBAAsB,iBAAiB;AACvE,UAAI,UAAW;AACf,YAAM,gBAAgB,MAAM,0BAA0B,UAAU,iBAAiB;AACjF,UAAI,CAAC,iBAAiB,CAAC,gBAAgB,oBAAoB,aAAa,GAAG;AACzE,wBAAgB;AAChB;AAAA,MACF;AACA,UAAI,WAAY,YAAW,WAAW;AAAA,IACxC,QAAQ;AACN,sBAAgB;AAAA,IAClB,UAAE;AACA,iCAA2B;AAAA,IAC7B;AAAA,EACF;AAEA,WAAS,kBAAwB;AAC/B,QAAI,UAAW;AACf,UAAM,aAAa;AACnB,YAAQ;AACR,QAAI;AACF,kBAAY,MAAM;AAAA,IACpB,QAAQ;AAAA,IAAC;AAAA,EACX;AAEA,WAAS,UAAU;AACjB,QAAI,UAAW;AACf,gBAAY;AACZ,QAAI,gBAAgB;AAClB,oBAAc,cAAc;AAC5B,uBAAiB;AAAA,IACnB;AACA,QAAI,uBAAuB;AACzB,oBAAc,qBAAqB;AACnC,8BAAwB;AAAA,IAC1B;AACA,QAAI,aAAa;AACf,mBAAa,WAAW;AACxB,oBAAc;AAAA,IAChB;AACA,QAAI,YAAY;AACd,kBAAY,OAAO,UAAU;AAC7B,mBAAa;AAAA,IACf;AACA,uBAAmB;AAGnB,QAAI,OAAO,oBAAoB,SAAS,OAAO;AAAA,EACjD;AAEA,MAAI,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;AAC5D,MAAI,IAAI,OAAO,QAAS,SAAQ;AAEhC,SAAO,IAAI,SAAS,QAAQ;AAAA,IAC1B,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,gBAAgB;AAAA,MAChB,iBAAiB;AAAA,MACjB,cAAc;AAAA,MACd,qBAAqB;AAAA,IACvB;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;AAAA,EACrB,KAAK;AAAA,IACH,SAAS;AAAA,IACT,aAAa;AAAA,IACb,MAAM,CAAC,QAAQ;AAAA,IACf,WAAW;AAAA,MACT,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,qBAAqB;AAAA,YACnB,QAAQ;AAAA,cACN,MAAM;AAAA,cACN,YAAY;AAAA,gBACV,IAAI,EAAE,MAAM,UAAU,aAAa,gDAAgD;AAAA,gBACnF,SAAS,EAAE,MAAM,UAAU,aAAa,sBAAsB;AAAA,gBAC9D,WAAW,EAAE,MAAM,UAAU,aAAa,oCAAoC;AAAA,gBAC9E,gBAAgB,EAAE,MAAM,UAAU,aAAa,qBAAqB;AAAA,cACtE;AAAA,YACF;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,MACA,KAAK,EAAE,aAAa,oBAAoB;AAAA,IAC1C;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/events",
3
- "version": "0.8.1-develop.7296.1.2111d779db",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,8 +33,8 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@open-mercato/queue": "0.8.1-develop.7296.1.2111d779db",
37
- "@open-mercato/shared": "0.8.1-develop.7296.1.2111d779db",
36
+ "@open-mercato/queue": "0.9.0",
37
+ "@open-mercato/shared": "0.9.0",
38
38
  "pg": "8.23.0"
39
39
  },
40
40
  "devDependencies": {
@@ -50,6 +50,5 @@
50
50
  "type": "git",
51
51
  "url": "https://github.com/open-mercato/open-mercato",
52
52
  "directory": "packages/events"
53
- },
54
- "stableVersion": "0.8.0"
53
+ }
55
54
  }
@@ -0,0 +1,172 @@
1
+ import {
2
+ BROADCAST_FLUSH_DEADLINE_MS,
3
+ flushPendingBroadcasts,
4
+ resetBroadcastCoalescerForTests,
5
+ resolveBroadcastCoalesceIntervalMs,
6
+ submitBroadcast,
7
+ } from '../broadcast-coalescer'
8
+
9
+ const INTERVAL_MS = 50
10
+
11
+ function wait(ms: number): Promise<void> {
12
+ return new Promise((resolve) => setTimeout(resolve, ms))
13
+ }
14
+
15
+ describe('broadcast coalescer', () => {
16
+ const originalInterval = process.env.OM_BROADCAST_COALESCE_INTERVAL_MS
17
+
18
+ afterEach(() => {
19
+ resetBroadcastCoalescerForTests()
20
+ if (originalInterval === undefined) delete process.env.OM_BROADCAST_COALESCE_INTERVAL_MS
21
+ else process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = originalInterval
22
+ })
23
+
24
+ it('collapses a burst to a leading dispatch plus a trailing flush carrying the newest payload', async () => {
25
+ const delivered: number[] = []
26
+
27
+ for (let index = 0; index < 50; index += 1) {
28
+ await submitBroadcast('key', async () => { delivered.push(index) }, { intervalMs: INTERVAL_MS })
29
+ }
30
+
31
+ // Leading edge only, so far: the other 49 were superseded, not delivered.
32
+ expect(delivered).toEqual([0])
33
+
34
+ await wait(INTERVAL_MS * 2)
35
+
36
+ // The trailing flush is unconditional, and the survivor is the LAST emit —
37
+ // the property that keeps a DataTable from ending a burst stale.
38
+ expect(delivered).toEqual([0, 49])
39
+ })
40
+
41
+ it('never lets one key suppress or impersonate another', async () => {
42
+ const tenantA: string[] = []
43
+ const tenantB: string[] = []
44
+
45
+ await submitBroadcast('event::tenant-a::', async () => { tenantA.push('a1') }, { intervalMs: INTERVAL_MS })
46
+ await submitBroadcast('event::tenant-b::', async () => { tenantB.push('b1') }, { intervalMs: INTERVAL_MS })
47
+ await submitBroadcast('event::tenant-a::', async () => { tenantA.push('a2') }, { intervalMs: INTERVAL_MS })
48
+ await submitBroadcast('event::tenant-b::', async () => { tenantB.push('b2') }, { intervalMs: INTERVAL_MS })
49
+
50
+ expect(tenantA).toEqual(['a1'])
51
+ expect(tenantB).toEqual(['b1'])
52
+
53
+ await wait(INTERVAL_MS * 2)
54
+
55
+ expect(tenantA).toEqual(['a1', 'a2'])
56
+ expect(tenantB).toEqual(['b1', 'b2'])
57
+ })
58
+
59
+ it('dispatches every submission synchronously when the interval is zero', async () => {
60
+ const delivered: number[] = []
61
+
62
+ for (let index = 0; index < 5; index += 1) {
63
+ await submitBroadcast('key', async () => { delivered.push(index) }, { intervalMs: 0 })
64
+ }
65
+
66
+ expect(delivered).toEqual([0, 1, 2, 3, 4])
67
+ })
68
+
69
+ it('contains a failing deferred dispatch so the next window still delivers', async () => {
70
+ const delivered: string[] = []
71
+
72
+ await submitBroadcast('key', async () => { delivered.push('leading') }, { intervalMs: INTERVAL_MS })
73
+ await submitBroadcast('key', async () => { throw new Error('[internal] pg_notify unavailable') }, { intervalMs: INTERVAL_MS })
74
+
75
+ await wait(INTERVAL_MS * 2)
76
+
77
+ await submitBroadcast('key', async () => { delivered.push('after-failure') }, { intervalMs: INTERVAL_MS })
78
+ await wait(INTERVAL_MS * 2)
79
+
80
+ expect(delivered).toContain('leading')
81
+ expect(delivered).toContain('after-failure')
82
+ })
83
+
84
+ it('flushes pending survivors on shutdown instead of dropping the tail', async () => {
85
+ const delivered: string[] = []
86
+
87
+ await submitBroadcast('key', async () => { delivered.push('leading') }, { intervalMs: 10_000 })
88
+ await submitBroadcast('key', async () => { delivered.push('tail') }, { intervalMs: 10_000 })
89
+
90
+ expect(delivered).toEqual(['leading'])
91
+
92
+ await flushPendingBroadcasts()
93
+
94
+ expect(delivered).toEqual(['leading', 'tail'])
95
+ })
96
+
97
+ it('bounds a shutdown flush by its deadline when a dispatch hangs', async () => {
98
+ const delivered: string[] = []
99
+
100
+ await submitBroadcast('key', async () => { delivered.push('leading') }, { intervalMs: 10_000 })
101
+ await submitBroadcast('key', () => new Promise<void>(() => {}), { intervalMs: 10_000 })
102
+ await submitBroadcast('other', async () => { delivered.push('other-leading') }, { intervalMs: 10_000 })
103
+ await submitBroadcast('other', async () => { delivered.push('other-tail') }, { intervalMs: 10_000 })
104
+
105
+ const startedAt = Date.now()
106
+ await flushPendingBroadcasts({ deadlineMs: INTERVAL_MS })
107
+ const elapsedMs = Date.now() - startedAt
108
+
109
+ expect(elapsedMs).toBeGreaterThanOrEqual(INTERVAL_MS - 5)
110
+ expect(elapsedMs).toBeLessThan(INTERVAL_MS * 20)
111
+ expect(delivered).toEqual(['leading', 'other-leading', 'other-tail'])
112
+ })
113
+
114
+ it('exposes a fixed 3000ms exit deadline', () => {
115
+ expect(BROADCAST_FLUSH_DEADLINE_MS).toBe(3000)
116
+ })
117
+
118
+ it('never runs two deliveries of one key concurrently, so an older payload cannot land last', async () => {
119
+ const started: string[] = []
120
+ const finished: string[] = []
121
+
122
+ const slowDispatch = (label: string, durationMs: number) => async () => {
123
+ started.push(label)
124
+ await wait(durationMs)
125
+ finished.push(label)
126
+ }
127
+
128
+ // Leading edge takes far longer than one window, so a naive re-arm would fire
129
+ // the next flush while it is still in flight.
130
+ await submitBroadcast('key', slowDispatch('first', INTERVAL_MS * 3), { intervalMs: INTERVAL_MS })
131
+ await submitBroadcast('key', slowDispatch('second', 0), { intervalMs: INTERVAL_MS })
132
+
133
+ await wait(INTERVAL_MS * 6)
134
+
135
+ expect(started).toEqual(['first', 'second'])
136
+ expect(finished).toEqual(['first', 'second'])
137
+ // 'second' must not have started before 'first' completed.
138
+ expect(started.indexOf('second')).toBeGreaterThanOrEqual(0)
139
+ expect(finished.indexOf('first')).toBeLessThan(finished.indexOf('second'))
140
+ })
141
+
142
+ it('forgets a key once its burst ends, so pending state stays bounded', async () => {
143
+ const delivered: string[] = []
144
+ await submitBroadcast('key', async () => { delivered.push('first') }, { intervalMs: INTERVAL_MS })
145
+ await wait(INTERVAL_MS * 2)
146
+
147
+ // The window closed with nothing superseded, so the next submit is a fresh
148
+ // leading edge rather than a queued survivor.
149
+ await submitBroadcast('key', async () => { delivered.push('second') }, { intervalMs: INTERVAL_MS })
150
+
151
+ expect(delivered).toEqual(['first', 'second'])
152
+ })
153
+
154
+ describe('interval resolution', () => {
155
+ it('defaults to 250ms and rejects unusable values', () => {
156
+ delete process.env.OM_BROADCAST_COALESCE_INTERVAL_MS
157
+ expect(resolveBroadcastCoalesceIntervalMs()).toBe(250)
158
+
159
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = '0'
160
+ expect(resolveBroadcastCoalesceIntervalMs()).toBe(0)
161
+
162
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = '1000'
163
+ expect(resolveBroadcastCoalesceIntervalMs()).toBe(1000)
164
+
165
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = '-5'
166
+ expect(resolveBroadcastCoalesceIntervalMs()).toBe(250)
167
+
168
+ process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = 'soon'
169
+ expect(resolveBroadcastCoalesceIntervalMs()).toBe(250)
170
+ })
171
+ })
172
+ })