@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/dist/index.d.ts +232 -0
- package/dist/index.js +343 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
- package/src/index.ts +431 -0
- package/src/nats-jwt.ts +335 -0
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
|
+
}
|