@opengeni/events 0.4.30 → 0.4.32-canary.36199476632001

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 CHANGED
@@ -9,6 +9,14 @@ export type EventBusOptions = {
9
9
  logger?: EventLogger;
10
10
  /** Test/host transport seam; production defaults to the nats.js connector. */
11
11
  connect?: typeof connect;
12
+ /**
13
+ * Metrics seam: called once for every subscription that ends without being
14
+ * unsubscribed (see {@link natsSubscriptionTerminationCounter}). A throwing
15
+ * sink is ignored.
16
+ */
17
+ onSubscriptionTerminated?: (termination: NatsSubscriptionTermination) => void;
18
+ /** Test seam for the responder resubscribe backoff; production uses {@link natsResubscribeDelayMs}. */
19
+ resubscribeDelayMs?: (attempt: number) => number;
12
20
  };
13
21
  export { SESSION_EVENT_COALESCED_TEXT_TARGET_BYTES, coalesceSessionEventDeltas, coalesceSessionEventDeltasWithCoverage, type CoalescedSessionEventPage, } from "./coalesce.js";
14
22
  /** Comfortably below NATS Core's common 1 MiB max_payload default. */
@@ -21,6 +29,54 @@ export declare const SESSION_EVENT_HTTP_PAGE_MAX_BYTES: number;
21
29
  export declare const WORKSPACE_CONTROL_NATS_MESSAGE_MAX_BYTES: number;
22
30
  /** Count+byte envelope for one workspace-control REST replay page. */
23
31
  export declare const WORKSPACE_CONTROL_HTTP_PAGE_MAX_BYTES: number;
32
+ /**
33
+ * Closed label set for {@link NatsSubscriptionTermination}. Each value names one
34
+ * subscription role this package owns; no subject, tenant, or payload
35
+ * dimension is ever admitted, so a metric sink can use them as labels.
36
+ */
37
+ export declare const NATS_SUBSCRIPTION_KINDS: readonly ["session_events", "workspace_control", "request_responder", "agent_events", "auth_callout"];
38
+ export type NatsSubscriptionKind = (typeof NATS_SUBSCRIPTION_KINDS)[number];
39
+ /**
40
+ * What happens after an unexpected end: a per-consumer subscription tells its
41
+ * consumer (`consumer_reconnect`, e.g. session SSE fails retryably and the
42
+ * client replays from Postgres), a process-lifetime responder resubscribes with
43
+ * bounded backoff (`resubscribe`), and a consumer that registered no hook only
44
+ * relies on its own durable re-read (`none`).
45
+ */
46
+ export declare const NATS_SUBSCRIPTION_RECOVERIES: readonly ["consumer_reconnect", "resubscribe", "none"];
47
+ export type NatsSubscriptionRecovery = (typeof NATS_SUBSCRIPTION_RECOVERIES)[number];
48
+ export type NatsSubscriptionTermination = {
49
+ kind: NatsSubscriptionKind;
50
+ recovery: NatsSubscriptionRecovery;
51
+ };
52
+ /** Prometheus counter name for {@link natsSubscriptionTerminationCounter}. */
53
+ export declare const NATS_SUBSCRIPTION_TERMINATIONS_METRIC = "opengeni_nats_subscription_terminations_total";
54
+ /**
55
+ * Adapt a counter sink (the API/worker `Observability`) into the
56
+ * `onSubscriptionTerminated` hook. Labels are the closed kind/recovery sets.
57
+ */
58
+ export declare function natsSubscriptionTerminationCounter(sink: {
59
+ incrementCounter(input: {
60
+ name: string;
61
+ help?: string;
62
+ labels?: Record<string, string>;
63
+ }): void;
64
+ }): (termination: NatsSubscriptionTermination) => void;
65
+ /** Per-subscription consumer options for {@link EventBus.subscribe}. */
66
+ export type BusSubscriptionOptions = {
67
+ /**
68
+ * Called at most once when the live transport subscription ends while the
69
+ * consumer still holds it (a broker-side error such as a permissions
70
+ * violation, or an iterator end the consumer did not request). Live events
71
+ * stop after this call, so the consumer must reconcile from Postgres: session
72
+ * and workspace-control SSE fail the stream retryably and the client
73
+ * reconnects and replays. Never called after the returned unsubscribe runs or
74
+ * while the bus itself is closing.
75
+ */
76
+ onTerminated?: (error: unknown) => void;
77
+ };
78
+ /** Bounded resubscribe backoff for process-lifetime responders: 1 s doubling to 30 s, plus up to 1 s jitter. */
79
+ export declare function natsResubscribeDelayMs(attempt: number, random?: () => number): number;
24
80
  export { decodeAuthRequest, mintAuthResponse, mintUserJwt, parseAgentConnectionName, workspaceAgentPermissions, type DecodedAuthRequest, type MintAuthResponseInput, type MintUserJwtInput, type NatsPermission, type NatsPermissions, } from "./nats-jwt.js";
