@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +18 -0
- package/dist/broadcast-coalescer.js +124 -0
- package/dist/broadcast-coalescer.js.map +7 -0
- package/dist/bus.js +108 -11
- package/dist/bus.js.map +2 -2
- package/dist/index.js +12 -0
- package/dist/index.js.map +2 -2
- package/dist/modules/events/api/route.js +1 -0
- package/dist/modules/events/api/route.js.map +2 -2
- package/dist/modules/events/api/stream/route.js +161 -20
- package/dist/modules/events/api/stream/route.js.map +2 -2
- package/package.json +4 -5
- package/src/__tests__/broadcast-coalescer.test.ts +172 -0
- package/src/__tests__/broadcast-coalescing-bus.test.ts +409 -0
- package/src/broadcast-coalescer.ts +204 -0
- package/src/bus.ts +199 -19
- package/src/index.ts +10 -0
- package/src/modules/events/api/__tests__/route.test.ts +27 -4
- package/src/modules/events/api/route.ts +1 -0
- package/src/modules/events/api/stream/__tests__/route.test.ts +650 -8
- package/src/modules/events/api/stream/route.ts +200 -26
|
@@ -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 (
|
|
235
|
+
if (ctx.auth?.isApiKey === true) {
|
|
152
236
|
return new Response("Unauthorized", { status: 401 });
|
|
153
237
|
}
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
organizationId,
|
|
173
|
-
userId,
|
|
174
|
-
roleIds,
|
|
268
|
+
identity: authorizedIdentity,
|
|
175
269
|
send,
|
|
176
|
-
close:
|
|
270
|
+
close: closeConnection
|
|
177
271
|
};
|
|
178
272
|
connections.add(connection);
|
|
179
|
-
|
|
273
|
+
streamController.enqueue(encoder.encode(": connected\n\n"));
|
|
180
274
|
heartbeatTimer = setInterval(() => {
|
|
181
275
|
try {
|
|
182
|
-
|
|
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: "
|
|
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": "AAUA,SAAS,6BAA6B;
|
|
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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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.
|
|
37
|
-
"@open-mercato/shared": "0.
|
|
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
|
+
})
|