@opengeni/events 0.2.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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@opengeni/events",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "provenance": true
21
+ },
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@opengeni/contracts": "^0.3.0",
28
+ "@opengeni/db": "^0.2.0",
29
+ "nats": "^2.29.3"
30
+ },
31
+ "devDependencies": {
32
+ "tsup": "^8.5.0",
33
+ "typescript": "^6.0.3"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
38
+ "directory": "packages/events"
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,431 @@
1
+ import type { SessionBusMessage, SessionEvent } from "@opengeni/contracts";
2
+ import { appendSessionEvents, sessionSubject, type AppendEventInput, type Database } from "@opengeni/db";
3
+ import { connect, JSONCodec, type ConnectionOptions, type Msg, type NatsConnection, type Subscription } from "nats";
4
+
5
+ const codec = JSONCodec<SessionBusMessage | SessionEvent>();
6
+
7
+ /**
8
+ * Reconnect + keepalive defaults applied to EVERY long-lived NATS connection
9
+ * this package opens (the event bus AND the standalone auth-callout responder).
10
+ *
11
+ * The production outage these guard against: an in-cluster NATS broker pod
12
+ * restart. nats.js's stock policy gives up after ~10 attempts (~20s) and the
13
+ * client goes permanently CONNECTION_CLOSED — which takes the whole control
14
+ * plane down with it: every session-create publishes events to NATS, and the
15
+ * API-hosted auth-callout responder dies so BYO agents get "authorization
16
+ * violation". Recovery then required a MANUAL api+worker restart. With these
17
+ * options the client retries forever and auto-recovers the moment the broker
18
+ * returns. Factored into one source of truth so the call sites never drift.
19
+ *
20
+ * - `reconnect` + `maxReconnectAttempts: -1` — never give up (infinite retry).
21
+ * - `reconnectTimeWait` (2s base) + `reconnectJitter`/`reconnectJitterTLS`
22
+ * (up to 1s) — a fleet of api/worker pods doesn't thundering-herd the broker
23
+ * on recovery.
24
+ * - `waitOnFirstConnect` — a broker briefly unavailable at boot must not
25
+ * hard-fail the process; the client keeps trying instead of throwing.
26
+ * - `pingInterval`/`maxPingOut` — promptly detect a silently-dead socket so the
27
+ * reconnect machinery actually engages instead of hanging on a zombie.
28
+ */
29
+ const RECONNECT_OPTIONS = {
30
+ reconnect: true,
31
+ maxReconnectAttempts: -1,
32
+ reconnectTimeWait: 2_000,
33
+ reconnectJitter: 1_000,
34
+ reconnectJitterTLS: 1_000,
35
+ waitOnFirstConnect: true,
36
+ pingInterval: 20_000,
37
+ maxPingOut: 3,
38
+ } satisfies ConnectionOptions;
39
+
40
+ /**
41
+ * The single source of truth for a long-lived connection's resilience: merge the
42
+ * reconnect/keepalive defaults UNDER the caller's connection options (servers +
43
+ * optional auth/name). Every long-lived `connect()` in this package goes through
44
+ * here so the two call sites can never diverge.
45
+ */
46
+ function withReconnectDefaults(options: ConnectionOptions): ConnectionOptions {
47
+ return { ...RECONNECT_OPTIONS, ...options };
48
+ }
49
+
50
+ /** How long a best-effort publish waits on `flush()` before giving up (see `publish`). */
51
+ const PUBLISH_FLUSH_TIMEOUT_MS = 2_000;
52
+
53
+ /**
54
+ * Await `nc.flush()` but never longer than `timeoutMs`. With infinite reconnect a
55
+ * `flush()` issued while the broker is down does NOT reject — it pends until the
56
+ * broker returns, which can be minutes. Racing it against a timer keeps a long
57
+ * outage from stalling an in-flight turn; the published message stays buffered
58
+ * and is delivered on reconnect regardless. A flush rejection (connection fully
59
+ * CLOSED) is swallowed here so the timeout race never leaks an unhandled
60
+ * rejection — the caller's publish path is what logs the drop.
61
+ */
62
+ async function flushWithTimeout(nc: NatsConnection, timeoutMs: number): Promise<void> {
63
+ let timer: ReturnType<typeof setTimeout> | undefined;
64
+ const timeout = new Promise<void>((resolve) => {
65
+ timer = setTimeout(resolve, timeoutMs);
66
+ });
67
+ try {
68
+ await Promise.race([nc.flush().catch(() => undefined), timeout]);
69
+ } finally {
70
+ if (timer) {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Drain a long-lived connection's status async-iterator to the log so a future
78
+ * broker outage is OBSERVABLE (disconnect → reconnecting → reconnect → update).
79
+ * Fire-and-forget for the connection's lifetime; the loop ends when the
80
+ * connection closes. `label` distinguishes the event-bus connection from the
81
+ * auth-callout responder in the logs.
82
+ */
83
+ function logConnectionStatus(nc: NatsConnection, label: string): void {
84
+ void (async () => {
85
+ try {
86
+ for await (const status of nc.status()) {
87
+ console.warn(`[nats:${label}] ${status.type}`, status.data);
88
+ }
89
+ } catch {
90
+ // The status iterator simply ends when the connection closes; never let it
91
+ // throw out of this background loop.
92
+ }
93
+ })();
94
+ }
95
+
96
+ export {
97
+ decodeAuthRequest,
98
+ mintAuthResponse,
99
+ mintUserJwt,
100
+ workspaceAgentPermissions,
101
+ type DecodedAuthRequest,
102
+ type MintAuthResponseInput,
103
+ type MintUserJwtInput,
104
+ type NatsPermission,
105
+ type NatsPermissions,
106
+ } from "./nats-jwt";
107
+
108
+ // Re-export the raw NATS primitives a consumer needs to open a direct connection or
109
+ // generate nkeys (the auth-callout responder's standalone connection, the
110
+ // agent-simulating integration tests). This keeps `nats` an internal dependency of
111
+ // this leaf — callers in the bun workspace reach it through @opengeni/events rather
112
+ // than depending on `nats` directly.
113
+ export { connect, nkeys, type NatsConnection } from "nats";
114
+
115
+ /**
116
+ * A raw request/reply reply — just the response bytes. Mirrors the subset of the
117
+ * NATS `Msg` shape a binary request/reply caller needs (`NatsControlRpc` consumes
118
+ * exactly this). Kept minimal so the events package does not leak the `nats` `Msg`
119
+ * type into the agent-loop-free runtime leaf.
120
+ */
121
+ export type RequestReply = { data: Uint8Array };
122
+
123
+ /**
124
+ * The minimal request/reply connection the selfhosted control plane consumes
125
+ * (structurally identical to `@opengeni/runtime`'s `NatsRequestConnection`). The
126
+ * API/worker hand this accessor to `NatsControlRpc` so the control transport rides
127
+ * the SAME managed NATS connection the event bus already owns — a NATS connection
128
+ * natively supports both pub/sub and request/reply, so there is NEVER a second
129
+ * connection.
130
+ */
131
+ export interface RequestConnection {
132
+ request(subject: string, payload: Uint8Array, opts: { timeout: number }): Promise<RequestReply>;
133
+ }
134
+
135
+ /**
136
+ * A handler answering a request/reply on a subscribed subject: given the request
137
+ * bytes (+ the concrete subject the message landed on, for `agent.<ws>.<id>.rpc`
138
+ * style wildcard routing), return the response bytes to reply with. A thrown error
139
+ * leaves the request unanswered (the caller's request times out / sees no
140
+ * responder), which the control plane maps to `agent_offline` / reconnecting.
141
+ */
142
+ export type RequestHandler = (request: Uint8Array, subject: string) => Promise<Uint8Array> | Uint8Array;
143
+
144
+ export type EventBus = {
145
+ publish: (workspaceId: string, sessionId: string, events: SessionEvent[]) => Promise<void>;
146
+ subscribe: (workspaceId: string, sessionId: string, onEvents: (events: SessionEvent[]) => void | Promise<void>) => Promise<() => void>;
147
+ /**
148
+ * Issue a binary request/reply on a subject over the bus's NATS connection
149
+ * (the selfhosted control plane: `agent.<ws>.<id>.rpc`). A new usage of what was
150
+ * a one-way bus — same connection, native NATS request/reply. Rejects on a
151
+ * no-responder (NATS 503) or a request timeout; the caller (`NatsControlRpc`)
152
+ * maps those to `agent_offline` / `agent_reconnecting`, never a NotFound.
153
+ */
154
+ request: (subject: string, payload: Uint8Array, opts: { timeoutMs: number }) => Promise<RequestReply>;
155
+ /**
156
+ * Subscribe-and-reply on a subject (the responder side — the enrolled agent, or
157
+ * a test stand-in for it): for every request on `subject`, call `handler` and
158
+ * `respond` with its bytes over the SAME connection. Returns an unsubscribe fn.
159
+ * A subject may be a NATS wildcard (e.g. `agent.*.*.rpc`).
160
+ */
161
+ subscribeRequests: (subject: string, handler: RequestHandler) => () => void;
162
+ /**
163
+ * Subscribe to the agent EVENT plane (the one-way fire-and-forget heartbeats +
164
+ * going-offline the agent PUBLISHES on `agent.<ws>.<id>.events`, NOT a
165
+ * request/reply). The M10 metrics-ingestion consumer subscribes the wildcard
166
+ * `agent.*.*.events` and gets each raw payload plus its concrete subject (so it
167
+ * can extract `<ws>`/`<id>` for the per-enrollment upsert). Returns an
168
+ * unsubscribe fn. Decoding the AgentEvent is the caller's concern (this leaf
169
+ * does not depend on `@opengeni/agent-proto`).
170
+ */
171
+ subscribeAgentEvents: (
172
+ subject: string,
173
+ handler: (payload: Uint8Array, subject: string) => void | Promise<void>,
174
+ ) => () => void;
175
+ /**
176
+ * The `RequestConnection` accessor the selfhosted `NatsControlRpc` consumes —
177
+ * the SAME managed connection (pub/sub + request/reply share it). The control
178
+ * plane injects this so the transport never opens a second connection.
179
+ */
180
+ getRequestConnection: () => RequestConnection;
181
+ close: () => Promise<void>;
182
+ };
183
+
184
+ /**
185
+ * Connect the event bus + control-plane request/reply over ONE managed NATS
186
+ * connection. `auth` is the PRIVILEGED control-plane login (M-AUTH): when the
187
+ * server runs with auth_callout, the api/worker authenticates as a static account
188
+ * user permitted to request `agent.*.rpc` + receive its inbox replies. When `auth`
189
+ * is omitted the connection is anonymous (local dev / a NATS without auth_callout)
190
+ * — the existing behavior, unchanged.
191
+ */
192
+ export async function createNatsEventBus(
193
+ natsUrl: string,
194
+ auth?: { user: string; pass: string },
195
+ ): Promise<EventBus> {
196
+ const connectOptions: ConnectionOptions = { servers: natsUrl };
197
+ if (auth) {
198
+ connectOptions.user = auth.user;
199
+ connectOptions.pass = auth.pass;
200
+ }
201
+ const nc = await connect(withReconnectDefaults(connectOptions));
202
+ logConnectionStatus(nc, "event-bus");
203
+ const requestConnection: RequestConnection = {
204
+ request: async (subject, payload, opts) => requestReply(nc, subject, payload, opts.timeout),
205
+ };
206
+ return {
207
+ publish: async (workspaceId, sessionId, events) => {
208
+ if (events.length === 0) {
209
+ return;
210
+ }
211
+ // Best-effort LIVE fan-out. These events are ALREADY durably appended to
212
+ // the DB before we get here (they carry a DB-assigned `sequence`), and
213
+ // every consumer reconciles from that durable log — the server SSE stream
214
+ // replays + gap-backfills via `listSessionEvents`, and the SDK client
215
+ // reconnects and replays from the durable events endpoint. So a publish
216
+ // that fails during a broker blip only delays LIVE delivery (healed by the
217
+ // next successful publish's gap-backfill, or a stream reconnect); it must
218
+ // never throw the in-flight turn to death.
219
+ try {
220
+ nc.publish(sessionSubject(workspaceId, sessionId), codec.encode({ workspaceId, sessionId, events }));
221
+ } catch (error) {
222
+ // `publish()` throws synchronously only when the connection is fully
223
+ // CLOSED (with infinite reconnect, effectively never outside shutdown).
224
+ console.warn(
225
+ `[nats:event-bus] dropped live publish for ${workspaceId}/${sessionId}; events are durable in the DB and reconcile on stream replay`,
226
+ error,
227
+ );
228
+ return;
229
+ }
230
+ await flushWithTimeout(nc, PUBLISH_FLUSH_TIMEOUT_MS);
231
+ },
232
+ subscribe: async (workspaceId, sessionId, onEvents) => subscribeSession(nc, workspaceId, sessionId, onEvents),
233
+ request: async (subject, payload, opts) => requestReply(nc, subject, payload, opts.timeoutMs),
234
+ subscribeRequests: (subject, handler) => subscribeRequests(nc, subject, handler),
235
+ subscribeAgentEvents: (subject, handler) => subscribeAgentEvents(nc, subject, handler),
236
+ getRequestConnection: () => requestConnection,
237
+ close: async () => {
238
+ await nc.drain();
239
+ },
240
+ };
241
+ }
242
+
243
+ /**
244
+ * A standalone NATS connection answering request/reply on ONE subject — the
245
+ * transport primitive the auth-callout responder uses. It is DELIBERATELY a
246
+ * SEPARATE connection from the event bus: the callout responder authenticates as
247
+ * the callout account's `auth_users` user (a username/password or token in the
248
+ * `AUTH` account), which is a DIFFERENT identity from the control-plane's
249
+ * privileged account that the event bus + `NatsControlRpc` ride. One connection
250
+ * per identity; never multiplex the two.
251
+ *
252
+ * `request`/`reply` here is the RAW NATS request/reply (`$SYS.REQ.USER.AUTH`): the
253
+ * server publishes an authorization request with a reply inbox; the handler returns
254
+ * the signed authorization-response bytes which we `respond` on that inbox.
255
+ */
256
+ export interface ResponderConnection {
257
+ /** Subscribe-and-reply on `subject`; returns an async close that drains. */
258
+ close: () => Promise<void>;
259
+ }
260
+
261
+ /** Connection auth for a standalone NATS connection (the callout responder). */
262
+ export type NatsConnectAuth =
263
+ | { kind: "user-password"; user: string; pass: string }
264
+ | { kind: "token"; token: string }
265
+ | { kind: "anonymous" };
266
+
267
+ /**
268
+ * Open a standalone NATS connection and subscribe `subject`, replying to every
269
+ * request with `handler(requestBytes, subject)`. Used by the auth-callout
270
+ * responder to serve `$SYS.REQ.USER.AUTH` as the callout auth user. Returns a
271
+ * handle whose `close()` drains the connection. A handler that throws leaves the
272
+ * request UNANSWERED — for auth-callout that means the server denies the
273
+ * connection on its own timeout, which is the correct fail-closed behavior (a
274
+ * responder bug must never accidentally grant access).
275
+ */
276
+ export async function createResponderConnection(
277
+ natsUrl: string,
278
+ auth: NatsConnectAuth,
279
+ subject: string,
280
+ handler: RequestHandler,
281
+ options: { name?: string } = {},
282
+ ): Promise<ResponderConnection> {
283
+ const connectOptions: ConnectionOptions = { servers: natsUrl };
284
+ if (options.name) {
285
+ connectOptions.name = options.name;
286
+ }
287
+ if (auth.kind === "user-password") {
288
+ connectOptions.user = auth.user;
289
+ connectOptions.pass = auth.pass;
290
+ } else if (auth.kind === "token") {
291
+ connectOptions.token = auth.token;
292
+ }
293
+ const nc = await connect(withReconnectDefaults(connectOptions));
294
+ logConnectionStatus(nc, options.name ? `auth-callout:${options.name}` : "auth-callout");
295
+ const sub: Subscription = nc.subscribe(subject);
296
+ void (async () => {
297
+ for await (const msg of sub) {
298
+ if (!msg.reply) {
299
+ continue;
300
+ }
301
+ try {
302
+ const reply = await handler(msg.data, msg.subject);
303
+ msg.respond(reply);
304
+ } catch {
305
+ // Leave UNANSWERED — fail-closed. The server denies the connect attempt
306
+ // on its callout timeout; a responder error never grants access.
307
+ }
308
+ }
309
+ })();
310
+ return {
311
+ close: async () => {
312
+ sub.unsubscribe();
313
+ await nc.drain();
314
+ },
315
+ };
316
+ }
317
+
318
+ export async function appendAndPublishEvents(db: Database, bus: EventBus, workspaceId: string, sessionId: string, events: AppendEventInput[]): Promise<SessionEvent[]> {
319
+ const appended = await appendSessionEvents(db, workspaceId, sessionId, events);
320
+ // The DB append above is the durable system of record; the publish is only a
321
+ // best-effort LIVE fan-out. Guard it so NO EventBus implementation can throw an
322
+ // in-flight agent turn to death on a transient NATS disconnect — consumers
323
+ // reconcile any missed live events from the durable log via the events/stream
324
+ // endpoint (DB replay + gap-backfill). The managed `createNatsEventBus` bus
325
+ // already swallows internally, so this catch is the belt-and-suspenders guard
326
+ // for any other bus impl (and a fully CLOSED connection during shutdown).
327
+ try {
328
+ await bus.publish(workspaceId, sessionId, appended);
329
+ } catch (error) {
330
+ console.warn(
331
+ `[events] live publish failed for ${workspaceId}/${sessionId}; ${appended.length} event(s) are durable and reconcile on stream replay`,
332
+ error,
333
+ );
334
+ }
335
+ return appended;
336
+ }
337
+
338
+ function subscribeSession(nc: NatsConnection, workspaceId: string, sessionId: string, onEvents: (events: SessionEvent[]) => void | Promise<void>): () => void {
339
+ const sub: Subscription = nc.subscribe(sessionSubject(workspaceId, sessionId));
340
+ void (async () => {
341
+ for await (const msg of sub) {
342
+ const decoded = codec.decode(msg.data) as SessionBusMessage | SessionEvent;
343
+ const events = "events" in decoded ? decoded.events : [decoded];
344
+ await onEvents(events);
345
+ }
346
+ })();
347
+ return () => {
348
+ sub.unsubscribe();
349
+ };
350
+ }
351
+
352
+ /**
353
+ * A binary request/reply over the managed connection. Returns ONLY the reply
354
+ * bytes (the `RequestReply` shape) — the request/reply error semantics (a
355
+ * no-responder NATS 503, a request timeout) propagate as the rejected promise so
356
+ * the caller owns the mapping. The reply is delivered via the connection's
357
+ * built-in mux inbox; no extra subscription is created here.
358
+ */
359
+ async function requestReply(nc: NatsConnection, subject: string, payload: Uint8Array, timeout: number): Promise<RequestReply> {
360
+ const msg: Msg = await nc.request(subject, payload, { timeout });
361
+ return { data: msg.data };
362
+ }
363
+
364
+ /**
365
+ * Subscribe to `subject` and reply to every request with the handler's bytes,
366
+ * over the SAME connection. The responder side of request/reply: each delivered
367
+ * `Msg` carries a `reply` inbox; `msg.respond(bytes)` publishes the answer there.
368
+ * A handler that throws (or a message with no `reply` subject) is left unanswered
369
+ * — the requester then sees a timeout, never a malformed reply.
370
+ */
371
+ function subscribeRequests(nc: NatsConnection, subject: string, handler: RequestHandler): () => void {
372
+ const sub: Subscription = nc.subscribe(subject);
373
+ void (async () => {
374
+ for await (const msg of sub) {
375
+ // A request always carries a reply inbox; a plain publish to this subject
376
+ // (no reply) is ignored — request/reply is the only contract here.
377
+ if (!msg.reply) {
378
+ continue;
379
+ }
380
+ try {
381
+ const reply = await handler(msg.data, msg.subject);
382
+ msg.respond(reply);
383
+ } catch {
384
+ // Leave the request unanswered: the requester's request times out, which
385
+ // the selfhosted control plane reads as a transient blip (reconnecting),
386
+ // never a malformed reply. The responder stays subscribed for the next op.
387
+ }
388
+ }
389
+ })();
390
+ return () => {
391
+ sub.unsubscribe();
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Subscribe to the one-way agent event plane: deliver each published payload (the
397
+ * agent's `AgentEvent` heartbeat / going-offline, NOT a request/reply) to the
398
+ * handler with its concrete subject. A plain `nc.subscribe` (no reply); a handler
399
+ * that throws is swallowed so one bad event never tears down the subscription
400
+ * (ingestion is best-effort — a metrics gap is never fatal).
401
+ */
402
+ function subscribeAgentEvents(
403
+ nc: NatsConnection,
404
+ subject: string,
405
+ handler: (payload: Uint8Array, subject: string) => void | Promise<void>,
406
+ ): () => void {
407
+ const sub: Subscription = nc.subscribe(subject);
408
+ void (async () => {
409
+ for await (const msg of sub) {
410
+ try {
411
+ await handler(msg.data, msg.subject);
412
+ } catch {
413
+ // Swallow: best-effort ingestion. The subscription stays live for the
414
+ // next event.
415
+ }
416
+ }
417
+ })();
418
+ return () => {
419
+ sub.unsubscribe();
420
+ };
421
+ }
422
+
423
+ export function formatSse(event: SessionEvent): string {
424
+ return [
425
+ `id: ${event.sequence}`,
426
+ `event: ${event.type}`,
427
+ `data: ${JSON.stringify(event)}`,
428
+ "",
429
+ "",
430
+ ].join("\n");
431
+ }