25
81
  export { connect, nkeys, type NatsConnection } from "nats";
26
82
  /**
@@ -107,11 +163,18 @@ export type EventBus = {
107
163
  * publish promise when this capability is unavailable.
108
164
  */
109
165
  publishConfirmed?: (workspaceId: string, sessionId: string, events: SessionEvent[]) => Promise<void>;
110
- subscribe: (workspaceId: string, sessionId: string, onEvents: (events: SessionEvent[]) => void | Promise<void>) => Promise<() => void>;
166
+ /**
167
+ * Live session-event fanout. `options.onTerminated` reports a transport
168
+ * subscription that ended while still held; implementations whose
169
+ * subscriptions can end on their own should call it so the consumer
170
+ * reconciles from Postgres. Buses that never end a held subscription may
171
+ * ignore it.
172
+ */
173
+ subscribe: (workspaceId: string, sessionId: string, onEvents: (events: SessionEvent[]) => void | Promise<void>, options?: BusSubscriptionOptions) => Promise<() => void>;
111
174
  /** Best-effort live invalidation; the event is already durable in Postgres. */
112
175
  publishWorkspaceControl: (workspaceId: string, event: WorkspaceControlEvent) => Promise<void>;
113
176
  /** One workspace subscription fans a control change to every open descendant view. */
114
- subscribeWorkspaceControl: (workspaceId: string, onEvent: (event: WorkspaceControlEvent) => void | Promise<void>) => Promise<() => void>;
177
+ subscribeWorkspaceControl: (workspaceId: string, onEvent: (event: WorkspaceControlEvent) => void | Promise<void>, options?: BusSubscriptionOptions) => Promise<() => void>;
115
178
  /**
116
179
  * Issue a binary request/reply on a subject over the bus's NATS connection
117
180
  * (the selfhosted control plane's exact claimed process subject). A new usage of what was
@@ -208,6 +271,8 @@ export declare function createResponderConnection(natsUrl: string, auth: NatsCon
208
271
  name?: string;
209
272
  logger?: EventLogger;
210
273
  connect?: typeof connect;
274
+ onSubscriptionTerminated?: (termination: NatsSubscriptionTermination) => void;
275
+ resubscribeDelayMs?: (attempt: number) => number;
211
276
  }): Promise<ResponderConnection>;
212
277
  /**
213
278
  * Optional timing seam for {@link appendAndPublishEvents}: `onAppend` fires after
package/dist/index.js CHANGED
@@ -316,6 +316,7 @@ var silentLogger = {
316
316
  var RECONNECT_OPTIONS = {
317
317
  reconnect: true,
318
318
  maxReconnectAttempts: -1,
319
+ ignoreAuthErrorAbort: true,
319
320
  reconnectTimeWait: 2e3,
320
321
  reconnectJitter: 1e3,
321
322
  reconnectJitterTLS: 1e3,
@@ -380,6 +381,165 @@ function logConnectionStatus(nc, label, logger = silentLogger, onStatus) {
380
381
  function isWarnNatsStatus(type) {
381
382
  return type === "disconnect" || type === "error" || type === "staleConnection";
382
383
  }
384
+ function subscriptionFailureAttributes(error) {
385
+ if (!error || typeof error !== "object") return { errorType: typeof error };
386
+ try {
387
+ const candidate = error;
388
+ return {
389
+ errorName: typeof candidate.name === "string" ? candidate.name.slice(0, 64) : "unknown",
390
+ ...typeof candidate.code === "string" ? { errorCode: candidate.code.slice(0, 64) } : {}
391
+ };
392
+ } catch {
393
+ return { errorName: "unknown" };
394
+ }
395
+ }
396
+ var NATS_SUBSCRIPTION_KINDS = [
397
+ "session_events",
398
+ "workspace_control",
399
+ "request_responder",
400
+ "agent_events",
401
+ "auth_callout"
402
+ ];
403
+ var NATS_SUBSCRIPTION_RECOVERIES = ["consumer_reconnect", "resubscribe", "none"];
404
+ var NATS_SUBSCRIPTION_TERMINATIONS_METRIC = "opengeni_nats_subscription_terminations_total";
405
+ function natsSubscriptionTerminationCounter(sink) {
406
+ return ({ kind, recovery }) => {
407
+ sink.incrementCounter({
408
+ name: NATS_SUBSCRIPTION_TERMINATIONS_METRIC,
409
+ help: "NATS subscriptions that ended without being unsubscribed, by subscription kind and recovery path.",
410
+ labels: { kind, recovery }
411
+ });
412
+ };
413
+ }
414
+ function natsResubscribeDelayMs(attempt, random = Math.random) {
415
+ const exponent = Number.isFinite(attempt) ? Math.min(Math.max(0, Math.trunc(attempt) - 1), 5) : 0;
416
+ const sample = random();
417
+ const unit = Number.isFinite(sample) ? Math.min(Math.max(sample, 0), 1 - Number.EPSILON) : 0;
418
+ return Math.min(3e4, 1e3 * 2 ** exponent) + Math.floor(unit * 1e3);
419
+ }
420
+ function pumpSubscription(subscription, context, onMessage, onEnded) {
421
+ const warn = context.logger?.warn ?? silentLogger.warn;
422
+ void (async () => {
423
+ let end = {};
424
+ try {
425
+ for await (const message of subscription) {
426
+ try {
427
+ await onMessage(message);
428
+ } catch (error) {
429
+ try {
430
+ warn("NATS subscription message dropped", {
431
+ label: context.label,
432
+ subject: context.subject,
433
+ ...subscriptionFailureAttributes(error)
434
+ });
435
+ } catch {
436
+ }
437
+ }
438
+ }
439
+ } catch (error) {
440
+ end = { error };
441
+ }
442
+ try {
443
+ onEnded(end);
444
+ } catch {
445
+ }
446
+ })();
447
+ }
448
+ function reportSubscriptionTermination(context, recovery, end) {
449
+ try {
450
+ (context.logger?.warn ?? silentLogger.warn)(
451
+ "error" in end && end.error !== void 0 ? "NATS subscription ended with an error" : "NATS subscription ended unexpectedly",
452
+ {
453
+ label: context.label,
454
+ subject: context.subject,
455
+ recovery,
456
+ ..."error" in end && end.error !== void 0 ? subscriptionFailureAttributes(end.error) : {}
457
+ }
458
+ );
459
+ } catch {
460
+ }
461
+ try {
462
+ context.onSubscriptionTerminated?.({ kind: context.kind, recovery });
463
+ } catch {
464
+ }
465
+ }
466
+ function connectionShuttingDown(nc) {
467
+ return nc.isClosed() || nc.isDraining();
468
+ }
469
+ function subscribeWithTermination(nc, context, onMessage, options) {
470
+ const sub = nc.subscribe(context.subject);
471
+ let released = false;
472
+ const onTerminated = options?.onTerminated;
473
+ pumpSubscription(sub, context, onMessage, (end) => {
474
+ if (released || connectionShuttingDown(nc)) return;
475
+ reportSubscriptionTermination(context, onTerminated ? "consumer_reconnect" : "none", end);
476
+ try {
477
+ onTerminated?.("error" in end ? end.error : void 0);
478
+ } catch {
479
+ }
480
+ });
481
+ return () => {
482
+ released = true;
483
+ sub.unsubscribe();
484
+ };
485
+ }
486
+ function subscribeWithResubscribe(nc, context, onMessage, resubscribeDelayMs = natsResubscribeDelayMs) {
487
+ let stopped = false;
488
+ let current = null;
489
+ let timer = null;
490
+ let consecutiveFailures = 0;
491
+ const pump = (sub) => {
492
+ current = sub;
493
+ pumpSubscription(
494
+ sub,
495
+ context,
496
+ async (message) => {
497
+ consecutiveFailures = 0;
498
+ await onMessage(message);
499
+ },
500
+ (end) => {
501
+ if (current === sub) current = null;
502
+ if (stopped || connectionShuttingDown(nc)) return;
503
+ reportSubscriptionTermination(context, "resubscribe", end);
504
+ schedule();
505
+ }
506
+ );
507
+ };
508
+ const schedule = () => {
509
+ if (stopped || connectionShuttingDown(nc)) return;
510
+ consecutiveFailures += 1;
511
+ let delayMs = 3e4;
512
+ try {
513
+ delayMs = resubscribeDelayMs(consecutiveFailures);
514
+ } catch {
515
+ }
516
+ timer = setTimeout(() => {
517
+ timer = null;
518
+ if (stopped || connectionShuttingDown(nc)) return;
519
+ let sub;
520
+ try {
521
+ sub = nc.subscribe(context.subject);
522
+ } catch (error) {
523
+ reportSubscriptionTermination(context, "resubscribe", { error });
524
+ schedule();
525
+ return;
526
+ }
527
+ pump(sub);
528
+ }, delayMs);
529
+ timer.unref?.();
530
+ };
531
+ pump(nc.subscribe(context.subject));
532
+ return () => {
533
+ stopped = true;
534
+ if (timer) {
535
+ clearTimeout(timer);
536
+ timer = null;
537
+ }
538
+ const sub = current;
539
+ current = null;
540
+ sub?.unsubscribe();
541
+ };
542
+ }
383
543
  var SESSION_EVENT_DURABLE_FANOUT_CAPABILITY_VERSION = 1;
384
544
  function requireSessionEventDurableFanoutCapability(bus) {
385
545
  const capability = bus?.sessionEventDurableFanout;
@@ -484,7 +644,7 @@ async function createNatsEventBus(natsUrl, auth, options = {}) {
484
644
  publishSessionEvents(workspaceId, sessionId, events);
485
645
  await flushConfirmedWithTimeout(nc, PUBLISH_FLUSH_TIMEOUT_MS);
486
646
  },
487
- subscribe: async (workspaceId, sessionId, onEvents) => subscribeSession(nc, workspaceId, sessionId, onEvents),
647
+ subscribe: async (workspaceId, sessionId, onEvents, subscriptionOptions) => subscribeSession(nc, workspaceId, sessionId, onEvents, options, subscriptionOptions),
488
648
  publishWorkspaceControl: async (workspaceId, event) => {
489
649
  try {
490
650
  const encoded = workspaceControlEventNatsPayload(event);
@@ -502,22 +662,27 @@ async function createNatsEventBus(natsUrl, auth, options = {}) {
502
662
  }
503
663
  await flushWithTimeout(nc, PUBLISH_FLUSH_TIMEOUT_MS);
504
664
  },
505
- subscribeWorkspaceControl: async (workspaceId, onEvent) => {
506
- const sub = nc.subscribe(workspaceControlSubject(workspaceId));
507
- void (async () => {
508
- for await (const msg of sub) {
509
- await onEvent(
510
- boundWorkspaceControlEvent(codec.decode(msg.data), {
511
- surface: "nats_legacy_guard"
512
- })
513
- );
514
- }
515
- })();
516
- return () => sub.unsubscribe();
517
- },
665
+ subscribeWorkspaceControl: async (workspaceId, onEvent, subscriptionOptions) => subscribeWithTermination(
666
+ nc,
667
+ {
668
+ label: "workspace-control",
669
+ kind: "workspace_control",
670
+ subject: workspaceControlSubject(workspaceId),
671
+ logger: options.logger,
672
+ onSubscriptionTerminated: options.onSubscriptionTerminated
673
+ },
674
+ async (msg) => {
675
+ await onEvent(
676
+ boundWorkspaceControlEvent(codec.decode(msg.data), {
677
+ surface: "nats_legacy_guard"
678
+ })
679
+ );
680
+ },
681
+ subscriptionOptions
682
+ ),
518
683
  request: async (subject, payload, opts) => requestReply(nc, subject, payload, opts.timeoutMs),
519
- subscribeRequests: (subject, handler) => subscribeRequests(nc, subject, handler),
520
- subscribeAgentEvents: (subject, handler) => subscribeAgentEvents(nc, subject, handler),
684
+ subscribeRequests: (subject, handler) => subscribeRequests(nc, subject, handler, options),
685
+ subscribeAgentEvents: (subject, handler) => subscribeAgentEvents(nc, subject, handler, options),
521
686
  getRequestConnection: () => requestConnection,
522
687
  getOpStreamConnection: () => opStreamConnection,
523
688
  isConnected: () => connected && !nc.isClosed() && !nc.isDraining(),
@@ -544,22 +709,30 @@ async function createResponderConnection(natsUrl, auth, subject, handler, option
544
709
  options.name ? `auth-callout:${options.name}` : "auth-callout",
545
710
  options.logger
546
711
  );
547
- const sub = nc.subscribe(subject);
548
- void (async () => {
549
- for await (const msg of sub) {
712
+ const unsubscribe = subscribeWithResubscribe(
713
+ nc,
714
+ {
715
+ label: options.name ? `auth-callout:${options.name}` : "auth-callout",
716
+ kind: "auth_callout",
717
+ subject,
718
+ logger: options.logger,
719
+ onSubscriptionTerminated: options.onSubscriptionTerminated
720
+ },
721
+ async (msg) => {
550
722
  if (!msg.reply) {
551
- continue;
723
+ return;
552
724
  }
553
725
  try {
554
726
  const reply = await handler(msg.data, msg.subject);
555
727
  msg.respond(reply);
556
728
  } catch {
557
729
  }
558
- }
559
- })();
730
+ },
731
+ options.resubscribeDelayMs
732
+ );
560
733
  return {
561
734
  close: async () => {
562
- sub.unsubscribe();
735
+ unsubscribe();
563
736
  await nc.drain();
564
737
  }
565
738
  };
@@ -647,56 +820,71 @@ async function appendAndPublishTurnEventsFenced(db, bus, workspaceId, sessionId,
647
820
  observeSince(observe?.onPublish, publishStartedAt, result.events.length);
648
821
  return result;
649
822
  }
650
- function subscribeSession(nc, workspaceId, sessionId, onEvents) {
651
- const sub = nc.subscribe(sessionSubject(workspaceId, sessionId));
652
- void (async () => {
653
- for await (const msg of sub) {
823
+ function subscribeSession(nc, workspaceId, sessionId, onEvents, busOptions, subscriptionOptions) {
824
+ return subscribeWithTermination(
825
+ nc,
826
+ {
827
+ label: "session-events",
828
+ kind: "session_events",
829
+ subject: sessionSubject(workspaceId, sessionId),
830
+ logger: busOptions.logger,
831
+ onSubscriptionTerminated: busOptions.onSubscriptionTerminated
832
+ },
833
+ async (msg) => {
654
834
  const decoded = codec.decode(msg.data);
655
835
  const events = ("events" in decoded ? decoded.events : [decoded]).map(
656
836
  (event) => boundSessionEventForSurface(event, "nats_legacy_guard")
657
837
  );
658
838
  await onEvents(events);
659
- }
660
- })();
661
- return () => {
662
- sub.unsubscribe();
663
- };
839
+ },
840
+ subscriptionOptions
841
+ );
664
842
  }
665
843
  async function requestReply(nc, subject, payload, timeout) {
666
844
  const msg = await nc.request(subject, payload, { timeout });
667
845
  return { data: msg.data };
668
846
  }
669
- function subscribeRequests(nc, subject, handler) {
670
- const sub = nc.subscribe(subject);
671
- void (async () => {
672
- for await (const msg of sub) {
847
+ function subscribeRequests(nc, subject, handler, busOptions) {
848
+ return subscribeWithResubscribe(
849
+ nc,
850
+ {
851
+ label: "request-responder",
852
+ kind: "request_responder",
853
+ subject,
854
+ logger: busOptions.logger,
855
+ onSubscriptionTerminated: busOptions.onSubscriptionTerminated
856
+ },
857
+ async (msg) => {
673
858
  if (!msg.reply) {
674
- continue;
859
+ return;
675
860
  }
676
861
  try {
677
862
  const reply = await handler(msg.data, msg.subject);
678
863
  msg.respond(reply);
679
864
  } catch {
680
865
  }
681
- }
682
- })();
683
- return () => {
684
- sub.unsubscribe();
685
- };
866
+ },
867
+ busOptions.resubscribeDelayMs
868
+ );
686
869
  }
687
- function subscribeAgentEvents(nc, subject, handler) {
688
- const sub = nc.subscribe(subject);
689
- void (async () => {
690
- for await (const msg of sub) {
870
+ function subscribeAgentEvents(nc, subject, handler, busOptions) {
871
+ return subscribeWithResubscribe(
872
+ nc,
873
+ {
874
+ label: "agent-events",
875
+ kind: "agent_events",
876
+ subject,
877
+ logger: busOptions.logger,
878
+ onSubscriptionTerminated: busOptions.onSubscriptionTerminated
879
+ },
880
+ async (msg) => {
691
881
  try {
692
882
  await handler(msg.data, msg.subject);
693
883
  } catch {
694
884
  }
695
- }
696
- })();
697
- return () => {
698
- sub.unsubscribe();
699
- };
885
+ },
886
+ busOptions.resubscribeDelayMs
887
+ );
700
888
  }
701
889
  function formatSse(event, idSequence = event.sequence) {
702
890
  const trustedId = Number.isSafeInteger(idSequence) && idSequence >= event.sequence ? idSequence : event.sequence;
@@ -853,6 +1041,9 @@ function workspaceControlSubject(workspaceId) {
853
1041
  return `workspaces.${workspaceId}.control`;
854
1042
  }
855
1043
  export {
1044
+ NATS_SUBSCRIPTION_KINDS,
1045
+ NATS_SUBSCRIPTION_RECOVERIES,
1046
+ NATS_SUBSCRIPTION_TERMINATIONS_METRIC,
856
1047
  SESSION_EVENT_COALESCED_TEXT_TARGET_BYTES,
857
1048
  SESSION_EVENT_DURABLE_FANOUT_CAPABILITY_VERSION,
858
1049
  SESSION_EVENT_HTTP_PAGE_MAX_BYTES,
@@ -875,6 +1066,8 @@ export {
875
1066
  formatWorkspaceControlEventSse,
876
1067
  mintAuthResponse,
877
1068
  mintUserJwt,
1069
+ natsResubscribeDelayMs,
1070
+ natsSubscriptionTerminationCounter,
878
1071
  nkeys2 as nkeys,
879
1072
  observeSince,
880
1073
  parseAgentConnectionName,