@spfn/core 0.3.0-beta.32 → 0.3.0-beta.33

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.
@@ -2,9 +2,9 @@ import { TSchema, Static } from '@sinclair/typebox';
2
2
  import { a as EventDef } from '../router-CX5uVjmU.js';
3
3
  export { b as EventContract, c as EventHandler, d as EventRouterContract, E as EventRouterDef, e as EventSubscribeOptions, F as FrameContext, f as FramePredicate, I as InferEventNames, g as InferEventPayload, h as InferEventPayloads, i as InferRouterEventPayload, j as InitialProducer, J as JobQueueSender, P as PubSubCache, S as SubscribeContext, k as SubscribePredicate, l as SubscribeRequest, m as SubscriptionMatch, n as defineEventRouter } from '../router-CX5uVjmU.js';
4
4
  export { defineWSRouter } from './ws/index.js';
5
- import '../types-DncqXfWN.js';
5
+ import '../types-DH6d2eOb.js';
6
6
  import 'hono';
7
- import '../token-manager-Ckjt2-qq.js';
7
+ import '../token-manager-CvOsAbnK.js';
8
8
  import 'node:http';
9
9
 
10
10
  /**
@@ -1,6 +1,23 @@
1
1
  import { logger } from '@spfn/core/logger';
2
2
 
3
3
  // src/event/event.ts
4
+ var keyCloseLogger = logger.child("@spfn/core:sse");
5
+ var REGISTRIES_KEY = Symbol.for("@spfn/core:sse-key-close-registries");
6
+ var registries = globalThis[REGISTRIES_KEY] ??= /* @__PURE__ */ new Set();
7
+ function applyKeyClose(keyId) {
8
+ let closed = 0;
9
+ registries.forEach((registry) => {
10
+ closed += registry.closeByKey(keyId);
11
+ });
12
+ if (closed > 0) {
13
+ keyCloseLogger.info("SSE connections closed", { count: closed, reason: "key revoked" });
14
+ } else {
15
+ keyCloseLogger.debug("SSE key close reached no open connection", { reason: "key revoked" });
16
+ }
17
+ return closed;
18
+ }
19
+
20
+ // src/event/cache-transport.ts
4
21
  var transportLogger = logger.child("@spfn/core:event-transport");
5
22
  var WARN_THROTTLE_MS = 3e4;
6
23
  var warnThrottle = /* @__PURE__ */ new Map();
@@ -19,7 +36,8 @@ globalThis[STATE_KEY] ??= {
19
36
  pubSubCache: void 0,
20
37
  subscriber: void 0,
21
38
  channelPrefix: void 0,
22
- wired: /* @__PURE__ */ new Map()
39
+ wired: /* @__PURE__ */ new Map(),
40
+ keyCloseDeferrals: /* @__PURE__ */ new Map()
23
41
  };
24
42
  var CONTROL_CHANNEL = "$control";
25
43
  var controlBindings = /* @__PURE__ */ new WeakMap();
@@ -36,6 +54,13 @@ function ensureControlBinding(cache) {
36
54
  return binding;
37
55
  }
38
56
  function isControlMessage(message) {
57
+ return isSubscriptionControlMessage(message) || isKeyCloseMessage(message);
58
+ }
59
+ function isKeyCloseMessage(message) {
60
+ const candidate = message;
61
+ return !!candidate && candidate.kind === "close" && typeof candidate.keyId === "string" && candidate.keyId !== "" && typeof candidate.origin === "string";
62
+ }
63
+ function isSubscriptionControlMessage(message) {
39
64
  const candidate = message;
40
65
  return !!candidate && (candidate.kind === "grant" || candidate.kind === "revoke") && typeof candidate.event === "string" && typeof candidate.subject === "string" && typeof candidate.origin === "string" && (candidate.match === void 0 || isSubscriptionMatch(candidate.match));
41
66
  }
@@ -51,6 +76,10 @@ function dispatchControl(binding, message) {
51
76
  if (message.origin === binding.origin) {
52
77
  return;
53
78
  }
79
+ if (message.kind === "close") {
80
+ applyKeyClose(message.keyId);
81
+ return;
82
+ }
54
83
  const appliers = binding.events.get(message.event);
55
84
  if (!appliers) {
56
85
  transportLogger.debug("Subscription control for an event this pod does not serve ignored", {
@@ -81,10 +110,9 @@ async function publishSubscriptionControl(cache, kind, eventName, subject, match
81
110
  ...match ? { match } : {},
82
111
  origin: ensureControlBinding(cache).origin
83
112
  };
84
- await cache.publish(CONTROL_CHANNEL, message).catch((err) => {
113
+ await cache.publish(CONTROL_CHANNEL, message).catch(() => {
85
114
  throttledWarn("control-publish-failed", "Subscription control publish failed \u2014 applied on this pod only", {
86
- event: eventName,
87
- error: err instanceof Error ? err.message : String(err)
115
+ event: eventName
88
116
  });
89
117
  });
90
118
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/event/cache-transport.ts","../../src/event/event.ts","../../src/event/sse/route-map.ts","../../src/event/router.ts","../../src/event/ws/handler.ts","../../src/event/ws/index.ts"],"names":["logger"],"mappings":";;;AAiBA,IAAM,eAAA,GAAkB,MAAA,CAAO,KAAA,CAAM,4BAA4B,CAAA;AAejE,IAAM,gBAAA,GAAmB,GAAA;AAuCzB,IAAM,YAAA,uBAAmB,GAAA,EAAkD;AAO3E,SAAS,aAAA,CAAc,GAAA,EAAa,OAAA,EAAiB,MAAA,EACrD;AACI,EAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,EAAA,MAAM,KAAA,GAAQ,YAAA,CAAa,GAAA,CAAI,GAAG,CAAA;AAElC,EAAA,IAAI,KAAA,IAAS,GAAA,GAAM,KAAA,CAAM,IAAA,GAAO,gBAAA,EAChC;AACI,IAAA,KAAA,CAAM,UAAA,EAAA;AAEN,IAAA;AAAA,EACJ;AAEA,EAAA,eAAA,CAAgB,IAAA,CAAK,OAAA,EAAS,KAAA,IAAS,KAAA,CAAM,UAAA,GAAa,CAAA,GACpD,EAAE,GAAG,MAAA,EAAQ,mBAAA,EAAqB,KAAA,CAAM,UAAA,KACxC,MAAM,CAAA;AACZ,EAAA,YAAA,CAAa,IAAI,GAAA,EAAK,EAAE,MAAM,GAAA,EAAK,UAAA,EAAY,GAAG,CAAA;AACtD;AAwCA,IAAM,SAAA,GAAY,MAAA,CAAO,GAAA,CAAI,4BAA4B,CAAA;AAEzB,UAAA,CAAmB,SAAS,CAAA,KAAM;AAAA,EAC9D,WAAA,EAAa,MAAA;AAAA,EACb,UAAA,EAAY,MAAA;AAAA,EACZ,aAAA,EAAe,MAAA;AAAA,EACf,KAAA,sBAAW,GAAA;AACf;AAmPA,IAAM,eAAA,GAAkB,UAAA;AAkCxB,IAAM,eAAA,uBAAsB,OAAA,EAAqC;AAEjE,SAAS,qBAAqB,KAAA,EAC9B;AACI,EAAA,IAAI,OAAA,GAAU,eAAA,CAAgB,GAAA,CAAI,KAAK,CAAA;AACvC,EAAA,IAAI,CAAC,OAAA,EACL;AACI,IAAA,OAAA,GAAU;AAAA,MACN,QAAQ,CAAA,EAAG,IAAA,CAAK,GAAA,EAAI,CAAE,SAAS,EAAE,CAAC,CAAA,CAAA,EAAI,IAAA,CAAK,QAAO,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,KAAA,CAAM,CAAC,CAAC,CAAA,CAAA;AAAA,MACzE,MAAA,sBAAY,GAAA,EAAI;AAAA,MAChB,WAAA,EAAa;AAAA,KACjB;AACA,IAAA,eAAA,CAAgB,GAAA,CAAI,OAAO,OAAO,CAAA;AAAA,EACtC;AAEA,EAAA,OAAO,OAAA;AACX;AAEA,SAAS,iBAAiB,OAAA,EAC1B;AACI,EAAA,MAAM,SAAA,GAAY,OAAA;AAElB,EAAA,OAAO,CAAC,CAAC,SAAA,KACD,SAAA,CAAU,IAAA,KAAS,OAAA,IAAW,SAAA,CAAU,IAAA,KAAS,QAAA,CAAA,IAClD,OAAO,SAAA,CAAU,KAAA,KAAU,QAAA,IAC3B,OAAO,SAAA,CAAU,OAAA,KAAY,QAAA,IAC7B,OAAO,SAAA,CAAU,MAAA,KAAW,QAAA,KAC3B,SAAA,CAAU,KAAA,KAAU,MAAA,IAAa,mBAAA,CAAoB,SAAA,CAAU,KAAK,CAAA,CAAA;AAChF;AAOO,SAAS,oBAAoB,KAAA,EACpC;AACI,EAAA,MAAM,QAAS,KAAA,EAA6C,KAAA;AAE5D,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,IACjB,OAAO,KAAA,KAAU,QAAA,IACjB,KAAA,KAAU,IAAA,IACV,CAAC,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,IACpB,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA,CAAE,MAAA,GAAS,CAAA,IAC5B,MAAA,CAAO,MAAA,CAAO,KAAK,CAAA,CAAE,KAAA,CAAM,CAAA,KAAA,KAAS,OAAO,KAAA,KAAU,QAAQ,CAAA;AACxE;AAEA,SAAS,eAAA,CAAgB,SAAyB,OAAA,EAClD;AACI,EAAA,IAAI,CAAC,gBAAA,CAAiB,OAAO,CAAA,EAC7B;AACI,IAAA,eAAA,CAAgB,MAAM,gDAAgD,CAAA;AAEtE,IAAA;AAAA,EACJ;AAEA,EAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,OAAA,CAAQ,MAAA,EAC/B;AACI,IAAA;AAAA,EACJ;AAEA,EAAA,MAAM,QAAA,GAAW,OAAA,CAAQ,MAAA,CAAO,GAAA,CAAI,QAAQ,KAAK,CAAA;AACjD,EAAA,IAAI,CAAC,QAAA,EACL;AACI,IAAA,eAAA,CAAgB,MAAM,mEAAA,EAAqE;AAAA,MACvF,OAAO,OAAA,CAAQ;AAAA,KAClB,CAAA;AAED,IAAA;AAAA,EACJ;AAEA,EAAA,QAAA,CAAS,OAAA,CAAQ,WAAS,KAAA,CAAM,OAAA,CAAQ,MAAM,OAAA,CAAQ,OAAA,EAAS,OAAA,CAAQ,KAAK,CAAC,CAAA;AACjF;AASA,eAAsB,uBAAA,CAClB,KAAA,EACA,SAAA,EACA,KAAA,EAEJ;AACI,EAAA,MAAM,OAAA,GAAU,qBAAqB,KAAK,CAAA;AAC1C,EAAA,MAAM,WAAW,OAAA,CAAQ,MAAA,CAAO,IAAI,SAAS,CAAA,wBAAS,GAAA,EAA8B;AACpF,EAAA,QAAA,CAAS,IAAI,KAAK,CAAA;AAClB,EAAA,OAAA,CAAQ,MAAA,CAAO,GAAA,CAAI,SAAA,EAAW,QAAQ,CAAA;AAEtC,EAAA,OAAA,CAAQ,WAAA,KAAgB,KAAA,CACnB,SAAA,CAAU,eAAA,EAAiB,CAAC,OAAA,KAAY,eAAA,CAAgB,OAAA,EAAS,OAAO,CAAC,CAAA,CACzE,KAAA,CAAM,CAAC,GAAA,KACR;AACI,IAAA,OAAA,CAAQ,WAAA,GAAc,MAAA;AACtB,IAAA,aAAA,CAAc,4BAA4B,wFAAA,EAAqF;AAAA,MAC3H,OAAO,GAAA,YAAe,KAAA,GAAQ,GAAA,CAAI,OAAA,GAAU,OAAO,GAAG;AAAA,KACzD,CAAA;AAAA,EACL,CAAC,CAAA;AAEL,EAAA,MAAM,OAAA,CAAQ,WAAA;AAClB;AAOA,eAAsB,0BAAA,CAClB,KAAA,EACA,IAAA,EACA,SAAA,EACA,SACA,KAAA,EAEJ;AACI,EAAA,MAAM,OAAA,GAAsC;AAAA,IACxC,IAAA;AAAA,IACA,KAAA,EAAO,SAAA;AAAA,IACP,OAAA;AAAA,IACA,GAAI,KAAA,GAAQ,EAAE,KAAA,KAAU,EAAC;AAAA,IACzB,MAAA,EAAQ,oBAAA,CAAqB,KAAK,CAAA,CAAE;AAAA,GACxC;AAEA,EAAA,MAAM,MAAM,OAAA,CAAQ,eAAA,EAAiB,OAAO,CAAA,CAAE,KAAA,CAAM,CAAC,GAAA,KACrD;AACI,IAAA,aAAA,CAAc,0BAA0B,qEAAA,EAAkE;AAAA,MACtG,KAAA,EAAO,SAAA;AAAA,MACP,OAAO,GAAA,YAAe,KAAA,GAAQ,GAAA,CAAI,OAAA,GAAU,OAAO,GAAG;AAAA,KACzD,CAAA;AAAA,EACL,CAAC,CAAA;AACL;;;AC3fA,IAAM,WAAA,GAAcA,MAAAA,CAAO,KAAA,CAAM,kBAAkB,CAAA;AAKnD,SAAS,eAAA,CAAgB,WAAmB,KAAA,EAC5C;AACI,EAAA,WAAA,CAAY,KAAA,CAAM,CAAA,qBAAA,EAAwB,SAAS,CAAA,CAAA,EAAI;AAAA,IACnD,OAAO,KAAA,YAAiB,KAAA,GAAQ,KAAA,CAAM,OAAA,GAAU,OAAO,KAAK;AAAA,GAC/D,CAAA;AACL;AAKA,SAAS,gBAAA,CAAiB,WAAmB,KAAA,EAC7C;AACI,EAAA,WAAA,CAAY,KAAA,CAAM,CAAA,mCAAA,EAAsC,SAAS,CAAA,CAAA,EAAI;AAAA,IACjE,OAAO,KAAA,YAAiB,KAAA,GAAQ,KAAA,CAAM,OAAA,GAAU,OAAO,KAAK;AAAA,GAC/D,CAAA;AACL;AAKA,SAAS,qBAA+B,IAAA,EACxC;AACI,EAAA,MAAM,QAAA,uBAA4C,GAAA,EAAI;AAEtD,EAAA,OAAO;AAAA,IACH,GAAA,EAAK,CAAC,OAAA,KACN;AACI,MAAA,QAAA,CAAS,IAAI,OAAO,CAAA;AACpB,MAAA,WAAA,CAAY,KAAA,CAAM,wBAAwB,IAAI,CAAA,CAAA,EAAI,EAAE,YAAA,EAAc,QAAA,CAAS,MAAM,CAAA;AAEjF,MAAA,OAAO,MACP;AACI,QAAA,QAAA,CAAS,OAAO,OAAO,CAAA;AACvB,QAAA,WAAA,CAAY,KAAA,CAAM,4BAA4B,IAAI,CAAA,CAAA,EAAI,EAAE,YAAA,EAAc,QAAA,CAAS,MAAM,CAAA;AAAA,MACzF,CAAA;AAAA,IACJ,CAAA;AAAA,IAEA,OAAO,MACP;AACI,MAAA,QAAA,CAAS,KAAA,EAAM;AACf,MAAA,WAAA,CAAY,KAAA,CAAM,CAAA,6BAAA,EAAgC,IAAI,CAAA,CAAE,CAAA;AAAA,IAC5D,CAAA;AAAA,IAEA,OAAA,EAAS,OAAO,OAAA,KAChB;AACI,MAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,UAAA;AAAA,QAC1B,CAAC,GAAG,QAAQ,CAAA,CAAE,IAAI,CAAC,OAAA,KAAY,OAAA,CAAQ,OAAO,CAAC;AAAA,OACnD;AAEA,MAAA,KAAA,MAAW,UAAU,OAAA,EACrB;AACI,QAAA,IAAI,MAAA,CAAO,WAAW,UAAA,EACtB;AACI,UAAA,eAAA,CAAgB,IAAA,EAAM,OAAO,MAAM,CAAA;AAAA,QACvC;AAAA,MACJ;AAAA,IACJ;AAAA,GACJ;AACJ;AAKA,SAAS,sBAAsB,IAAA,EAC/B;AACI,EAAA,MAAM,SAAA,uBAA6C,GAAA,EAAI;AAEvD,EAAA,OAAO;AAAA,IACH,QAAA,EAAU,CAAC,SAAA,EAAmB,MAAA,KAC9B;AACI,MAAA,SAAA,CAAU,GAAA,CAAI,WAAW,MAAM,CAAA;AAC/B,MAAA,WAAA,CAAY,MAAM,CAAA,gCAAA,EAAmC,IAAI,CAAA,CAAA,EAAI,EAAE,WAAW,CAAA;AAAA,IAC9E,CAAA;AAAA,IAEA,IAAA,EAAM,OAAO,OAAA,KACb;AACI,MAAA,IAAI,SAAA,CAAU,SAAS,CAAA,EACvB;AACI,QAAA;AAAA,MACJ;AAEA,MAAA,MAAM,OAAA,GAAU,CAAC,GAAG,SAAA,CAAU,SAAS,CAAA;AACvC,MAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,UAAA;AAAA,QAC1B,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAC,SAAA,EAAW,MAAM,CAAA,KAAM,MAAA,CAAO,SAAA,EAAW,OAAO,CAAC;AAAA,OACnE;AAEA,MAAA,KAAA,MAAW,CAAC,CAAA,EAAG,MAAM,CAAA,IAAK,OAAA,CAAQ,SAAQ,EAC1C;AACI,QAAA,IAAI,MAAA,CAAO,WAAW,UAAA,EACtB;AACI,UAAA,gBAAA,CAAiB,QAAQ,CAAC,CAAA,CAAE,CAAC,CAAA,EAAG,OAAO,MAAM,CAAA;AAAA,QACjD;AAAA,MACJ;AAAA,IACJ,CAAA;AAAA,IAEA,IAAI,IAAA,GACJ;AACI,MAAA,OAAO,SAAA,CAAU,IAAA;AAAA,IACrB;AAAA,GACJ;AACJ;AASA,SAAS,0BAAA,CAA2B,MAAc,QAAA,EAClD;AACI,EAAA,MAAM,QAAA,uBAAyC,GAAA,EAAI;AAEnD,EAAA,MAAM,UAAA,GAAa,CAAC,IAAA,EAA0B,OAAA,EAAiB,KAAA,KAC/D;AACI,IAAA,QAAA,CAAS,QAAQ,CAAA,OAAA,KAAW,OAAA,CAAQ,IAAI,CAAA,CAAE,OAAA,EAAS,KAAK,CAAC,CAAA;AAAA,EAC7D,CAAA;AAEA,EAAA,MAAM,MAAA,GAAS,CAAC,IAAA,KAA6B,OAAO,SAAiB,KAAA,KACrE;AACI,IAAA,WAAA,CAAY,IAAA,EAAM,MAAM,KAAK,CAAA;AAC7B,IAAA,UAAA,CAAW,IAAA,EAAM,SAAS,KAAK,CAAA;AAE/B,IAAA,MAAM,QAAQ,QAAA,EAAS;AACvB,IAAA,IAAI,KAAA,EACJ;AACI,MAAA,MAAM,0BAAA,CAA2B,KAAA,EAAO,IAAA,EAAM,IAAA,EAAM,SAAS,KAAK,CAAA;AAAA,IACtE;AAAA,EACJ,CAAA;AAEA,EAAA,OAAO;AAAA,IACH,UAAA;AAAA,IACA,KAAA,EAAO,OAAO,OAAO,CAAA;AAAA,IACrB,MAAA,EAAQ,OAAO,QAAQ,CAAA;AAAA,IACvB,QAAA,EAAU,CAAC,OAAA,KACX;AACI,MAAA,QAAA,CAAS,IAAI,OAAO,CAAA;AAEpB,MAAA,OAAO,MAAM,QAAA,CAAS,MAAA,CAAO,OAAO,CAAA;AAAA,IACxC;AAAA,GACJ;AACJ;AAQA,SAAS,WAAA,CAAY,IAAA,EAAc,IAAA,EAA0B,KAAA,EAC7D;AACI,EAAA,IAAI,KAAA,KAAU,MAAA,IAAa,CAAC,mBAAA,CAAoB,KAAK,CAAA,EACrD;AACI,IAAA,MAAM,IAAI,KAAA;AAAA,MACN,CAAA,OAAA,EAAU,IAAI,CAAA,GAAA,EAAM,IAAI,CAAA,yOAAA;AAAA,KAG5B;AAAA,EACJ;AACJ;AAKA,SAAS,kBAAA,CAAmB,IAAA,EAAc,MAAA,EAAgB,QAAA,EAC1D;AACI,EAAA,IAAI,QAAA,EACJ;AACI,IAAA,MAAM,IAAI,KAAA;AAAA,MACN,CAAA,OAAA,EAAU,IAAI,CAAA,oBAAA,EAAuB,MAAM,+GAE7B,MAAM,CAAA,oCAAA;AAAA,KACxB;AAAA,EACJ;AACJ;AAKA,SAAS,eAAA,CACL,MACA,MAAA,EAEJ;AACI,EAAA,MAAM,cAAA,GAAiB,qBAA+B,IAAI,CAAA;AAE1D,EAAA,MAAM,mBAAA,GAAsB,qBAA+B,IAAI,CAAA;AAC/D,EAAA,MAAM,eAAA,GAAkB,sBAAsB,IAAI,CAAA;AAClD,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI,eAAA,GAAkB,KAAA;AACtB,EAAA,MAAM,oBAAA,GAAuB,0BAAA,CAA2B,IAAA,EAAM,MAAM,KAAK,CAAA;AAEzE,EAAA,MAAM,IAAA,GAAO,OAAO,OAAA,KACpB;AACI,IAAA,WAAA,CAAY,KAAA,CAAM,CAAA,gBAAA,EAAmB,IAAI,CAAA,CAAA,EAAI;AAAA,MACzC,OAAA;AAAA,MACA,QAAA,EAAU,CAAC,CAAC,KAAA;AAAA,MACZ,eAAe,eAAA,CAAgB;AAAA,KAClC,CAAA;AAED,IAAA,IAAI,KAAA,EACJ;AACI,MAAA,MAAM,KAAA,CAAM,OAAA,CAAQ,IAAA,EAAM,OAAO,CAAA;AAAA,IACrC,CAAA,MAEA;AACI,MAAA,MAAM,cAAA,CAAe,QAAQ,OAAmB,CAAA;AAAA,IACpD;AAEA,IAAA,MAAM,mBAAA,CAAoB,QAAQ,OAAmB,CAAA;AAErD,IAAA,MAAM,eAAA,CAAgB,KAAK,OAAO,CAAA;AAClC,IAAA,WAAA,CAAY,KAAA,CAAM,CAAA,eAAA,EAAkB,IAAI,CAAA,CAAE,CAAA;AAAA,EAC9C,CAAA;AAEA,EAAA,MAAM,QAAA,GAAW,OAAO,QAAA,KACxB;AACI,IAAA,IAAI,eAAA,EACJ;AACI,MAAA,WAAA,CAAY,IAAA,CAAK,CAAA,oCAAA,EAAuC,IAAI,CAAA,CAAE,CAAA;AAE9D,MAAA,OAAO,IAAA;AAAA,IACX;AASA,IAAA,MAAM,QAAA,CAAS,SAAA,CAAU,IAAA,EAAM,OAAO,OAAA,KACtC;AACI,MAAA,WAAA,CAAY,KAAA,CAAM,CAAA,2BAAA,EAA8B,IAAI,CAAA,CAAE,CAAA;AACtD,MAAA,MAAM,cAAA,CAAe,QAAQ,OAAmB,CAAA;AAAA,IACpD,CAAC,CAAA;AAED,IAAA,KAAA,GAAQ,QAAA;AACR,IAAA,eAAA,GAAkB,IAAA;AAElB,IAAA,MAAM,uBAAA,CAAwB,QAAA,EAAU,IAAA,EAAM,oBAAA,CAAqB,UAAU,CAAA;AAE7E,IAAA,WAAA,CAAY,KAAA,CAAM,CAAA,oCAAA,EAAuC,IAAI,CAAA,CAAE,CAAA;AAE/D,IAAA,OAAO,IAAA;AAAA,EACX,CAAA;AAMA,EAAA,MAAM,aAAa,MACnB;AACI,IAAA,KAAA,GAAQ,MAAA;AACR,IAAA,eAAA,GAAkB,KAAA;AAAA,EACtB,CAAA;AAEA,EAAA,IAAI,QAAA;AAEJ,EAAA,MAAM,eAAA,GAAkB,CAAC,IAAA,KACzB;AACI,IAAA,IAAI,QAAA,EACJ;AACI,MAAA,MAAM,IAAI,KAAA;AAAA,QACN,CAAA,OAAA,EAAU,IAAI,CAAA,uCAAA,EAA0C,QAAA,CAAS,KAAK,CAAA,gKAAA;AAAA,OAG1E;AAAA,IACJ;AAEA,IAAA,QAAA,GAAW,IAAA;AAEX,IAAA,OAAO,IAAA;AAAA,EACX,CAAA;AAEA,EAAA,IAAI,YAAA;AACJ,EAAA,IAAI,MAAA;AACJ,EAAA,IAAI,OAAA;AAEJ,EAAA,MAAM,mBAAA,GAAsB,CAAI,SAAA,KAChC;AACI,IAAA,kBAAA,CAAmB,IAAA,EAAM,cAAA,EAAgB,CAAC,CAAC,YAAY,CAAA;AACvD,IAAA,YAAA,GAAe,SAAA;AAEf,IAAA,OAAO,IAAA;AAAA,EACX,CAAA;AAEA,EAAA,MAAM,aAAA,GAAgB,CAAC,SAAA,KACvB;AACI,IAAA,kBAAA,CAAmB,IAAA,EAAM,QAAA,EAAU,CAAC,CAAC,MAAM,CAAA;AAC3C,IAAA,MAAA,GAAS,SAAA;AAET,IAAA,OAAO,IAAA;AAAA,EACX,CAAA;AAEA,EAAA,MAAM,cAAA,GAAiB,CAAI,QAAA,KAC3B;AACI,IAAA,kBAAA,CAAmB,IAAA,EAAM,SAAA,EAAW,CAAC,CAAC,OAAO,CAAA;AAC7C,IAAA,OAAA,GAAU,QAAA;AAEV,IAAA,OAAO,IAAA;AAAA,EACX,CAAA;AAEA,EAAA,MAAM,SAAA,GAAY,CAAC,OAAA,EAAiC,OAAA,KAAA,CAC/C,SAAS,SAAA,GAAY,mBAAA,GAAsB,cAAA,EAAgB,GAAA,CAAI,OAAO,CAAA;AAE3E,EAAA,MAAM,iBAAiB,MACvB;AACI,IAAA,cAAA,CAAe,KAAA,EAAM;AACrB,IAAA,mBAAA,CAAoB,KAAA,EAAM;AAAA,EAC9B,CAAA;AAEA,EAAA,MAAM,IAAA,GAA2B;AAAA,IAC7B,IAAA;AAAA,IACA,MAAA;AAAA,IACA,IAAI,SAAA,GACJ;AACI,MAAA,OAAO,QAAA;AAAA,IACX,CAAA;AAAA,IACA,QAAA,EAAU,eAAA;AAAA,IACV,IAAI,aAAA,GACJ;AACI,MAAA,OAAO,YAAA;AAAA,IACX,CAAA;AAAA,IACA,YAAA,EAAc,mBAAA;AAAA,IACd,IAAI,OAAA,GACJ;AACI,MAAA,OAAO,MAAA;AAAA,IACX,CAAA;AAAA,IACA,MAAA,EAAQ,aAAA;AAAA,IACR,IAAI,QAAA,GACJ;AACI,MAAA,OAAO,OAAA;AAAA,IACX,CAAA;AAAA,IACA,OAAA,EAAS,cAAA;AAAA,IACT,OAAO,oBAAA,CAAqB,KAAA;AAAA,IAC5B,QAAQ,oBAAA,CAAqB,MAAA;AAAA,IAC7B,8BAA8B,oBAAA,CAAqB,QAAA;AAAA,IACnD,SAAA;AAAA,IACA,cAAA;AAAA,IACA,IAAA;AAAA,IACA,QAAA;AAAA,IACA,mBAAmB,eAAA,CAAgB,QAAA;AAAA,IACnC,WAAA,EAAa,UAAA;AAAA,IACb,QAAA,EAAU;AAAA,GACd;AAEA,EAAA,OAAO,IAAA;AACX;AAyCO,SAAS,WAAA,CACZ,MACA,MAAA,EAEJ;AACI,EAAA,IAAI,MAAA,EACJ;AACI,IAAA,OAAO,eAAA,CAA2B,MAAM,MAAM,CAAA;AAAA,EAClD;AAEA,EAAA,OAAO,gBAAsB,IAAI,CAAA;AACrC;;;AC/aO,IAAM,aAAA,GAAgB;AAAA,EACzB,WAAA,EAAa,EAAE,MAAA,EAAQ,MAAA,EAAiB,MAAM,eAAA;AAClD;;;ACyHO,SAAS,kBAEd,MAAA,EACF;AACI,EAAA,IAAI,QAAA;AAEJ,EAAA,MAAM,MAAA,GAAkC;AAAA,IACpC,MAAA;AAAA,IACA,UAAA,EAAY,MAAA,CAAO,IAAA,CAAK,MAAM,CAAA;AAAA,IAC9B,IAAI,SAAA,GACJ;AACI,MAAA,OAAO,QAAA;AAAA,IACX,CAAA;AAAA,IACA,QAAA,EAAU,CAAC,IAAA,KACX;AACI,MAAA,gBAAA,CAAiB,KAAK,UAAU,CAAA;AAChC,MAAA,QAAA,GAAW,IAAA;AAEX,MAAA,OAAO,MAAA;AAAA,IACX,CAAA;AAAA,IACA,QAAQ;AAAC,GACb;AAEA,EAAA,OAAO,MAAA;AACX;AAMA,SAAS,iBAAiB,UAAA,EAC1B;AACI,EAAA,IAAI,eAAe,MAAA,IAAa,CAAC,UAAA,CAAW,UAAA,CAAW,GAAG,CAAA,EAC1D;AACI,IAAA,MAAM,IAAI,KAAA;AAAA,MACN,kDAAkD,UAAU,CAAA,0KAAA;AAAA,KAGhE;AAAA,EACJ;AACJ;AClKiBA,MAAAA,CAAO,KAAA,CAAM,eAAe;;;ACkEtC,SAAS,eAGd,GAAA,EAIF;AACI,EAAA,OAAO;AAAA,IACH,QAAQ,GAAA,CAAI,MAAA;AAAA,IACZ,UAAA,EAAY,MAAA,CAAO,IAAA,CAAK,GAAA,CAAI,MAAM,CAAA;AAAA,IAClC,QAAA,EAAW,GAAA,CAAI,QAAA,IAAY,EAAC;AAAA,IAC5B,QAAQ;AAAC,GACb;AACJ","file":"index.js","sourcesContent":["/**\n * Event cache transport (Redis/Valkey pub/sub)\n *\n * Wires each event in a router to a shared {@link PubSubCache} so that an\n * `emit` on one pod fans out to subscribers on every pod. This is the runtime\n * glue behind `EventDef.useCache` — applications don't call it directly; the\n * server's `.events()` / `.websockets()` registration calls it at startup when\n * a cache (`CACHE_URL`) is configured. Without a cache, events stay in-process\n * and this is a no-op.\n *\n * Mirrors the auto-detection pattern used by `CacheTokenStore` for SSE tokens.\n */\n\nimport { logger } from '@spfn/core/logger';\nimport type { EventRouterBase } from './router';\nimport type { PubSubCache, SubscriptionMatch } from './types';\n\nconst transportLogger = logger.child('@spfn/core:event-transport');\n\n/** Default channel prefix; isolates SPFN event channels from other Redis keys. */\nconst DEFAULT_CHANNEL_PREFIX = 'spfn:sse:';\n\n/** Cap on the subscriber `quit()` during shutdown so a wedged socket can't stall it. */\nconst SUBSCRIBER_QUIT_TIMEOUT_MS = 5000;\n\n/** Cap a Redis PUBLISH so a wedged socket (half-open, no FIN) can't hang `emit` forever. */\nconst PUBLISH_TIMEOUT_MS = 5000;\n\n/** Cap a Redis SUBSCRIBE so a wedged socket can't hang server startup; degrades to in-process. */\nconst SUBSCRIBE_TIMEOUT_MS = 5000;\n\n/** Fold repeated transport warnings during a sustained outage into one line per window. */\nconst WARN_THROTTLE_MS = 30000;\n\n/**\n * After a publish failure, skip Redis entirely (deliver locally) for this long before\n * probing again. Without it, a streaming `emit` loop pays the full PUBLISH_TIMEOUT_MS per\n * chunk during a sustained outage (50 chunks × 5s = minutes of request hang).\n */\nconst PUBLISH_BREAKER_COOLDOWN_MS = 10000;\n\n/**\n * Reject if `promise` doesn't settle within `ms`. ioredis has no command timeout by\n * default, so a half-open socket leaves publish/subscribe pending indefinitely; this\n * bounds it so the caller can fall back instead of hanging.\n */\nfunction withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>\n{\n return new Promise<T>((resolve, reject) =>\n {\n const timer = setTimeout(() =>\n {\n reject(new Error(`${label} timed out after ${ms}ms`));\n }, ms);\n timer.unref?.();\n\n promise.then(\n (value) =>\n {\n clearTimeout(timer);\n resolve(value);\n },\n (err) =>\n {\n clearTimeout(timer);\n reject(err);\n },\n );\n });\n}\n\nconst warnThrottle = new Map<string, { last: number; suppressed: number }>();\n\n/**\n * Warn at most once per {@link WARN_THROTTLE_MS} per key, folding the suppressed count\n * into the next line — a sustained Redis outage must not flood logs (one WARN per emit\n * × a fast stream = thousands/sec).\n */\nfunction throttledWarn(key: string, message: string, fields: Record<string, unknown>): void\n{\n const now = Date.now();\n const entry = warnThrottle.get(key);\n\n if (entry && now - entry.last < WARN_THROTTLE_MS)\n {\n entry.suppressed++;\n\n return;\n }\n\n transportLogger.warn(message, entry && entry.suppressed > 0\n ? { ...fields, suppressedSinceLast: entry.suppressed }\n : fields);\n warnThrottle.set(key, { last: now, suppressed: 0 });\n}\n\n/**\n * Minimal pub/sub client interface (compatible with ioredis Redis | Cluster).\n * A duplicated connection is required for SUBSCRIBE — ioredis puts a connection\n * into subscriber mode, where regular commands are rejected.\n */\ntype PubSubClient = {\n publish(channel: string, message: string): Promise<unknown>;\n duplicate(): PubSubClient;\n subscribe(...channels: string[]): Promise<unknown>;\n on(event: string, listener: (...args: any[]) => void): unknown;\n quit?(): Promise<unknown>;\n /** ioredis connection status ('ready' | 'connecting' | 'close' | ...). */\n status?: string;\n};\n\ntype MessageHandler = (message: unknown) => void | Promise<void>;\n\n/** The slice of EventDef this module wires (kept minimal + duck-typed). */\ntype WirableEvent = {\n name: string;\n useCache: (cache: PubSubCache) => Promise<unknown>;\n _resetCache?: () => void;\n};\n\n// ── globalThis singleton ──────────────────────────────────────────\n// Survives the CJS/ESM dual-package hazard (same pattern as cache-manager):\n// one subscriber connection and one wired-event registry per process. The\n// registry holds the EventDefs (not just names) so closeEventTransport can\n// reset their cache binding — otherwise the per-event cacheSubscribed latch\n// and this state would diverge across a restart in the same process.\ninterface TransportState\n{\n pubSubCache: PubSubCache | undefined;\n subscriber: PubSubClient | undefined;\n channelPrefix: string | undefined;\n wired: Map<string, WirableEvent>;\n}\n\nconst STATE_KEY = Symbol.for('@spfn/core:event-transport');\n\nconst state: TransportState = ((globalThis as any)[STATE_KEY] ??= {\n pubSubCache: undefined,\n subscriber: undefined,\n channelPrefix: undefined,\n wired: new Map<string, WirableEvent>(),\n});\n\n/**\n * Resolve the channel prefix: explicit option → env override → default.\n */\nfunction resolveChannelPrefix(override?: string): string\n{\n return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;\n}\n\n/**\n * Build a {@link PubSubCache} backed by a Redis/Valkey client.\n *\n * One duplicated connection is created lazily and shared across every channel.\n * Publish failures are logged and swallowed — SSE is lossy (at-most-once), so a\n * dropped event during a Redis blip must never crash `emit` or kill a stream.\n */\nexport function createRedisPubSubCache(\n client: PubSubClient,\n channelPrefix: string,\n onSubscriber?: (connection: PubSubClient) => void,\n): PubSubCache\n{\n let subscriber: PubSubClient | undefined;\n const handlers = new Map<string, MessageHandler>();\n // Circuit breaker: epoch-ms until which Redis is treated as down (skip publish).\n let degradedUntil = 0;\n\n const ensureSubscriber = (): PubSubClient =>\n {\n if (subscriber)\n {\n return subscriber;\n }\n\n // ioredis re-issues SUBSCRIBE after a reconnect (autoResubscribe, default\n // true). Don't pass it as a duplicate() option — Cluster.duplicate()'s\n // first positional arg is the startup-nodes array, so an options object\n // lands in the wrong slot and is silently dropped. The 'message' listener\n // is attached once and survives reconnects.\n subscriber = client.duplicate();\n onSubscriber?.(subscriber);\n\n subscriber.on('message', (channel: string, raw: string) =>\n {\n const handler = handlers.get(channel);\n if (!handler)\n {\n return;\n }\n\n let payload: unknown;\n try\n {\n payload = JSON.parse(raw);\n }\n catch\n {\n transportLogger.warn('Pub/sub message parse failed — dropped', { channel });\n\n return;\n }\n\n void handler(payload);\n });\n\n // ioredis auto-reconnects (and re-subscribes via autoResubscribe above);\n // surface the blip, throttled so a sustained outage's reconnect loop can't\n // flood logs.\n subscriber.on('error', (err: Error) =>\n {\n throttledWarn('subscriber-error', 'Pub/sub subscriber error', { error: err.message });\n });\n\n return subscriber;\n };\n\n // Deliver to this pod's own subscribers without the Redis round-trip — the\n // fallback when publish can't reach Redis. Mostly avoids double-delivery (a\n // failed publish usually means Redis never echoed back), but a reply-lost\n // race — Redis broadcast, then the publisher's reply dropped — can deliver\n // both here and via the echo. We accept that rare duplicate over dropping,\n // since SSE is lossy and a frozen stream is worse than a repeated chunk.\n // Same-pod subscribers are fed ONLY by the echo on the subscriber connection\n // (a separate socket from the write client). If it isn't connected, a successful\n // publish still won't reach this pod's own streams — so they need the local\n // fallback. A status-less subscriber (test mocks) is treated as healthy.\n const subscriberHealthy = (): boolean =>\n !!subscriber && (subscriber.status === undefined || subscriber.status === 'ready');\n\n const deliverLocal = (channel: string, message: unknown): void =>\n {\n const handler = handlers.get(channel);\n if (handler)\n {\n void handler(message);\n }\n };\n\n return {\n publish: async (name: string, message: unknown): Promise<void> =>\n {\n const channel = channelPrefix + name;\n\n // `string | undefined`: JSON.stringify returns undefined (no throw) for a\n // top-level function/symbol, and throws for bigint/circular. Both mean\n // \"can't cross Redis\" — handle each as a local-only delivery.\n let serialized: string | undefined;\n try\n {\n // `?? null` so void-payload events serialize to \"null\" rather than\n // `undefined` (JSON.stringify(undefined) → undefined → publish breaks).\n serialized = JSON.stringify(message ?? null);\n }\n catch (err)\n {\n // Non-serializable payload (bigint, circular ref). Cross-pod is\n // impossible; deliver the live object locally (best effort — a\n // downstream JSON.stringify may re-throw, but better than dropping).\n transportLogger.warn('Pub/sub payload serialization failed — local delivery only', {\n channel,\n error: err instanceof Error ? err.message : String(err),\n });\n deliverLocal(channel, message ?? null);\n\n return;\n }\n\n if (serialized === undefined)\n {\n // Top-level function/symbol payload — JSON.stringify yields undefined.\n // Publishing that is meaningless and re-parsing it later would throw,\n // so deliver locally and stop (guards the \"publish never kills emit\").\n transportLogger.warn('Pub/sub payload not serializable (function/symbol) — local delivery only', {\n channel,\n });\n deliverLocal(channel, message ?? null);\n\n return;\n }\n\n // Skip Redis when the breaker is open (recent failure) or the client isn't\n // connected. This (a) bounds a sustained outage to one timeout per cooldown\n // instead of one per emit, and (b) avoids ioredis's offline queue buffering\n // the publish and re-sending it on reconnect — which would deliver the event\n // twice (local fallback now + echo later). Treat a status-less client (test\n // mocks) as ready so behavior is unchanged there.\n const ready = client.status === undefined || client.status === 'ready';\n const now = Date.now();\n if (!ready || (degradedUntil > 0 && now < degradedUntil))\n {\n // Not connected, or breaker open → skip Redis, deliver locally.\n deliverLocal(channel, JSON.parse(serialized));\n\n return;\n }\n\n // Healthy (degradedUntil === 0) → publish directly; concurrent emits all\n // fan out. Half-open (breaker tripped but cooldown elapsed) → this emit is\n // the single probe: arm the breaker for the probe's duration so CONCURRENT\n // emits fast-path to local instead of each launching their own 5s probe.\n if (degradedUntil > 0)\n {\n degradedUntil = now + PUBLISH_TIMEOUT_MS;\n }\n\n try\n {\n // Timeout-bound: a wedged socket would otherwise leave this pending\n // forever and hang `emit` (and the request that called it).\n await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, 'pub/sub publish');\n degradedUntil = 0; // success → close the breaker\n\n // Publish reached Redis (remote pods get it via their echo), but if THIS\n // pod's subscriber socket is down its echo won't arrive — cover same-pod\n // subscribers locally. No double-delivery: Redis doesn't buffer for a\n // disconnected subscriber, so the missed echo never arrives later.\n if (!subscriberHealthy())\n {\n throttledWarn('subscriber-down', 'Subscriber connection down — same-pod local fallback', {\n channel,\n });\n deliverLocal(channel, JSON.parse(serialized));\n }\n }\n catch (err)\n {\n // Redis blip/timeout: open the breaker so subsequent emits fast-path to\n // local delivery instead of each paying the timeout. The cross-pod echo\n // won't arrive, so deliver to this pod's subscribers directly. Parse the\n // serialized copy so handlers see the SAME shape as the echo path (a JSON\n // round-trip), never the live mutable object. Throttled WARN — a sustained\n // outage emits one line per window, not one per chunk.\n degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;\n throttledWarn('publish-failed', 'Pub/sub publish failed — local delivery only', {\n channel,\n error: err instanceof Error ? err.message : String(err),\n });\n deliverLocal(channel, JSON.parse(serialized));\n }\n },\n\n subscribe: async (name: string, handler: MessageHandler): Promise<void> =>\n {\n const channel = channelPrefix + name;\n const sub = ensureSubscriber();\n\n try\n {\n // Timeout-bound: at startup this is awaited before the HTTP listener,\n // so a wedged subscriber would otherwise hang the entire boot.\n await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, 'pub/sub subscribe');\n }\n catch (err)\n {\n // SUBSCRIBE failed. If nothing else is live on this connection it\n // may be half-open — drop the memo (and quit it) so a later retry\n // rebuilds a fresh subscriber instead of reusing a dead one.\n if (handlers.size === 0 && subscriber === sub)\n {\n subscriber = undefined;\n void sub.quit?.().catch(() => undefined);\n }\n\n throw err;\n }\n\n // Register only after a confirmed subscribe — never leave a handler\n // bound to a channel the broker hasn't acknowledged.\n handlers.set(channel, handler);\n },\n };\n}\n\n// ── Subscription control channel (grant / revoke across pods) ─────\n\n/**\n * The channel `grant` / `revoke` travel on, published through the same\n * {@link PubSubCache} as events (so `<prefix>$control` on Redis — same timeouts,\n * breaker and warn throttling, no second connection). Event channels are\n * `<prefix><event name>`; an event named `control` would collide with a bare\n * `control`, and `$` never appears in a dotted event name, so it can't.\n */\nconst CONTROL_CHANNEL = '$control';\n\n/** What one `grant` / `revoke` publishes. */\nexport interface SubscriptionControlMessage\n{\n kind: 'grant' | 'revoke';\n\n /** The event's wire name (`defineEvent(name)`) — router keys differ per pod's router. */\n event: string;\n\n subject: string;\n\n /** Which of the subject's connections it reaches; absent → every one. */\n match?: SubscriptionMatch;\n\n /** The publishing binding, so a pod skips its own echo (it applied the change locally first). */\n origin: string;\n}\n\n/** Applies one control message to the local registries of one event. */\nexport type SubscriptionControlApply = (\n kind: SubscriptionControlMessage['kind'],\n subject: string,\n match?: SubscriptionMatch,\n) => void;\n\ninterface ControlBinding\n{\n origin: string;\n events: Map<string, Set<SubscriptionControlApply>>;\n subscribing: Promise<void> | undefined;\n}\n\n/** One binding per cache: in production one per process, in tests one per simulated pod. */\nconst controlBindings = new WeakMap<PubSubCache, ControlBinding>();\n\nfunction ensureControlBinding(cache: PubSubCache): ControlBinding\n{\n let binding = controlBindings.get(cache);\n if (!binding)\n {\n binding = {\n origin: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,\n events: new Map(),\n subscribing: undefined,\n };\n controlBindings.set(cache, binding);\n }\n\n return binding;\n}\n\nfunction isControlMessage(message: unknown): message is SubscriptionControlMessage\n{\n const candidate = message as Partial<SubscriptionControlMessage> | null;\n\n return !!candidate\n && (candidate.kind === 'grant' || candidate.kind === 'revoke')\n && typeof candidate.event === 'string'\n && typeof candidate.subject === 'string'\n && typeof candidate.origin === 'string'\n && (candidate.match === undefined || isSubscriptionMatch(candidate.match));\n}\n\n/**\n * `{ query: { [field]: string } }` with at least one field, and nothing looser:\n * a bad match must not widen a grant, and an empty `query` matches every\n * connection of the subject.\n */\nexport function isSubscriptionMatch(match: unknown): match is SubscriptionMatch\n{\n const query = (match as Partial<SubscriptionMatch> | null)?.query;\n\n return typeof match === 'object'\n && typeof query === 'object'\n && query !== null\n && !Array.isArray(query)\n && Object.keys(query).length > 0\n && Object.values(query).every(value => typeof value === 'string');\n}\n\nfunction dispatchControl(binding: ControlBinding, message: unknown): void\n{\n if (!isControlMessage(message))\n {\n transportLogger.debug('Malformed subscription control message ignored');\n\n return;\n }\n\n if (message.origin === binding.origin)\n {\n return;\n }\n\n const appliers = binding.events.get(message.event);\n if (!appliers)\n {\n transportLogger.debug('Subscription control for an event this pod does not serve ignored', {\n event: message.event,\n });\n\n return;\n }\n\n appliers.forEach(apply => apply(message.kind, message.subject, message.match));\n}\n\n/**\n * Let `grant` / `revoke` published by other pods reach an event on this pod.\n *\n * Called by `EventDef.useCache`. Subscribes the control channel once per cache;\n * a failed subscribe is warned and retried by the next event's bind — inbound\n * control then stays local until it succeeds, never failing `useCache`.\n */\nexport async function bindSubscriptionControl(\n cache: PubSubCache,\n eventName: string,\n apply: SubscriptionControlApply,\n): Promise<void>\n{\n const binding = ensureControlBinding(cache);\n const appliers = binding.events.get(eventName) ?? new Set<SubscriptionControlApply>();\n appliers.add(apply);\n binding.events.set(eventName, appliers);\n\n binding.subscribing ??= cache\n .subscribe(CONTROL_CHANNEL, (message) => dispatchControl(binding, message))\n .catch((err: unknown) =>\n {\n binding.subscribing = undefined;\n throttledWarn('control-subscribe-failed', 'Subscription control subscribe failed — grant/revoke from other pods not received', {\n error: err instanceof Error ? err.message : String(err),\n });\n });\n\n await binding.subscribing;\n}\n\n/**\n * Tell every other pod about a `grant` / `revoke` this pod already applied.\n * A failure is warned (throttled) and swallowed: the change stays local, and\n * the next connect re-reads permission anyway.\n */\nexport async function publishSubscriptionControl(\n cache: PubSubCache,\n kind: SubscriptionControlMessage['kind'],\n eventName: string,\n subject: string,\n match?: SubscriptionMatch,\n): Promise<void>\n{\n const message: SubscriptionControlMessage = {\n kind,\n event: eventName,\n subject,\n ...(match ? { match } : {}),\n origin: ensureControlBinding(cache).origin,\n };\n\n await cache.publish(CONTROL_CHANNEL, message).catch((err: unknown) =>\n {\n throttledWarn('control-publish-failed', 'Subscription control publish failed — applied on this pod only', {\n event: eventName,\n error: err instanceof Error ? err.message : String(err),\n });\n });\n}\n\n/**\n * Options controlling event cache transport wiring.\n */\nexport interface WireEventCacheOptions\n{\n /**\n * Force in-process mode even when a cache is configured.\n * @default true (auto: Redis when CACHE_URL is set, else in-process)\n */\n multiInstance?: boolean;\n\n /**\n * Channel prefix override (env `SPFN_SSE_CHANNEL_PREFIX`, else `spfn:sse:`).\n * Use distinct prefixes to isolate apps/tenants sharing one Redis.\n */\n channelPrefix?: string;\n}\n\n/**\n * Wire every event in a router to the shared Redis pub/sub cache.\n *\n * Returns the transport that ended up active. Idempotent per event name across\n * routers (an event shared by the SSE and WS routers is wired once), so it is\n * safe to call for both `.events()` and `.websockets()`.\n */\nexport async function wireEventRouterCache(\n router: EventRouterBase<any>,\n options: WireEventCacheOptions = {},\n): Promise<'in-process' | 'redis'>\n{\n // Logged at INFO (not debug): operators must be able to confirm from prod logs\n // whether cross-pod fan-out is actually live, and whether any event degraded.\n if (options.multiInstance === false)\n {\n // A sibling router may have already wired shared events to Redis — this flag\n // is process-global and can't un-wire them, so be honest about that.\n const events = router.events as Record<string, WirableEvent>;\n const stillRedis = Object.keys(events).filter(k => state.wired.has(events[k].name)).length;\n\n if (stillRedis > 0)\n {\n transportLogger.warn('multiInstance:false has no effect on events already wired to redis by another router', {\n redisWired: stillRedis,\n });\n }\n else\n {\n transportLogger.info('Event transport: in-process (multiInstance disabled)');\n }\n\n return 'in-process';\n }\n\n const pubSubCache = await resolvePubSubCache(options.channelPrefix);\n if (!pubSubCache)\n {\n transportLogger.info('Event transport: in-process (no cache configured)');\n\n return 'in-process';\n }\n\n const events = router.events as Record<string, WirableEvent>;\n let wired = 0;\n let degraded = 0;\n let alreadyWired = 0;\n\n for (const key of Object.keys(events))\n {\n const event = events[key];\n if (state.wired.has(event.name))\n {\n alreadyWired++; // e.g. an event shared by the SSE and WS routers\n continue;\n }\n\n try\n {\n await event.useCache(pubSubCache);\n state.wired.set(event.name, event);\n wired++;\n }\n catch (err)\n {\n // Degrade this event to in-process rather than aborting startup — a\n // transient SUBSCRIBE failure must not crash the pod. Not marked\n // wired, so a later wiring pass can retry it.\n degraded++;\n transportLogger.warn('Event cache wiring failed — staying in-process for this event', {\n event: event.name,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n // Every event degraded (and none were already wired) → fan-out is effectively\n // dead for this router; report in-process honestly instead of a false 'redis'.\n if (wired + alreadyWired === 0)\n {\n transportLogger.warn('Event transport: redis configured but no event wired — running in-process', {\n total: Object.keys(events).length,\n degraded,\n });\n\n return 'in-process';\n }\n\n transportLogger.info('Event transport: redis (cross-pod fan-out)', {\n total: Object.keys(events).length,\n wired,\n degraded,\n alreadyWired,\n });\n\n return 'redis';\n}\n\n/**\n * Resolve (and cache) the shared pub/sub cache from the global cache instance.\n * Returns undefined when no cache is available.\n */\nasync function resolvePubSubCache(channelPrefix?: string): Promise<PubSubCache | undefined>\n{\n const resolvedPrefix = resolveChannelPrefix(channelPrefix);\n\n if (state.pubSubCache)\n {\n // The transport (and its prefix) is process-global, resolved once on the\n // first wiring. Warn if a later router (.websockets() after .events())\n // asks for a different prefix — it's silently ignored, not applied.\n if (state.channelPrefix !== resolvedPrefix)\n {\n transportLogger.warn('Conflicting channelPrefix ignored — the transport prefix is process-global (first wiring wins).', {\n active: state.channelPrefix,\n ignored: resolvedPrefix,\n });\n }\n\n return state.pubSubCache;\n }\n\n let client: PubSubClient | undefined;\n try\n {\n const { getCache } = await import('@spfn/core/cache');\n client = getCache() as PubSubClient | undefined;\n }\n catch\n {\n // Cache module unavailable — fall back to in-process.\n return undefined;\n }\n\n if (!client)\n {\n return undefined;\n }\n\n // Cross-app isolation depends on the channel prefix. The default is shared by\n // every SPFN app, so warn (once, on first resolve) when running on it — apps\n // sharing one Redis with colliding event names would otherwise leak payloads.\n if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX)\n {\n transportLogger.warn(\n 'Event fan-out is on the default channel prefix (spfn:sse:). Apps sharing one '\n + 'Redis must set channelPrefix or SPFN_SSE_CHANNEL_PREFIX to avoid cross-app leakage.',\n );\n }\n\n state.channelPrefix = resolvedPrefix;\n state.pubSubCache = createRedisPubSubCache(\n client,\n resolvedPrefix,\n (connection) =>\n {\n state.subscriber = connection;\n },\n );\n\n return state.pubSubCache;\n}\n\n/**\n * Close the shared pub/sub subscriber connection and reset transport state.\n *\n * Called during graceful shutdown — the subscriber is a `duplicate()` of the\n * cache write connection and is not tracked by the cache manager, so it must be\n * quit explicitly (before `closeCache`). No-op when no subscriber was opened.\n *\n * Also resets each wired EventDef's cache binding. EventDefs are module\n * singletons that outlive a server instance; without this, a second server start\n * in the same process (tests, hot-reload) hits the `cacheSubscribed` latch, never\n * rebinds, and silently runs with cross-pod fan-out dead.\n */\nexport async function closeEventTransport(): Promise<void>\n{\n const subscriber = state.subscriber;\n\n for (const event of state.wired.values())\n {\n event._resetCache?.();\n }\n\n state.subscriber = undefined;\n state.pubSubCache = undefined;\n state.channelPrefix = undefined;\n state.wired.clear();\n\n if (!subscriber?.quit)\n {\n return;\n }\n\n // Bound the quit: a wedged connection (broken socket, no FIN) must not stall\n // shutdown past this timeout — the process exits and the OS reclaims the socket.\n await Promise.race([\n subscriber.quit().catch(() => undefined),\n new Promise<void>((resolve) =>\n {\n const timer = setTimeout(resolve, SUBSCRIBER_QUIT_TIMEOUT_MS);\n timer.unref?.();\n }),\n ]);\n}\n","/**\n * Event System\n *\n * Decoupled pub/sub event system with optional cache integration for multi-instance support\n *\n * @example\n * ```typescript\n * // Define event\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe (in-memory)\n * userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit\n * await userCreated.emit({ userId: '123' });\n *\n * // With cache for multi-instance\n * const event = defineEvent('user.created', schema);\n * await event.useCache(cache); // Must await before emitting\n * await event.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\n\nimport type { TSchema, Static } from '@sinclair/typebox';\nimport { logger } from '@spfn/core/logger';\nimport { bindSubscriptionControl, isSubscriptionMatch, publishSubscriptionControl } from './cache-transport';\nimport type {\n EventContract,\n EventDef,\n EventHandler,\n EventSubscribeOptions,\n FramePredicate,\n InitialProducer,\n JobQueueSender,\n PubSubCache,\n SubscribePredicate,\n SubscriptionControl,\n SubscriptionMatch,\n} from './types';\n\nconst eventLogger = logger.child('@spfn/core:event');\n\n/**\n * Log handler error with consistent format\n */\nfunction logHandlerError(eventName: string, error: unknown): void\n{\n eventLogger.error(`Event handler error: ${eventName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Log job queue error with consistent format\n */\nfunction logJobQueueError(queueName: string, error: unknown): void\n{\n eventLogger.error(`Failed to send event to job queue: ${queueName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Create handler subscription manager\n */\nfunction createHandlerManager<TPayload>(name: string)\n{\n const handlers: Set<EventHandler<TPayload>> = new Set();\n\n return {\n add: (handler: EventHandler<TPayload>): (() => void) =>\n {\n handlers.add(handler);\n eventLogger.debug(`Subscribed to event: ${name}`, { handlerCount: handlers.size });\n\n return () =>\n {\n handlers.delete(handler);\n eventLogger.debug(`Unsubscribed from event: ${name}`, { handlerCount: handlers.size });\n };\n },\n\n clear: (): void =>\n {\n handlers.clear();\n eventLogger.debug(`Unsubscribed all from event: ${name}`);\n },\n\n trigger: async (payload: TPayload): Promise<void> =>\n {\n const results = await Promise.allSettled(\n [...handlers].map((handler) => handler(payload)),\n );\n\n for (const result of results)\n {\n if (result.status === 'rejected')\n {\n logHandlerError(name, result.reason);\n }\n }\n },\n };\n}\n\n/**\n * Create job queue manager\n */\nfunction createJobQueueManager(name: string)\n{\n const jobQueues: Map<string, JobQueueSender> = new Map();\n\n return {\n register: (queueName: string, sender: JobQueueSender): void =>\n {\n jobQueues.set(queueName, sender);\n eventLogger.debug(`Registered job queue for event: ${name}`, { queueName });\n },\n\n send: async (payload: unknown): Promise<void> =>\n {\n if (jobQueues.size === 0)\n {\n return;\n }\n\n const entries = [...jobQueues.entries()];\n const results = await Promise.allSettled(\n entries.map(([queueName, sender]) => sender(queueName, payload)),\n );\n\n for (const [i, result] of results.entries())\n {\n if (result.status === 'rejected')\n {\n logJobQueueError(entries[i][0], result.reason);\n }\n }\n },\n\n get size(): number\n {\n return jobQueues.size;\n },\n };\n}\n\n/**\n * Create the registry of SSE handlers' subscription controls for one event.\n *\n * `grant` / `revoke` apply to every registered control (an event may sit under\n * two router keys, or in two handlers in one process), then — when the event is\n * bound to a cache — publish once so every other pod applies it too.\n */\nfunction createSubscriptionControls(name: string, getCache: () => PubSubCache | undefined)\n{\n const controls: Set<SubscriptionControl> = new Set();\n\n const applyLocal = (kind: 'grant' | 'revoke', subject: string, match?: SubscriptionMatch): void =>\n {\n controls.forEach(control => control[kind](subject, match));\n };\n\n const change = (kind: 'grant' | 'revoke') => async (subject: string, match?: SubscriptionMatch): Promise<void> =>\n {\n assertMatch(name, kind, match);\n applyLocal(kind, subject, match);\n\n const cache = getCache();\n if (cache)\n {\n await publishSubscriptionControl(cache, kind, name, subject, match);\n }\n };\n\n return {\n applyLocal,\n grant: change('grant'),\n revoke: change('revoke'),\n register: (control: SubscriptionControl): (() => void) =>\n {\n controls.add(control);\n\n return () => controls.delete(control);\n },\n };\n}\n\n/**\n * Refuse a `match` before it is applied or published. JSON drops an\n * `undefined` field, so `{ workspaceId: undefined }` would open nothing here\n * and every connection of the subject on every other pod; an empty `query`\n * would open every connection everywhere.\n */\nfunction assertMatch(name: string, kind: 'grant' | 'revoke', match: SubscriptionMatch | undefined): void\n{\n if (match !== undefined && !isSubscriptionMatch(match))\n {\n throw new Error(\n `Event \"${name}\" .${kind}() refused its match: match.query must name at least one field, each with a string value. `\n + 'A match only ever narrows a grant or revoke to the connections that declared those values; '\n + 'omit match to reach every connection of the subject.',\n );\n }\n}\n\n/**\n * Throw when a set-once declaration of a shared event is made a second time.\n */\nfunction assertDeclaredOnce(name: string, method: string, declared: boolean): void\n{\n if (declared)\n {\n throw new Error(\n `Event \"${name}\" already declares .${method}(). `\n + 'An event is defined once and shared, so a second declaration would silently replace the first. '\n + `Declare .${method}() once, where the event is defined.`,\n );\n }\n}\n\n/**\n * Internal: Create event implementation\n */\nfunction createEventImpl<TPayload>(\n name: string,\n schema?: TSchema,\n): EventDef<TPayload>\n{\n const handlerManager = createHandlerManager<TPayload>(name);\n // `localOnly` handlers: run by this process's emit, never by a cache delivery.\n const localHandlerManager = createHandlerManager<TPayload>(name);\n const jobQueueManager = createJobQueueManager(name);\n let cache: PubSubCache | undefined;\n let cacheSubscribed = false;\n const subscriptionControls = createSubscriptionControls(name, () => cache);\n\n const emit = async (payload?: TPayload): Promise<void> =>\n {\n eventLogger.debug(`Emitting event: ${name}`, {\n payload,\n hasCache: !!cache,\n jobQueueCount: jobQueueManager.size,\n });\n\n if (cache)\n {\n await cache.publish(name, payload);\n }\n else\n {\n await handlerManager.trigger(payload as TPayload);\n }\n\n await localHandlerManager.trigger(payload as TPayload);\n\n await jobQueueManager.send(payload);\n eventLogger.debug(`Event emitted: ${name}`);\n };\n\n const useCache = async (newCache: PubSubCache): Promise<EventDef<TPayload>> =>\n {\n if (cacheSubscribed)\n {\n eventLogger.warn(`Cache already configured for event: ${name}`);\n\n return self;\n }\n\n // Subscribe FIRST, then flip to cache mode. If subscribe rejects, the\n // event stays in-process (cache undefined) and cacheSubscribed stays\n // false — so emit keeps delivering locally and a later useCache can retry.\n // Setting these before the await would latch a half-broken state: emit\n // would publish to a channel this instance never subscribed to.\n // A delivery from the cache — this pod's own emit echoed back, or another\n // pod's — reaches the plain handlers only; `localOnly` ones ran in emit.\n await newCache.subscribe(name, async (message: unknown) =>\n {\n eventLogger.debug(`Received event from cache: ${name}`);\n await handlerManager.trigger(message as TPayload);\n });\n\n cache = newCache;\n cacheSubscribed = true;\n\n await bindSubscriptionControl(newCache, name, subscriptionControls.applyLocal);\n\n eventLogger.debug(`Cache subscription ready for event: ${name}`);\n\n return self;\n };\n\n // Drop the cache binding so a later useCache can rebind a fresh transport.\n // Without this, the cacheSubscribed latch would make a second server start in\n // the same process (tests, hot-reload) a no-op while the event still points at\n // a torn-down cache — cross-pod fan-out would silently stay dead.\n const resetCache = (): void =>\n {\n cache = undefined;\n cacheSubscribed = false;\n };\n\n let contract: EventContract | undefined;\n\n const declareContract = (next: EventContract): EventDef<TPayload> =>\n {\n if (contract)\n {\n throw new Error(\n `Event \"${name}\" already declares .contract({ since: '${contract.since}' }). `\n + 'An event is defined once and shared, so a second declaration would silently replace one of the '\n + 'two. Declare the contract once, where the event is defined.',\n );\n }\n\n contract = next;\n\n return self;\n };\n\n let canSubscribe: SubscribePredicate<any> | undefined;\n let filter: FramePredicate<TPayload> | undefined;\n let initial: InitialProducer<TPayload, any> | undefined;\n\n const declareCanSubscribe = <P>(predicate: SubscribePredicate<P>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'canSubscribe', !!canSubscribe);\n canSubscribe = predicate;\n\n return self;\n };\n\n const declareFilter = (predicate: FramePredicate<TPayload>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'filter', !!filter);\n filter = predicate;\n\n return self;\n };\n\n const declareInitial = <P>(producer: InitialProducer<TPayload, P>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'initial', !!initial);\n initial = producer;\n\n return self;\n };\n\n const subscribe = (handler: EventHandler<TPayload>, options?: EventSubscribeOptions): (() => void) =>\n (options?.localOnly ? localHandlerManager : handlerManager).add(handler);\n\n const unsubscribeAll = (): void =>\n {\n handlerManager.clear();\n localHandlerManager.clear();\n };\n\n const self: EventDef<TPayload> = {\n name,\n schema,\n get _contract()\n {\n return contract;\n },\n contract: declareContract,\n get _canSubscribe()\n {\n return canSubscribe;\n },\n canSubscribe: declareCanSubscribe,\n get _filter()\n {\n return filter;\n },\n filter: declareFilter,\n get _initial()\n {\n return initial;\n },\n initial: declareInitial,\n grant: subscriptionControls.grant,\n revoke: subscriptionControls.revoke,\n _registerSubscriptionControl: subscriptionControls.register,\n subscribe,\n unsubscribeAll,\n emit: emit as EventDef<TPayload>['emit'],\n useCache,\n _registerJobQueue: jobQueueManager.register,\n _resetCache: resetCache,\n _payload: undefined as unknown as TPayload,\n };\n\n return self;\n}\n\n/**\n * Define an event without payload\n */\nexport function defineEvent(name: string): EventDef<void>;\n\n/**\n * Define an event with typed payload\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema: T,\n): EventDef<Static<T>>;\n\n/**\n * Define an event for decoupled pub/sub\n *\n * @example\n * ```typescript\n * // Define event with payload\n * export const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe to event (in-memory)\n * const unsubscribe = userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit event\n * await userCreated.emit({ userId: '123' });\n *\n * // Unsubscribe when done\n * unsubscribe();\n *\n * // Multi-instance with cache\n * await userCreated.useCache(cache);\n * await userCreated.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema?: T,\n): EventDef<Static<T>> | EventDef\n{\n if (schema)\n {\n return createEventImpl<Static<T>>(name, schema);\n }\n\n return createEventImpl<void>(name);\n}\n","/**\n * SSE Event Route Map\n *\n * Static route map for SSE token endpoint.\n * Merge into RPC proxy routeMap so `eventsToken` resolves to `POST /events/token`.\n *\n * @example\n * ```typescript\n * // app/api/rpc/[routeName]/route.ts\n * import { createRpcProxy } from '@spfn/core/nextjs/server';\n * import { eventRouteMap } from '@spfn/core/event';\n * import { routeMap } from '@/generated/route-map';\n *\n * export const { GET, POST } = createRpcProxy({\n * routeMap: { ...routeMap, ...eventRouteMap },\n * });\n * ```\n */\nexport const eventRouteMap = {\n eventsToken: { method: 'POST' as const, path: '/events/token' },\n};\n","/**\n * Event Router\n *\n * Type-safe event router for SSE subscription\n *\n * @example\n * ```typescript\n * import { defineEvent, defineEventRouter } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * const orderPlaced = defineEvent('order.placed', Type.Object({\n * orderId: Type.String(),\n * amount: Type.Number(),\n * }));\n *\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * export type EventRouter = typeof eventRouter;\n * ```\n */\n\nimport type { EventDef } from './types';\n\n/**\n * How a client reaches the contracted events of a router.\n *\n * Declared on the router because it is the stream's promise, not one event's:\n * `createServer` checks it against `.events()` at boot, so the document cannot\n * describe a path or an auth mode the server does not serve.\n */\nexport interface EventRouterContract\n{\n /**\n * `'tokenExchange'` when `.events()` enables `auth` — the client first posts\n * to the token path and opens the stream with `?token=`. `'none'` otherwise.\n */\n auth: 'none' | 'tokenExchange';\n\n /**\n * The stream path `.events()` registers.\n * @default '/events/stream'\n */\n streamPath?: string;\n}\n\n/**\n * What every router of events carries — the SSE event router and the\n * WebSocket router alike.\n *\n * The WebSocket router builds on this rather than on `EventRouterDef`, because\n * only an SSE event router can be contracted.\n */\nexport interface EventRouterBase<TEvents extends Record<string, EventDef<any>>>\n{\n /**\n * Event definitions\n */\n readonly events: TEvents;\n\n /**\n * Event names as array\n */\n readonly eventNames: (keyof TEvents)[];\n\n /**\n * Type inference helper - payload types by event name\n */\n readonly _types: {\n [K in keyof TEvents]: TEvents[K]['_payload'];\n };\n}\n\n/**\n * Event Router Definition\n */\nexport interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> extends EventRouterBase<TEvents>\n{\n /**\n * The contract declared with `.contract()`, absent when the router declares\n * none. Underscored for the same reason as `EventDef._contract`.\n */\n readonly _contract?: EventRouterContract;\n\n /**\n * Declare how a client reaches this router's contracted events.\n *\n * Sets the contract on this router and returns it. A `streamPath` that does\n * not start with `/` throws here, where the typo is.\n */\n contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;\n}\n\n/**\n * Infer event names from EventRouter\n */\nexport type InferEventNames<T> = T extends EventRouterBase<infer E>\n ? keyof E & string\n : never;\n\n/**\n * Infer payload type for specific event\n */\nexport type InferEventPayload<\n T extends EventRouterBase<any>,\n K extends InferEventNames<T>,\n> = T['_types'][K];\n\n/**\n * Infer all event payloads map\n */\nexport type InferEventPayloads<T extends EventRouterBase<any>> = T['_types'];\n\n/**\n * Define an event router for SSE subscription\n *\n * `.contract({ auth })` on the router, together with `.contract({ since })` on\n * its events, publishes those events in `contracts/current.json` — see the\n * contract README.\n *\n * @example\n * ```typescript\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * // Type inference\n * type Names = InferEventNames<typeof eventRouter>;\n * // 'userCreated' | 'orderPlaced'\n *\n * type Payload = InferEventPayload<typeof eventRouter, 'userCreated'>;\n * // { userId: string }\n * ```\n */\nexport function defineEventRouter<\n TEvents extends Record<string, EventDef<any>>,\n>(events: TEvents): EventRouterDef<TEvents>\n{\n let contract: EventRouterContract | undefined;\n\n const router: EventRouterDef<TEvents> = {\n events,\n eventNames: Object.keys(events) as (keyof TEvents)[],\n get _contract()\n {\n return contract;\n },\n contract: (next) =>\n {\n assertStreamPath(next.streamPath);\n contract = next;\n\n return router;\n },\n _types: {} as EventRouterDef<TEvents>['_types'],\n };\n\n return router;\n}\n\n/**\n * A stream path is matched against the path `.events()` registers and written\n * into the contract as a URL path, so a relative one can never be right.\n */\nfunction assertStreamPath(streamPath: string | undefined): void\n{\n if (streamPath !== undefined && !streamPath.startsWith('/'))\n {\n throw new Error(\n `defineEventRouter(...).contract({ streamPath: \"${streamPath}\" }) is not an absolute path. `\n + 'The contract publishes it as the URL path a client opens, and createServer compares it with the '\n + 'path .events() registers. Start it with \"/\".',\n );\n }\n}\n","/**\n * WebSocket Handler\n *\n * Attaches a WebSocket server to an existing Node.js http.Server.\n * Handles authentication, event subscription, and client message routing.\n */\n\nimport type { Server } from 'node:http';\nimport type { EventDef } from '../types';\nimport type {\n WSRouterDef,\n WSHandlerConfig,\n WSHandlerAuthConfig,\n WSMessageHandlers,\n WSRawConnection,\n} from './types';\nimport type { SSETokenManager } from '../sse/token-manager';\nimport { logger } from '@spfn/core/logger';\n\nconst wsLogger = logger.child('@spfn/core:ws');\n\n/**\n * Decide whether a WebSocket upgrade's Origin is permitted.\n *\n * - no allow-list configured → allowed (check disabled)\n * - no Origin header (native/non-browser client) → allowed\n * - Origin present → must be in the allow-list\n */\nexport function isOriginAllowed(\n origin: string | undefined,\n allowList: Set<string> | null,\n): boolean\n{\n if (!allowList)\n {\n return true;\n }\n\n if (!origin)\n {\n return true;\n }\n\n return allowList.has(origin);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Attach a WebSocket server to a Node.js http.Server\n *\n * @returns cleanup function that closes the WebSocket server\n */\nexport async function attachWSHandler<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers,\n>(\n server: Server,\n router: WSRouterDef<TEvents, TMessages>,\n config: WSHandlerConfig & { path?: string } = {},\n tokenManager?: SSETokenManager,\n): Promise<() => Promise<void>>\n{\n const WebSocketServer = await loadWSServer();\n\n const {\n pingInterval = 30000,\n path = '/ws',\n maxPayload = 1_048_576,\n maxBufferedBytes = 1_048_576,\n maxConnections = 10_000,\n maxConnectionsPerSubject = 0,\n allowedOrigins,\n auth: authConfig,\n } = config;\n\n const originAllowList = allowedOrigins && allowedOrigins.length > 0\n ? new Set(allowedOrigins)\n : null;\n\n if (authConfig?.enabled && !tokenManager)\n {\n throw new Error(\n 'WebSocket auth.enabled=true requires a tokenManager. ' +\n 'Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer.',\n );\n }\n\n const wss = new WebSocketServer({ server, path, maxPayload });\n\n // Track live connections for graceful shutdown\n const clients = new Set<any>();\n // Live connection count per authenticated subject (for the per-subject cap)\n const subjectCounts = new Map<string, number>();\n\n wss.on('connection', (ws: any, req: any) =>\n {\n // Origin allow-list (cross-site WebSocket hijacking protection). Browsers\n // always send Origin on the upgrade; reject one that isn't allowed before\n // doing any work. A missing Origin (native/non-browser client, which can't\n // be CSRF'd into using ambient cookies) passes through.\n if (!isOriginAllowed(req.headers?.origin, originAllowList))\n {\n wsLogger.warn('WebSocket rejected: disallowed origin', { origin: req.headers?.origin });\n ws.close(1008, 'Origin not allowed');\n\n return;\n }\n\n // Global connection cap — reject before doing any work\n if (clients.size >= maxConnections)\n {\n ws.close(1013, 'Server at capacity');\n\n return;\n }\n\n clients.add(ws);\n ws.on('close', () => clients.delete(ws));\n handleConnection(ws, req, router, authConfig, tokenManager, {\n pingInterval,\n maxBufferedBytes,\n maxConnectionsPerSubject,\n subjectCounts,\n })\n .catch((err: Error) =>\n {\n wsLogger.error('WebSocket connection handler error', err);\n if (ws.readyState === 1) ws.close(1011, 'Internal server error');\n });\n });\n\n wss.on('error', (err: Error) =>\n {\n wsLogger.error('WebSocket server error', err);\n });\n\n wsLogger.info(`✓ WebSocket endpoint registered at ${path}`, {\n events: router.eventNames,\n auth: !!authConfig?.enabled,\n });\n\n return () => new Promise<void>((resolve, reject) =>\n {\n // Close all existing connections with 1001 Going Away\n for (const client of clients)\n {\n client.close(1001, 'Server shutting down');\n }\n clients.clear();\n\n wss.close((err?: Error) =>\n {\n if (err) reject(err);\n else resolve();\n });\n });\n}\n\n// ============================================================================\n// Connection Handler\n// ============================================================================\n\ninterface ConnectionOptions\n{\n pingInterval: number;\n maxBufferedBytes: number;\n maxConnectionsPerSubject: number;\n subjectCounts: Map<string, number>;\n}\n\nasync function handleConnection(\n ws: any,\n req: any,\n router: WSRouterDef<any, any>,\n authConfig: WSHandlerAuthConfig | undefined,\n tokenManager: SSETokenManager | undefined,\n opts: ConnectionOptions,\n): Promise<void>\n{\n const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;\n // Register close handler before any await — ensures we never miss the event even during auth\n let pingTimer: ReturnType<typeof setInterval> | undefined;\n let connectionUnsubscribes: (() => void)[] = [];\n let subscribedEvents: string[] = [];\n ws.on('close', () =>\n {\n clearInterval(pingTimer);\n connectionUnsubscribes.forEach(fn => fn());\n if (subscribedEvents.length > 0)\n wsLogger.info('WebSocket connection closed', { events: subscribedEvents });\n });\n\n const url = parseURL(req);\n if (!url)\n {\n ws.close(1002, 'Invalid request URL');\n\n return;\n }\n\n // ── 1. Authenticate ──\n const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : undefined);\n if (subject === false)\n {\n ws.close(4001, 'Missing token');\n\n return;\n }\n if (subject === null)\n {\n ws.close(4001, 'Invalid or expired token');\n\n return;\n }\n\n // ── 2. Resolve subscribed events ──\n const requestedEvents = parseRequestedEvents(url, router.eventNames as string[]);\n if (requestedEvents.length === 0)\n {\n ws.close(4000, 'No valid event names specified');\n\n return;\n }\n\n // ── 3. Authorize ──\n const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);\n if (allowedEvents === null)\n {\n ws.close(4003, 'Not authorized for any requested events');\n\n return;\n }\n\n // ── Per-subject connection cap (authenticated subjects only) ──\n if (maxConnectionsPerSubject > 0 && typeof subject === 'string')\n {\n const current = subjectCounts.get(subject) ?? 0;\n if (current >= maxConnectionsPerSubject)\n {\n ws.close(1013, 'Too many connections for this subject');\n\n return;\n }\n\n subjectCounts.set(subject, current + 1);\n ws.on('close', () =>\n {\n const remaining = (subjectCounts.get(subject) ?? 1) - 1;\n if (remaining <= 0) subjectCounts.delete(subject);\n else subjectCounts.set(subject, remaining);\n });\n }\n\n subscribedEvents = allowedEvents;\n wsLogger.info('WebSocket connection established', {\n events: allowedEvents,\n subject: subject ?? undefined,\n });\n\n // ── 4. Build connection wrapper ──\n const connection = createConnection(ws, maxBufferedBytes);\n\n // ── 5. Subscribe to server-push events ──\n connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);\n\n // If socket closed during auth awaits, clean up and bail\n if (ws.readyState !== 1)\n {\n connectionUnsubscribes.forEach(fn => fn());\n connectionUnsubscribes = [];\n\n return;\n }\n\n // ── 6. Handle incoming messages ──\n ws.on('message', (data: Buffer | string) =>\n {\n onClientMessage(data, router, connection, subject)\n .catch((err: Error) => wsLogger.error('Unhandled message error', err));\n });\n\n // ── 7. Keep-alive ping with liveness (pong) tracking ──\n // Without reaping un-ponged sockets, a half-open connection (sleeping device,\n // NAT drop with no FIN) lingers with its subscriptions, timer, and buffers.\n if (pingInterval > 0)\n {\n ws.isAlive = true;\n ws.on('pong', () =>\n {\n ws.isAlive = true;\n });\n\n pingTimer = setInterval(() =>\n {\n if (ws.readyState !== 1) return;\n\n if (ws.isAlive === false)\n {\n // No pong since the previous tick — drop the dead/half-open socket\n ws.terminate();\n\n return;\n }\n\n ws.isAlive = false;\n ws.ping();\n }, pingInterval);\n }\n\n // ── 9. Send connected ack ──\n connection.send('__connected', {\n subscribedEvents: allowedEvents,\n timestamp: Date.now(),\n });\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction parseURL(req: any): URL | null\n{\n try\n {\n return new URL(req.url ?? '/', 'ws://localhost');\n }\n catch\n {\n return null;\n }\n}\n\n/**\n * Resolve subject from token\n * - undefined: no auth required\n * - false: token param missing (when required)\n * - null: token invalid/expired\n * - string: authenticated subject\n */\nasync function resolveSubject(\n url: URL,\n tokenManager?: SSETokenManager,\n): Promise<string | undefined | false | null>\n{\n if (!tokenManager)\n {\n return undefined;\n }\n\n const token = url.searchParams.get('token');\n if (!token)\n {\n return false;\n }\n\n return await tokenManager.verify(token);\n}\n\nfunction parseRequestedEvents(url: URL, validEventNames: string[]): string[]\n{\n const eventsParam = url.searchParams.get('events');\n if (!eventsParam)\n {\n return [];\n }\n\n return eventsParam\n .split(',')\n .map(e => e.trim())\n .filter(e => validEventNames.includes(e));\n}\n\nasync function resolveAllowedEvents(\n subject: string | undefined,\n requestedEvents: string[],\n authConfig?: WSHandlerAuthConfig,\n): Promise<string[] | null>\n{\n if (!subject || !authConfig?.authorize)\n {\n return requestedEvents;\n }\n\n const allowed = await authConfig.authorize(subject, requestedEvents);\n\n return allowed.length === 0 ? null : allowed;\n}\n\n/**\n * Send a JSON frame with backpressure protection. If the socket's outbound\n * buffer is already past the cap, the consumer is too slow — close the\n * connection (1013) instead of buffering more and risking OOM. The client\n * reconnects and re-subscribes.\n */\nexport function safeSend(ws: any, frame: unknown, maxBufferedBytes: number): void\n{\n if (ws.readyState !== 1) return;\n\n if (ws.bufferedAmount > maxBufferedBytes)\n {\n ws.close(1013, 'Send buffer overflow');\n\n return;\n }\n\n try\n {\n ws.send(JSON.stringify(frame));\n }\n catch\n {\n // Socket closed between the readyState check and send — ignore\n }\n}\n\nfunction createConnection(ws: any, maxBufferedBytes: number): WSRawConnection\n{\n return {\n send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),\n close: (code, reason) => ws.close(code, reason),\n };\n}\n\nfunction subscribeEvents(\n ws: any,\n router: WSRouterDef<any, any>,\n allowedEvents: string[],\n subject: string | undefined,\n authConfig: WSHandlerAuthConfig | undefined,\n maxBufferedBytes: number,\n): (() => void)[]\n{\n const unsubscribes: (() => void)[] = [];\n\n for (const eventName of allowedEvents)\n {\n const eventDef = router.events[eventName];\n if (!eventDef) continue;\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n if (ws.readyState !== 1) return;\n\n if (subject && authConfig?.filter?.[eventName])\n {\n if (!authConfig.filter[eventName](subject, payload)) return;\n }\n\n safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n return unsubscribes;\n}\n\nasync function onClientMessage(\n data: Buffer | string,\n router: WSRouterDef<any, any>,\n connection: WSRawConnection,\n subject: string | undefined,\n): Promise<void>\n{\n let message: { type?: string; data?: unknown };\n\n try\n {\n message = JSON.parse(data.toString());\n }\n catch\n {\n return;\n }\n\n const { type, data: payload } = message;\n if (!type) return;\n\n const handler = router.messages[type];\n if (!handler) return;\n\n try\n {\n await handler({ payload, subject, ws: connection });\n }\n catch (err)\n {\n wsLogger.error(`WebSocket message handler error: ${type}`, err as Error);\n }\n}\n\n// ============================================================================\n// Dynamic import for optional 'ws' dependency\n// ============================================================================\n\n/**\n * Resolve the WebSocketServer constructor from the 'ws' module object.\n *\n * ws is a dual package, so the module shape depends on the loader path:\n * - ESM (exports.import → wrapper.mjs): WebSocketServer is a named export;\n * the default export is the bare WebSocket class with no properties on it.\n * - CJS (require interop): module.exports = WebSocket with\n * WebSocket.WebSocketServer attached, surfaced under .default.\n * Named exports must win — checking .default first crashes on the ESM path.\n */\nexport function resolveWSServerCtor(mod: any): any\n{\n return mod.WebSocketServer ?? mod.Server\n ?? mod.default?.WebSocketServer ?? mod.default?.Server;\n}\n\nasync function loadWSServer(): Promise<any>\n{\n let mod: any;\n\n try\n {\n mod = await import('ws');\n }\n catch\n {\n throw new Error(\n '@spfn/core WebSocket support requires the \"ws\" package.\\n' +\n 'Install it with: pnpm add ws',\n );\n }\n\n const WSS = resolveWSServerCtor(mod);\n\n if (typeof WSS !== 'function')\n {\n throw new Error(\n 'WebSocketServer not found in ws module. ' +\n 'Ensure ws@^8 is installed: pnpm add ws',\n );\n }\n\n return WSS;\n}\n","/**\n * WebSocket Module\n *\n * Type-safe WebSocket server with event-based pub/sub and bidirectional messaging.\n *\n * @example Server setup\n * ```typescript\n * // src/server/ws.ts\n * import { defineWSRouter } from '@spfn/core/event/ws';\n * import { defineEvent } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userUpdated = defineEvent('user.updated', Type.Object({ userId: Type.String() }));\n * const notification = defineEvent('notification', Type.Object({ message: Type.String() }));\n *\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * },\n * });\n *\n * export type WSRouter = typeof wsRouter;\n *\n * // server.config.ts\n * defineServerConfig()\n * .websockets(wsRouter)\n * .build();\n * ```\n *\n * @example Client usage\n * ```typescript\n * import { createWSClient } from '@spfn/core/event/ws/client';\n * import type { WSRouter } from '@/server/ws';\n *\n * const client = createWSClient<WSRouter>();\n *\n * client.subscribe({\n * events: ['userUpdated', 'notification'],\n * handlers: {\n * userUpdated: ({ userId }) => console.log(userId),\n * notification: ({ message }) => console.log(message),\n * },\n * });\n *\n * client.send('ping', {});\n * ```\n */\n\nimport type { EventDef } from '../types';\nimport type { WSRouterDef, WSMessageHandlers } from './types';\n\nexport { attachWSHandler } from './handler';\nexport type {\n WSRouterDef,\n WSHandlerConfig,\n WSAuthConfig,\n WSHandlerAuthConfig,\n WSMessageContext,\n WSMessageHandlerFn,\n WSMessageHandlers,\n WSRawConnection,\n WSClientConfig,\n WSConnectionState,\n WSEventHandlers,\n WSSubscribeOptions,\n WSUnsubscribe,\n} from './types';\n\n/**\n * Define a WebSocket router\n *\n * Combines server→client event push with client→server message handlers.\n *\n * @example\n * ```typescript\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * 'chat.send': ({ payload, subject }) => handleChat(payload, subject),\n * },\n * });\n * ```\n */\nexport function defineWSRouter<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers = WSMessageHandlers,\n>(def: {\n events: TEvents;\n messages?: TMessages;\n}): WSRouterDef<TEvents, TMessages>\n{\n return {\n events: def.events,\n eventNames: Object.keys(def.events) as (keyof TEvents)[],\n messages: (def.messages ?? {}) as TMessages,\n _types: {} as WSRouterDef<TEvents, TMessages>['_types'],\n };\n}\n"]}
1
+ {"version":3,"sources":["../../src/event/sse/key-close.ts","../../src/event/cache-transport.ts","../../src/event/event.ts","../../src/event/sse/route-map.ts","../../src/event/router.ts","../../src/event/ws/handler.ts","../../src/event/ws/index.ts"],"names":["logger"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Process-wide close of the SSE streams one device key opened\n *\n * Every `createSSEHandler` registers its connection registry here, so a key\n * revocation reaches every handler of the process (two routers, tests) — the\n * local half of `closeSSEConnectionsByKey`. The cross-pod half lives in the\n * cache transport, which calls `applyKeyClose` for a close another pod\n * published; this module imports nothing from it, so the two never cycle.\n */\n\nimport { logger } from '@spfn/core/logger';\nimport type { ConnectionRegistry } from './connection-registry';\n\nconst keyCloseLogger = logger.child('@spfn/core:sse');\n\n/** What this module needs of a registry. */\ntype KeyClosable = Pick<ConnectionRegistry, 'closeByKey'>;\n\nconst REGISTRIES_KEY = Symbol.for('@spfn/core:sse-key-close-registries');\n\n// globalThis, like the event transport: one set per process across the CJS/ESM dual package.\nconst registries: Set<KeyClosable> = ((globalThis as any)[REGISTRIES_KEY] ??= new Set<KeyClosable>());\n\n/** Let `applyKeyClose` reach one handler's connections. */\nexport function registerKeyCloseRegistry(registry: KeyClosable): void\n{\n registries.add(registry);\n}\n\n/**\n * Close, on this process, every open SSE stream whose token was minted with\n * `keyId`, and stop its pending connects from opening. Answers how many open\n * streams it closed. Logs the count and reason only — never the key id.\n */\nexport function applyKeyClose(keyId: string): number\n{\n let closed = 0;\n registries.forEach(registry =>\n {\n closed += registry.closeByKey(keyId);\n });\n\n if (closed > 0)\n {\n keyCloseLogger.info('SSE connections closed', { count: closed, reason: 'key revoked' });\n }\n else\n {\n keyCloseLogger.debug('SSE key close reached no open connection', { reason: 'key revoked' });\n }\n\n return closed;\n}\n","/**\n * Event cache transport (Redis/Valkey pub/sub)\n *\n * Wires each event in a router to a shared {@link PubSubCache} so that an\n * `emit` on one pod fans out to subscribers on every pod. This is the runtime\n * glue behind `EventDef.useCache` — applications don't call it directly; the\n * server's `.events()` / `.websockets()` registration calls it at startup when\n * a cache (`CACHE_URL`) is configured. Without a cache, events stay in-process\n * and this is a no-op.\n *\n * Mirrors the auto-detection pattern used by `CacheTokenStore` for SSE tokens.\n */\n\nimport { logger } from '@spfn/core/logger';\nimport type { EventRouterBase } from './router';\nimport type { PubSubCache, SubscriptionMatch } from './types';\nimport { applyKeyClose } from './sse/key-close';\n\nconst transportLogger = logger.child('@spfn/core:event-transport');\n\n/** Default channel prefix; isolates SPFN event channels from other Redis keys. */\nconst DEFAULT_CHANNEL_PREFIX = 'spfn:sse:';\n\n/** Cap on the subscriber `quit()` during shutdown so a wedged socket can't stall it. */\nconst SUBSCRIBER_QUIT_TIMEOUT_MS = 5000;\n\n/** Cap a Redis PUBLISH so a wedged socket (half-open, no FIN) can't hang `emit` forever. */\nconst PUBLISH_TIMEOUT_MS = 5000;\n\n/** Cap a Redis SUBSCRIBE so a wedged socket can't hang server startup; degrades to in-process. */\nconst SUBSCRIBE_TIMEOUT_MS = 5000;\n\n/** Fold repeated transport warnings during a sustained outage into one line per window. */\nconst WARN_THROTTLE_MS = 30000;\n\n/**\n * After a publish failure, skip Redis entirely (deliver locally) for this long before\n * probing again. Without it, a streaming `emit` loop pays the full PUBLISH_TIMEOUT_MS per\n * chunk during a sustained outage (50 chunks × 5s = minutes of request hang).\n */\nconst PUBLISH_BREAKER_COOLDOWN_MS = 10000;\n\n/**\n * A key close the transport could not publish is re-sent after this long: just past the\n * breaker's cooldown, so the retry is the half-open probe rather than another skip.\n */\nconst KEY_CLOSE_RETRY_MS = PUBLISH_BREAKER_COOLDOWN_MS + 500;\n\n/** Cap on key closes waiting to be re-sent; past it the oldest is dropped. */\nconst PENDING_KEY_CLOSE_CAP = 1000;\n\n/**\n * Reject if `promise` doesn't settle within `ms`. ioredis has no command timeout by\n * default, so a half-open socket leaves publish/subscribe pending indefinitely; this\n * bounds it so the caller can fall back instead of hanging.\n */\nfunction withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>\n{\n return new Promise<T>((resolve, reject) =>\n {\n const timer = setTimeout(() =>\n {\n reject(new Error(`${label} timed out after ${ms}ms`));\n }, ms);\n timer.unref?.();\n\n promise.then(\n (value) =>\n {\n clearTimeout(timer);\n resolve(value);\n },\n (err) =>\n {\n clearTimeout(timer);\n reject(err);\n },\n );\n });\n}\n\nconst warnThrottle = new Map<string, { last: number; suppressed: number }>();\n\n/**\n * Warn at most once per {@link WARN_THROTTLE_MS} per key, folding the suppressed count\n * into the next line — a sustained Redis outage must not flood logs (one WARN per emit\n * × a fast stream = thousands/sec).\n */\nfunction throttledWarn(key: string, message: string, fields: Record<string, unknown>): void\n{\n const now = Date.now();\n const entry = warnThrottle.get(key);\n\n if (entry && now - entry.last < WARN_THROTTLE_MS)\n {\n entry.suppressed++;\n\n return;\n }\n\n transportLogger.warn(message, entry && entry.suppressed > 0\n ? { ...fields, suppressedSinceLast: entry.suppressed }\n : fields);\n warnThrottle.set(key, { last: now, suppressed: 0 });\n}\n\n/**\n * Forget every throttle stamp, so a test's warnings do not depend on the ones before it.\n *\n * @internal Exported for tests\n */\nexport function _resetWarnThrottle(): void\n{\n warnThrottle.clear();\n}\n\n/**\n * Minimal pub/sub client interface (compatible with ioredis Redis | Cluster).\n * A duplicated connection is required for SUBSCRIBE — ioredis puts a connection\n * into subscriber mode, where regular commands are rejected.\n */\ntype PubSubClient = {\n publish(channel: string, message: string): Promise<unknown>;\n duplicate(): PubSubClient;\n subscribe(...channels: string[]): Promise<unknown>;\n on(event: string, listener: (...args: any[]) => void): unknown;\n quit?(): Promise<unknown>;\n /** ioredis connection status ('ready' | 'connecting' | 'close' | ...). */\n status?: string;\n};\n\ntype MessageHandler = (message: unknown) => void | Promise<void>;\n\n/** The slice of EventDef this module wires (kept minimal + duck-typed). */\ntype WirableEvent = {\n name: string;\n useCache: (cache: PubSubCache) => Promise<unknown>;\n _resetCache?: () => void;\n};\n\n// ── globalThis singleton ──────────────────────────────────────────\n// Survives the CJS/ESM dual-package hazard (same pattern as cache-manager):\n// one subscriber connection and one wired-event registry per process. The\n// registry holds the EventDefs (not just names) so closeEventTransport can\n// reset their cache binding — otherwise the per-event cacheSubscribed latch\n// and this state would diverge across a restart in the same process.\ninterface TransportState\n{\n pubSubCache: PubSubCache | undefined;\n subscriber: PubSubClient | undefined;\n channelPrefix: string | undefined;\n wired: Map<string, WirableEvent>;\n\n /** Each Redis-backed cache's key closes waiting to be re-sent, so shutdown can drop them. */\n keyCloseDeferrals: Map<PubSubCache, KeyCloseDeferral>;\n}\n\nconst STATE_KEY = Symbol.for('@spfn/core:event-transport');\n\nconst state: TransportState = ((globalThis as any)[STATE_KEY] ??= {\n pubSubCache: undefined,\n subscriber: undefined,\n channelPrefix: undefined,\n wired: new Map<string, WirableEvent>(),\n keyCloseDeferrals: new Map<PubSubCache, KeyCloseDeferral>(),\n});\n\n// ── Deferred key closes ───────────────────────────────────────────\n// A `$control` close skipped by the open breaker, or whose publish failed, must\n// not be lost: no other pod has anything else that ends a revoked key's stream.\n\ninterface KeyCloseDeferral\n{\n /** Hold a close for re-sending (once per key id) and arm the retry. */\n defer(message: KeyCloseControlMessage): void;\n\n /** Re-send every held close through the cache's `publish`; one that fails again is held again. */\n flush(): Promise<void>;\n\n /** Drop every held close and the retry, for good. */\n dispose(): void;\n\n /** The held key ids, oldest first. */\n keyIds(): string[];\n}\n\nfunction createKeyCloseDeferral(resend: (message: KeyCloseControlMessage) => Promise<void>): KeyCloseDeferral\n{\n const pending = new Map<string, KeyCloseControlMessage>();\n let timer: ReturnType<typeof setTimeout> | undefined;\n let flushing = false;\n let disposed = false;\n\n const arm = (): void =>\n {\n if (timer || disposed)\n {\n return;\n }\n\n timer = setTimeout(() =>\n {\n timer = undefined;\n void flush();\n }, KEY_CLOSE_RETRY_MS);\n timer.unref?.();\n };\n\n const disarm = (): void =>\n {\n clearTimeout(timer);\n timer = undefined;\n };\n\n const flush = async (): Promise<void> =>\n {\n // A resend that succeeds flushes again: the flag keeps that from recursing.\n if (flushing || pending.size === 0)\n {\n return;\n }\n\n flushing = true;\n const snapshot = [...pending.values()];\n pending.clear();\n for (const message of snapshot)\n {\n await resend(message);\n }\n flushing = false;\n\n if (pending.size > 0)\n {\n arm();\n }\n else\n {\n disarm();\n }\n };\n\n return {\n defer: (message) =>\n {\n if (disposed)\n {\n return;\n }\n\n pending.delete(message.keyId);\n pending.set(message.keyId, message);\n dropOldestPastCap(pending);\n throttledWarn('key-close-deferred', 'SSE key close deferred — re-sent when the pub/sub transport recovers', {\n channel: CONTROL_CHANNEL,\n pending: pending.size,\n });\n arm();\n },\n flush,\n dispose: () =>\n {\n disposed = true;\n disarm();\n pending.clear();\n },\n keyIds: () => [...pending.keys()],\n };\n}\n\nfunction dropOldestPastCap(pending: Map<string, KeyCloseControlMessage>): void\n{\n const dropped = pending.size - PENDING_KEY_CLOSE_CAP;\n if (dropped <= 0)\n {\n return;\n }\n\n [...pending.keys()].slice(0, dropped).forEach(keyId => pending.delete(keyId));\n throttledWarn('key-close-dropped', 'SSE key closes dropped — too many waiting to be re-sent', { dropped });\n}\n\n/**\n * The key ids a cache holds for re-sending, oldest first.\n *\n * @internal Exported for tests\n */\nexport function _pendingKeyCloses(cache: PubSubCache): string[]\n{\n return state.keyCloseDeferrals.get(cache)?.keyIds() ?? [];\n}\n\n/**\n * Resolve the channel prefix: explicit option → env override → default.\n */\nfunction resolveChannelPrefix(override?: string): string\n{\n return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;\n}\n\n/**\n * Build a {@link PubSubCache} backed by a Redis/Valkey client.\n *\n * One duplicated connection is created lazily and shared across every channel.\n * Publish failures are logged and swallowed — SSE is lossy (at-most-once), so a\n * dropped event during a Redis blip must never crash `emit` or kill a stream.\n * The warning names the channel alone, nothing derived from the error: a client's\n * error can echo the command, and with it any published payload (an event's as\n * much as a control message's).\n */\nexport function createRedisPubSubCache(\n client: PubSubClient,\n channelPrefix: string,\n onSubscriber?: (connection: PubSubClient) => void,\n): PubSubCache\n{\n let subscriber: PubSubClient | undefined;\n const handlers = new Map<string, MessageHandler>();\n // Circuit breaker: epoch-ms until which Redis is treated as down (skip publish).\n let degradedUntil = 0;\n\n const ensureSubscriber = (): PubSubClient =>\n {\n if (subscriber)\n {\n return subscriber;\n }\n\n // ioredis re-issues SUBSCRIBE after a reconnect (autoResubscribe, default\n // true). Don't pass it as a duplicate() option — Cluster.duplicate()'s\n // first positional arg is the startup-nodes array, so an options object\n // lands in the wrong slot and is silently dropped. The 'message' listener\n // is attached once and survives reconnects.\n subscriber = client.duplicate();\n onSubscriber?.(subscriber);\n\n subscriber.on('message', (channel: string, raw: string) =>\n {\n const handler = handlers.get(channel);\n if (!handler)\n {\n return;\n }\n\n let payload: unknown;\n try\n {\n payload = JSON.parse(raw);\n }\n catch\n {\n transportLogger.warn('Pub/sub message parse failed — dropped', { channel });\n\n return;\n }\n\n void handler(payload);\n });\n\n // ioredis auto-reconnects (and re-subscribes via autoResubscribe above);\n // surface the blip, throttled so a sustained outage's reconnect loop can't\n // flood logs.\n subscriber.on('error', (err: Error) =>\n {\n throttledWarn('subscriber-error', 'Pub/sub subscriber error', { error: err.message });\n });\n\n return subscriber;\n };\n\n // Deliver to this pod's own subscribers without the Redis round-trip — the\n // fallback when publish can't reach Redis. Mostly avoids double-delivery (a\n // failed publish usually means Redis never echoed back), but a reply-lost\n // race — Redis broadcast, then the publisher's reply dropped — can deliver\n // both here and via the echo. We accept that rare duplicate over dropping,\n // since SSE is lossy and a frozen stream is worse than a repeated chunk.\n // Same-pod subscribers are fed ONLY by the echo on the subscriber connection\n // (a separate socket from the write client). If it isn't connected, a successful\n // publish still won't reach this pod's own streams — so they need the local\n // fallback. A status-less subscriber (test mocks) is treated as healthy.\n const subscriberHealthy = (): boolean =>\n !!subscriber && (subscriber.status === undefined || subscriber.status === 'ready');\n\n const deliverLocal = (channel: string, message: unknown): void =>\n {\n const handler = handlers.get(channel);\n if (handler)\n {\n void handler(message);\n }\n };\n\n // Set below, once `cache` exists: the deferral re-sends through its `publish`.\n let keyCloses: KeyCloseDeferral;\n\n // A key close Redis did not take is held for re-sending; every other message stays dropped (lossy).\n const deferIfKeyClose = (name: string, message: unknown): void =>\n {\n if (name === CONTROL_CHANNEL && isKeyCloseMessage(message))\n {\n keyCloses.defer(message);\n }\n };\n\n const cache: PubSubCache = {\n publish: async (name: string, message: unknown): Promise<void> =>\n {\n const channel = channelPrefix + name;\n\n // `string | undefined`: JSON.stringify returns undefined (no throw) for a\n // top-level function/symbol, and throws for bigint/circular. Both mean\n // \"can't cross Redis\" — handle each as a local-only delivery.\n let serialized: string | undefined;\n try\n {\n // `?? null` so void-payload events serialize to \"null\" rather than\n // `undefined` (JSON.stringify(undefined) → undefined → publish breaks).\n serialized = JSON.stringify(message ?? null);\n }\n catch (err)\n {\n // Non-serializable payload (bigint, circular ref). Cross-pod is\n // impossible; deliver the live object locally (best effort — a\n // downstream JSON.stringify may re-throw, but better than dropping).\n transportLogger.warn('Pub/sub payload serialization failed — local delivery only', {\n channel,\n error: err instanceof Error ? err.message : String(err),\n });\n deliverLocal(channel, message ?? null);\n\n return;\n }\n\n if (serialized === undefined)\n {\n // Top-level function/symbol payload — JSON.stringify yields undefined.\n // Publishing that is meaningless and re-parsing it later would throw,\n // so deliver locally and stop (guards the \"publish never kills emit\").\n transportLogger.warn('Pub/sub payload not serializable (function/symbol) — local delivery only', {\n channel,\n });\n deliverLocal(channel, message ?? null);\n\n return;\n }\n\n // Skip Redis when the breaker is open (recent failure) or the client isn't\n // connected. This (a) bounds a sustained outage to one timeout per cooldown\n // instead of one per emit, and (b) avoids ioredis's offline queue buffering\n // the publish and re-sending it on reconnect — which would deliver the event\n // twice (local fallback now + echo later). Treat a status-less client (test\n // mocks) as ready so behavior is unchanged there.\n const ready = client.status === undefined || client.status === 'ready';\n const now = Date.now();\n if (!ready || (degradedUntil > 0 && now < degradedUntil))\n {\n // Not connected, or breaker open → skip Redis, deliver locally.\n deferIfKeyClose(name, message);\n deliverLocal(channel, JSON.parse(serialized));\n\n return;\n }\n\n // Healthy (degradedUntil === 0) → publish directly; concurrent emits all\n // fan out. Half-open (breaker tripped but cooldown elapsed) → this emit is\n // the single probe: arm the breaker for the probe's duration so CONCURRENT\n // emits fast-path to local instead of each launching their own 5s probe.\n if (degradedUntil > 0)\n {\n degradedUntil = now + PUBLISH_TIMEOUT_MS;\n }\n\n try\n {\n // Timeout-bound: a wedged socket would otherwise leave this pending\n // forever and hang `emit` (and the request that called it).\n await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, 'pub/sub publish');\n degradedUntil = 0; // success → close the breaker\n void keyCloses.flush(); // Redis is back: re-send the key closes it missed\n\n // Publish reached Redis (remote pods get it via their echo), but if THIS\n // pod's subscriber socket is down its echo won't arrive — cover same-pod\n // subscribers locally. No double-delivery: Redis doesn't buffer for a\n // disconnected subscriber, so the missed echo never arrives later.\n if (!subscriberHealthy())\n {\n throttledWarn('subscriber-down', 'Subscriber connection down — same-pod local fallback', {\n channel,\n });\n deliverLocal(channel, JSON.parse(serialized));\n }\n }\n catch\n {\n // Redis blip/timeout: open the breaker so subsequent emits fast-path to\n // local delivery instead of each paying the timeout. The cross-pod echo\n // won't arrive, so deliver to this pod's subscribers directly. Parse the\n // serialized copy so handlers see the SAME shape as the echo path (a JSON\n // round-trip), never the live mutable object. Throttled WARN — a sustained\n // outage emits one line per window, not one per chunk; the channel alone\n // (see the JSDoc above). Keyed by channel, so one channel's failure never\n // silences another's — a key close on `$control` behind an event's.\n degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;\n throttledWarn(`publish-failed:${channel}`, 'Pub/sub publish failed — local delivery only', { channel });\n deferIfKeyClose(name, message);\n deliverLocal(channel, JSON.parse(serialized));\n }\n },\n\n subscribe: async (name: string, handler: MessageHandler): Promise<void> =>\n {\n const channel = channelPrefix + name;\n const sub = ensureSubscriber();\n\n try\n {\n // Timeout-bound: at startup this is awaited before the HTTP listener,\n // so a wedged subscriber would otherwise hang the entire boot.\n await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, 'pub/sub subscribe');\n }\n catch (err)\n {\n // SUBSCRIBE failed. If nothing else is live on this connection it\n // may be half-open — drop the memo (and quit it) so a later retry\n // rebuilds a fresh subscriber instead of reusing a dead one.\n if (handlers.size === 0 && subscriber === sub)\n {\n subscriber = undefined;\n void sub.quit?.().catch(() => undefined);\n }\n\n throw err;\n }\n\n // Register only after a confirmed subscribe — never leave a handler\n // bound to a channel the broker hasn't acknowledged.\n handlers.set(channel, handler);\n },\n };\n\n keyCloses = createKeyCloseDeferral(message => cache.publish(CONTROL_CHANNEL, message));\n state.keyCloseDeferrals.set(cache, keyCloses);\n\n return cache;\n}\n\n// ── Subscription control channel (grant / revoke / key close across pods) ─────\n\n/**\n * The channel `grant` / `revoke` and key closes travel on, published through the same\n * {@link PubSubCache} as events (so `<prefix>$control` on Redis — same timeouts,\n * breaker and warn throttling, no second connection). Event channels are\n * `<prefix><event name>`; an event named `control` would collide with a bare\n * `control`, and `$` never appears in a dotted event name, so it can't.\n */\nconst CONTROL_CHANNEL = '$control';\n\n/** What one `grant` / `revoke` publishes. */\nexport interface SubscriptionControlMessage\n{\n kind: 'grant' | 'revoke';\n\n /** The event's wire name (`defineEvent(name)`) — router keys differ per pod's router. */\n event: string;\n\n subject: string;\n\n /** Which of the subject's connections it reaches; absent → every one. */\n match?: SubscriptionMatch;\n\n /** The publishing binding, so a pod skips its own echo (it applied the change locally first). */\n origin: string;\n}\n\n/** What one key close publishes: close the SSE streams that device key opened. */\nexport interface KeyCloseControlMessage\n{\n kind: 'close';\n keyId: string;\n\n /** The publishing binding, so a pod skips its own echo (it closed locally first). */\n origin: string;\n}\n\n/** Everything the control channel carries. */\nexport type ControlMessage = SubscriptionControlMessage | KeyCloseControlMessage;\n\n/** Applies one control message to the local registries of one event. */\nexport type SubscriptionControlApply = (\n kind: SubscriptionControlMessage['kind'],\n subject: string,\n match?: SubscriptionMatch,\n) => void;\n\ninterface ControlBinding\n{\n origin: string;\n events: Map<string, Set<SubscriptionControlApply>>;\n subscribing: Promise<void> | undefined;\n}\n\n/** One binding per cache: in production one per process, in tests one per simulated pod. */\nconst controlBindings = new WeakMap<PubSubCache, ControlBinding>();\n\nfunction ensureControlBinding(cache: PubSubCache): ControlBinding\n{\n let binding = controlBindings.get(cache);\n if (!binding)\n {\n binding = {\n origin: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,\n events: new Map(),\n subscribing: undefined,\n };\n controlBindings.set(cache, binding);\n }\n\n return binding;\n}\n\nfunction isControlMessage(message: unknown): message is ControlMessage\n{\n return isSubscriptionControlMessage(message) || isKeyCloseMessage(message);\n}\n\nfunction isKeyCloseMessage(message: unknown): message is KeyCloseControlMessage\n{\n const candidate = message as Partial<KeyCloseControlMessage> | null;\n\n return !!candidate\n && candidate.kind === 'close'\n && typeof candidate.keyId === 'string'\n && candidate.keyId !== ''\n && typeof candidate.origin === 'string';\n}\n\nfunction isSubscriptionControlMessage(message: unknown): message is SubscriptionControlMessage\n{\n const candidate = message as Partial<SubscriptionControlMessage> | null;\n\n return !!candidate\n && (candidate.kind === 'grant' || candidate.kind === 'revoke')\n && typeof candidate.event === 'string'\n && typeof candidate.subject === 'string'\n && typeof candidate.origin === 'string'\n && (candidate.match === undefined || isSubscriptionMatch(candidate.match));\n}\n\n/**\n * `{ query: { [field]: string } }` with at least one field, and nothing looser:\n * a bad match must not widen a grant, and an empty `query` matches every\n * connection of the subject.\n */\nexport function isSubscriptionMatch(match: unknown): match is SubscriptionMatch\n{\n const query = (match as Partial<SubscriptionMatch> | null)?.query;\n\n return typeof match === 'object'\n && typeof query === 'object'\n && query !== null\n && !Array.isArray(query)\n && Object.keys(query).length > 0\n && Object.values(query).every(value => typeof value === 'string');\n}\n\nfunction dispatchControl(binding: ControlBinding, message: unknown): void\n{\n if (!isControlMessage(message))\n {\n transportLogger.debug('Malformed subscription control message ignored');\n\n return;\n }\n\n if (message.origin === binding.origin)\n {\n return;\n }\n\n // Not an event: a key close reaches every handler of this pod, whatever events it serves.\n if (message.kind === 'close')\n {\n applyKeyClose(message.keyId);\n\n return;\n }\n\n const appliers = binding.events.get(message.event);\n if (!appliers)\n {\n transportLogger.debug('Subscription control for an event this pod does not serve ignored', {\n event: message.event,\n });\n\n return;\n }\n\n appliers.forEach(apply => apply(message.kind, message.subject, message.match));\n}\n\n/**\n * Let `grant` / `revoke` published by other pods reach an event on this pod.\n *\n * Called by `EventDef.useCache`. Subscribes the control channel once per cache;\n * a failed subscribe is warned and retried by the next event's bind — inbound\n * control then stays local until it succeeds, never failing `useCache`.\n */\nexport async function bindSubscriptionControl(\n cache: PubSubCache,\n eventName: string,\n apply: SubscriptionControlApply,\n): Promise<void>\n{\n const binding = ensureControlBinding(cache);\n const appliers = binding.events.get(eventName) ?? new Set<SubscriptionControlApply>();\n appliers.add(apply);\n binding.events.set(eventName, appliers);\n\n binding.subscribing ??= cache\n .subscribe(CONTROL_CHANNEL, (message) => dispatchControl(binding, message))\n .catch((err: unknown) =>\n {\n binding.subscribing = undefined;\n throttledWarn('control-subscribe-failed', 'Subscription control subscribe failed — grant/revoke from other pods not received', {\n error: err instanceof Error ? err.message : String(err),\n });\n });\n\n await binding.subscribing;\n}\n\n/**\n * Tell every other pod about a `grant` / `revoke` this pod already applied.\n * A failure is warned (throttled) and swallowed: the change stays local, and\n * the next connect re-reads permission anyway.\n */\nexport async function publishSubscriptionControl(\n cache: PubSubCache,\n kind: SubscriptionControlMessage['kind'],\n eventName: string,\n subject: string,\n match?: SubscriptionMatch,\n): Promise<void>\n{\n const message: SubscriptionControlMessage = {\n kind,\n event: eventName,\n subject,\n ...(match ? { match } : {}),\n origin: ensureControlBinding(cache).origin,\n };\n\n await cache.publish(CONTROL_CHANNEL, message).catch(() =>\n {\n // Nothing from the error: an app-supplied cache's error can echo the command, and with it the subject.\n throttledWarn('control-publish-failed', 'Subscription control publish failed — applied on this pod only', {\n event: eventName,\n });\n });\n}\n\n/**\n * Tell every other pod to close the SSE streams of a device key this pod\n * already closed locally. A failure is warned (throttled) and swallowed. On a\n * cache from {@link createRedisPubSubCache}, a close Redis did not take is held\n * and re-sent after the breaker cooldown or with the next successful publish;\n * on any other cache it stays local. Neither the key id nor the error's subject is logged.\n */\nexport async function publishKeyClose(cache: PubSubCache, keyId: string): Promise<void>\n{\n const message: KeyCloseControlMessage = { kind: 'close', keyId, origin: ensureControlBinding(cache).origin };\n\n await cache.publish(CONTROL_CHANNEL, message).catch(() =>\n {\n // Nothing from the error: its message and name can both echo the command, and with it the key id.\n throttledWarn('key-close-publish-failed', 'SSE key close publish failed — closed on this pod only', {\n channel: CONTROL_CHANNEL,\n });\n });\n}\n\n/**\n * The pub/sub cache the event transport publishes on: the one wiring already\n * resolved, else resolved now from `@spfn/core/cache` (`getCache()`); undefined\n * without a cache. For a publisher that may run on a pod serving no events.\n */\nexport async function resolveEventPubSubCache(): Promise<PubSubCache | undefined>\n{\n return state.pubSubCache ?? await resolvePubSubCache();\n}\n\n/**\n * Options controlling event cache transport wiring.\n */\nexport interface WireEventCacheOptions\n{\n /**\n * Force in-process mode even when a cache is configured.\n * @default true (auto: Redis when CACHE_URL is set, else in-process)\n */\n multiInstance?: boolean;\n\n /**\n * Channel prefix override (env `SPFN_SSE_CHANNEL_PREFIX`, else `spfn:sse:`).\n * Use distinct prefixes to isolate apps/tenants sharing one Redis.\n */\n channelPrefix?: string;\n}\n\n/**\n * Wire every event in a router to the shared Redis pub/sub cache.\n *\n * Returns the transport that ended up active. Idempotent per event name across\n * routers (an event shared by the SSE and WS routers is wired once), so it is\n * safe to call for both `.events()` and `.websockets()`.\n */\nexport async function wireEventRouterCache(\n router: EventRouterBase<any>,\n options: WireEventCacheOptions = {},\n): Promise<'in-process' | 'redis'>\n{\n // Logged at INFO (not debug): operators must be able to confirm from prod logs\n // whether cross-pod fan-out is actually live, and whether any event degraded.\n if (options.multiInstance === false)\n {\n // A sibling router may have already wired shared events to Redis — this flag\n // is process-global and can't un-wire them, so be honest about that.\n const events = router.events as Record<string, WirableEvent>;\n const stillRedis = Object.keys(events).filter(k => state.wired.has(events[k].name)).length;\n\n if (stillRedis > 0)\n {\n transportLogger.warn('multiInstance:false has no effect on events already wired to redis by another router', {\n redisWired: stillRedis,\n });\n }\n else\n {\n transportLogger.info('Event transport: in-process (multiInstance disabled)');\n }\n\n return 'in-process';\n }\n\n const pubSubCache = await resolvePubSubCache(options.channelPrefix);\n if (!pubSubCache)\n {\n transportLogger.info('Event transport: in-process (no cache configured)');\n\n return 'in-process';\n }\n\n const events = router.events as Record<string, WirableEvent>;\n let wired = 0;\n let degraded = 0;\n let alreadyWired = 0;\n\n for (const key of Object.keys(events))\n {\n const event = events[key];\n if (state.wired.has(event.name))\n {\n alreadyWired++; // e.g. an event shared by the SSE and WS routers\n continue;\n }\n\n try\n {\n await event.useCache(pubSubCache);\n state.wired.set(event.name, event);\n wired++;\n }\n catch (err)\n {\n // Degrade this event to in-process rather than aborting startup — a\n // transient SUBSCRIBE failure must not crash the pod. Not marked\n // wired, so a later wiring pass can retry it.\n degraded++;\n transportLogger.warn('Event cache wiring failed — staying in-process for this event', {\n event: event.name,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n // Every event degraded (and none were already wired) → fan-out is effectively\n // dead for this router; report in-process honestly instead of a false 'redis'.\n if (wired + alreadyWired === 0)\n {\n transportLogger.warn('Event transport: redis configured but no event wired — running in-process', {\n total: Object.keys(events).length,\n degraded,\n });\n\n return 'in-process';\n }\n\n transportLogger.info('Event transport: redis (cross-pod fan-out)', {\n total: Object.keys(events).length,\n wired,\n degraded,\n alreadyWired,\n });\n\n return 'redis';\n}\n\n/**\n * Resolve (and cache) the shared pub/sub cache from the global cache instance.\n * Returns undefined when no cache is available.\n */\nasync function resolvePubSubCache(channelPrefix?: string): Promise<PubSubCache | undefined>\n{\n const resolvedPrefix = resolveChannelPrefix(channelPrefix);\n\n if (state.pubSubCache)\n {\n // The transport (and its prefix) is process-global, resolved once on the\n // first wiring. Warn if a later router (.websockets() after .events())\n // asks for a different prefix — it's silently ignored, not applied.\n if (state.channelPrefix !== resolvedPrefix)\n {\n transportLogger.warn('Conflicting channelPrefix ignored — the transport prefix is process-global (first wiring wins).', {\n active: state.channelPrefix,\n ignored: resolvedPrefix,\n });\n }\n\n return state.pubSubCache;\n }\n\n let client: PubSubClient | undefined;\n try\n {\n const { getCache } = await import('@spfn/core/cache');\n client = getCache() as PubSubClient | undefined;\n }\n catch\n {\n // Cache module unavailable — fall back to in-process.\n return undefined;\n }\n\n if (!client)\n {\n return undefined;\n }\n\n // Cross-app isolation depends on the channel prefix. The default is shared by\n // every SPFN app, so warn (once, on first resolve) when running on it — apps\n // sharing one Redis with colliding event names would otherwise leak payloads.\n if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX)\n {\n transportLogger.warn(\n 'Event fan-out is on the default channel prefix (spfn:sse:). Apps sharing one '\n + 'Redis must set channelPrefix or SPFN_SSE_CHANNEL_PREFIX to avoid cross-app leakage.',\n );\n }\n\n state.channelPrefix = resolvedPrefix;\n state.pubSubCache = createRedisPubSubCache(\n client,\n resolvedPrefix,\n (connection) =>\n {\n state.subscriber = connection;\n },\n );\n\n return state.pubSubCache;\n}\n\n/**\n * Close the shared pub/sub subscriber connection and reset transport state.\n *\n * Called during graceful shutdown — the subscriber is a `duplicate()` of the\n * cache write connection and is not tracked by the cache manager, so it must be\n * quit explicitly (before `closeCache`). No-op when no subscriber was opened.\n *\n * Also resets each wired EventDef's cache binding. EventDefs are module\n * singletons that outlive a server instance; without this, a second server start\n * in the same process (tests, hot-reload) hits the `cacheSubscribed` latch, never\n * rebinds, and silently runs with cross-pod fan-out dead.\n */\nexport async function closeEventTransport(): Promise<void>\n{\n const subscriber = state.subscriber;\n\n for (const event of state.wired.values())\n {\n event._resetCache?.();\n }\n\n // A held close would otherwise be re-sent by a transport that is shutting down.\n state.keyCloseDeferrals.forEach(deferral => deferral.dispose());\n state.keyCloseDeferrals.clear();\n\n state.subscriber = undefined;\n state.pubSubCache = undefined;\n state.channelPrefix = undefined;\n state.wired.clear();\n\n if (!subscriber?.quit)\n {\n return;\n }\n\n // Bound the quit: a wedged connection (broken socket, no FIN) must not stall\n // shutdown past this timeout — the process exits and the OS reclaims the socket.\n await Promise.race([\n subscriber.quit().catch(() => undefined),\n new Promise<void>((resolve) =>\n {\n const timer = setTimeout(resolve, SUBSCRIBER_QUIT_TIMEOUT_MS);\n timer.unref?.();\n }),\n ]);\n}\n","/**\n * Event System\n *\n * Decoupled pub/sub event system with optional cache integration for multi-instance support\n *\n * @example\n * ```typescript\n * // Define event\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe (in-memory)\n * userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit\n * await userCreated.emit({ userId: '123' });\n *\n * // With cache for multi-instance\n * const event = defineEvent('user.created', schema);\n * await event.useCache(cache); // Must await before emitting\n * await event.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\n\nimport type { TSchema, Static } from '@sinclair/typebox';\nimport { logger } from '@spfn/core/logger';\nimport { bindSubscriptionControl, isSubscriptionMatch, publishSubscriptionControl } from './cache-transport';\nimport type {\n EventContract,\n EventDef,\n EventHandler,\n EventSubscribeOptions,\n FramePredicate,\n InitialProducer,\n JobQueueSender,\n PubSubCache,\n SubscribePredicate,\n SubscriptionControl,\n SubscriptionMatch,\n} from './types';\n\nconst eventLogger = logger.child('@spfn/core:event');\n\n/**\n * Log handler error with consistent format\n */\nfunction logHandlerError(eventName: string, error: unknown): void\n{\n eventLogger.error(`Event handler error: ${eventName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Log job queue error with consistent format\n */\nfunction logJobQueueError(queueName: string, error: unknown): void\n{\n eventLogger.error(`Failed to send event to job queue: ${queueName}`, {\n error: error instanceof Error ? error.message : String(error),\n });\n}\n\n/**\n * Create handler subscription manager\n */\nfunction createHandlerManager<TPayload>(name: string)\n{\n const handlers: Set<EventHandler<TPayload>> = new Set();\n\n return {\n add: (handler: EventHandler<TPayload>): (() => void) =>\n {\n handlers.add(handler);\n eventLogger.debug(`Subscribed to event: ${name}`, { handlerCount: handlers.size });\n\n return () =>\n {\n handlers.delete(handler);\n eventLogger.debug(`Unsubscribed from event: ${name}`, { handlerCount: handlers.size });\n };\n },\n\n clear: (): void =>\n {\n handlers.clear();\n eventLogger.debug(`Unsubscribed all from event: ${name}`);\n },\n\n trigger: async (payload: TPayload): Promise<void> =>\n {\n const results = await Promise.allSettled(\n [...handlers].map((handler) => handler(payload)),\n );\n\n for (const result of results)\n {\n if (result.status === 'rejected')\n {\n logHandlerError(name, result.reason);\n }\n }\n },\n };\n}\n\n/**\n * Create job queue manager\n */\nfunction createJobQueueManager(name: string)\n{\n const jobQueues: Map<string, JobQueueSender> = new Map();\n\n return {\n register: (queueName: string, sender: JobQueueSender): void =>\n {\n jobQueues.set(queueName, sender);\n eventLogger.debug(`Registered job queue for event: ${name}`, { queueName });\n },\n\n send: async (payload: unknown): Promise<void> =>\n {\n if (jobQueues.size === 0)\n {\n return;\n }\n\n const entries = [...jobQueues.entries()];\n const results = await Promise.allSettled(\n entries.map(([queueName, sender]) => sender(queueName, payload)),\n );\n\n for (const [i, result] of results.entries())\n {\n if (result.status === 'rejected')\n {\n logJobQueueError(entries[i][0], result.reason);\n }\n }\n },\n\n get size(): number\n {\n return jobQueues.size;\n },\n };\n}\n\n/**\n * Create the registry of SSE handlers' subscription controls for one event.\n *\n * `grant` / `revoke` apply to every registered control (an event may sit under\n * two router keys, or in two handlers in one process), then — when the event is\n * bound to a cache — publish once so every other pod applies it too.\n */\nfunction createSubscriptionControls(name: string, getCache: () => PubSubCache | undefined)\n{\n const controls: Set<SubscriptionControl> = new Set();\n\n const applyLocal = (kind: 'grant' | 'revoke', subject: string, match?: SubscriptionMatch): void =>\n {\n controls.forEach(control => control[kind](subject, match));\n };\n\n const change = (kind: 'grant' | 'revoke') => async (subject: string, match?: SubscriptionMatch): Promise<void> =>\n {\n assertMatch(name, kind, match);\n applyLocal(kind, subject, match);\n\n const cache = getCache();\n if (cache)\n {\n await publishSubscriptionControl(cache, kind, name, subject, match);\n }\n };\n\n return {\n applyLocal,\n grant: change('grant'),\n revoke: change('revoke'),\n register: (control: SubscriptionControl): (() => void) =>\n {\n controls.add(control);\n\n return () => controls.delete(control);\n },\n };\n}\n\n/**\n * Refuse a `match` before it is applied or published. JSON drops an\n * `undefined` field, so `{ workspaceId: undefined }` would open nothing here\n * and every connection of the subject on every other pod; an empty `query`\n * would open every connection everywhere.\n */\nfunction assertMatch(name: string, kind: 'grant' | 'revoke', match: SubscriptionMatch | undefined): void\n{\n if (match !== undefined && !isSubscriptionMatch(match))\n {\n throw new Error(\n `Event \"${name}\" .${kind}() refused its match: match.query must name at least one field, each with a string value. `\n + 'A match only ever narrows a grant or revoke to the connections that declared those values; '\n + 'omit match to reach every connection of the subject.',\n );\n }\n}\n\n/**\n * Throw when a set-once declaration of a shared event is made a second time.\n */\nfunction assertDeclaredOnce(name: string, method: string, declared: boolean): void\n{\n if (declared)\n {\n throw new Error(\n `Event \"${name}\" already declares .${method}(). `\n + 'An event is defined once and shared, so a second declaration would silently replace the first. '\n + `Declare .${method}() once, where the event is defined.`,\n );\n }\n}\n\n/**\n * Internal: Create event implementation\n */\nfunction createEventImpl<TPayload>(\n name: string,\n schema?: TSchema,\n): EventDef<TPayload>\n{\n const handlerManager = createHandlerManager<TPayload>(name);\n // `localOnly` handlers: run by this process's emit, never by a cache delivery.\n const localHandlerManager = createHandlerManager<TPayload>(name);\n const jobQueueManager = createJobQueueManager(name);\n let cache: PubSubCache | undefined;\n let cacheSubscribed = false;\n const subscriptionControls = createSubscriptionControls(name, () => cache);\n\n const emit = async (payload?: TPayload): Promise<void> =>\n {\n eventLogger.debug(`Emitting event: ${name}`, {\n payload,\n hasCache: !!cache,\n jobQueueCount: jobQueueManager.size,\n });\n\n if (cache)\n {\n await cache.publish(name, payload);\n }\n else\n {\n await handlerManager.trigger(payload as TPayload);\n }\n\n await localHandlerManager.trigger(payload as TPayload);\n\n await jobQueueManager.send(payload);\n eventLogger.debug(`Event emitted: ${name}`);\n };\n\n const useCache = async (newCache: PubSubCache): Promise<EventDef<TPayload>> =>\n {\n if (cacheSubscribed)\n {\n eventLogger.warn(`Cache already configured for event: ${name}`);\n\n return self;\n }\n\n // Subscribe FIRST, then flip to cache mode. If subscribe rejects, the\n // event stays in-process (cache undefined) and cacheSubscribed stays\n // false — so emit keeps delivering locally and a later useCache can retry.\n // Setting these before the await would latch a half-broken state: emit\n // would publish to a channel this instance never subscribed to.\n // A delivery from the cache — this pod's own emit echoed back, or another\n // pod's — reaches the plain handlers only; `localOnly` ones ran in emit.\n await newCache.subscribe(name, async (message: unknown) =>\n {\n eventLogger.debug(`Received event from cache: ${name}`);\n await handlerManager.trigger(message as TPayload);\n });\n\n cache = newCache;\n cacheSubscribed = true;\n\n await bindSubscriptionControl(newCache, name, subscriptionControls.applyLocal);\n\n eventLogger.debug(`Cache subscription ready for event: ${name}`);\n\n return self;\n };\n\n // Drop the cache binding so a later useCache can rebind a fresh transport.\n // Without this, the cacheSubscribed latch would make a second server start in\n // the same process (tests, hot-reload) a no-op while the event still points at\n // a torn-down cache — cross-pod fan-out would silently stay dead.\n const resetCache = (): void =>\n {\n cache = undefined;\n cacheSubscribed = false;\n };\n\n let contract: EventContract | undefined;\n\n const declareContract = (next: EventContract): EventDef<TPayload> =>\n {\n if (contract)\n {\n throw new Error(\n `Event \"${name}\" already declares .contract({ since: '${contract.since}' }). `\n + 'An event is defined once and shared, so a second declaration would silently replace one of the '\n + 'two. Declare the contract once, where the event is defined.',\n );\n }\n\n contract = next;\n\n return self;\n };\n\n let canSubscribe: SubscribePredicate<any> | undefined;\n let filter: FramePredicate<TPayload> | undefined;\n let initial: InitialProducer<TPayload, any> | undefined;\n\n const declareCanSubscribe = <P>(predicate: SubscribePredicate<P>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'canSubscribe', !!canSubscribe);\n canSubscribe = predicate;\n\n return self;\n };\n\n const declareFilter = (predicate: FramePredicate<TPayload>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'filter', !!filter);\n filter = predicate;\n\n return self;\n };\n\n const declareInitial = <P>(producer: InitialProducer<TPayload, P>): EventDef<TPayload> =>\n {\n assertDeclaredOnce(name, 'initial', !!initial);\n initial = producer;\n\n return self;\n };\n\n const subscribe = (handler: EventHandler<TPayload>, options?: EventSubscribeOptions): (() => void) =>\n (options?.localOnly ? localHandlerManager : handlerManager).add(handler);\n\n const unsubscribeAll = (): void =>\n {\n handlerManager.clear();\n localHandlerManager.clear();\n };\n\n const self: EventDef<TPayload> = {\n name,\n schema,\n get _contract()\n {\n return contract;\n },\n contract: declareContract,\n get _canSubscribe()\n {\n return canSubscribe;\n },\n canSubscribe: declareCanSubscribe,\n get _filter()\n {\n return filter;\n },\n filter: declareFilter,\n get _initial()\n {\n return initial;\n },\n initial: declareInitial,\n grant: subscriptionControls.grant,\n revoke: subscriptionControls.revoke,\n _registerSubscriptionControl: subscriptionControls.register,\n subscribe,\n unsubscribeAll,\n emit: emit as EventDef<TPayload>['emit'],\n useCache,\n _registerJobQueue: jobQueueManager.register,\n _resetCache: resetCache,\n _payload: undefined as unknown as TPayload,\n };\n\n return self;\n}\n\n/**\n * Define an event without payload\n */\nexport function defineEvent(name: string): EventDef<void>;\n\n/**\n * Define an event with typed payload\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema: T,\n): EventDef<Static<T>>;\n\n/**\n * Define an event for decoupled pub/sub\n *\n * @example\n * ```typescript\n * // Define event with payload\n * export const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * // Subscribe to event (in-memory)\n * const unsubscribe = userCreated.subscribe((payload) => {\n * console.log('User created:', payload.userId);\n * });\n *\n * // Emit event\n * await userCreated.emit({ userId: '123' });\n *\n * // Unsubscribe when done\n * unsubscribe();\n *\n * // Multi-instance with cache\n * await userCreated.useCache(cache);\n * await userCreated.emit({ userId: '123' }); // Broadcast to all instances\n * ```\n */\nexport function defineEvent<T extends TSchema>(\n name: string,\n schema?: T,\n): EventDef<Static<T>> | EventDef\n{\n if (schema)\n {\n return createEventImpl<Static<T>>(name, schema);\n }\n\n return createEventImpl<void>(name);\n}\n","/**\n * SSE Event Route Map\n *\n * Static route map for SSE token endpoint.\n * Merge into RPC proxy routeMap so `eventsToken` resolves to `POST /events/token`.\n *\n * @example\n * ```typescript\n * // app/api/rpc/[routeName]/route.ts\n * import { createRpcProxy } from '@spfn/core/nextjs/server';\n * import { eventRouteMap } from '@spfn/core/event';\n * import { routeMap } from '@/generated/route-map';\n *\n * export const { GET, POST } = createRpcProxy({\n * routeMap: { ...routeMap, ...eventRouteMap },\n * });\n * ```\n */\nexport const eventRouteMap = {\n eventsToken: { method: 'POST' as const, path: '/events/token' },\n};\n","/**\n * Event Router\n *\n * Type-safe event router for SSE subscription\n *\n * @example\n * ```typescript\n * import { defineEvent, defineEventRouter } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userCreated = defineEvent('user.created', Type.Object({\n * userId: Type.String(),\n * }));\n *\n * const orderPlaced = defineEvent('order.placed', Type.Object({\n * orderId: Type.String(),\n * amount: Type.Number(),\n * }));\n *\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * export type EventRouter = typeof eventRouter;\n * ```\n */\n\nimport type { EventDef } from './types';\n\n/**\n * How a client reaches the contracted events of a router.\n *\n * Declared on the router because it is the stream's promise, not one event's:\n * `createServer` checks it against `.events()` at boot, so the document cannot\n * describe a path or an auth mode the server does not serve.\n */\nexport interface EventRouterContract\n{\n /**\n * `'tokenExchange'` when `.events()` enables `auth` — the client first posts\n * to the token path and opens the stream with `?token=`. `'none'` otherwise.\n */\n auth: 'none' | 'tokenExchange';\n\n /**\n * The stream path `.events()` registers.\n * @default '/events/stream'\n */\n streamPath?: string;\n}\n\n/**\n * What every router of events carries — the SSE event router and the\n * WebSocket router alike.\n *\n * The WebSocket router builds on this rather than on `EventRouterDef`, because\n * only an SSE event router can be contracted.\n */\nexport interface EventRouterBase<TEvents extends Record<string, EventDef<any>>>\n{\n /**\n * Event definitions\n */\n readonly events: TEvents;\n\n /**\n * Event names as array\n */\n readonly eventNames: (keyof TEvents)[];\n\n /**\n * Type inference helper - payload types by event name\n */\n readonly _types: {\n [K in keyof TEvents]: TEvents[K]['_payload'];\n };\n}\n\n/**\n * Event Router Definition\n */\nexport interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> extends EventRouterBase<TEvents>\n{\n /**\n * The contract declared with `.contract()`, absent when the router declares\n * none. Underscored for the same reason as `EventDef._contract`.\n */\n readonly _contract?: EventRouterContract;\n\n /**\n * Declare how a client reaches this router's contracted events.\n *\n * Sets the contract on this router and returns it. A `streamPath` that does\n * not start with `/` throws here, where the typo is.\n */\n contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;\n}\n\n/**\n * Infer event names from EventRouter\n */\nexport type InferEventNames<T> = T extends EventRouterBase<infer E>\n ? keyof E & string\n : never;\n\n/**\n * Infer payload type for specific event\n */\nexport type InferEventPayload<\n T extends EventRouterBase<any>,\n K extends InferEventNames<T>,\n> = T['_types'][K];\n\n/**\n * Infer all event payloads map\n */\nexport type InferEventPayloads<T extends EventRouterBase<any>> = T['_types'];\n\n/**\n * Define an event router for SSE subscription\n *\n * `.contract({ auth })` on the router, together with `.contract({ since })` on\n * its events, publishes those events in `contracts/current.json` — see the\n * contract README.\n *\n * @example\n * ```typescript\n * export const eventRouter = defineEventRouter({\n * userCreated,\n * orderPlaced,\n * });\n *\n * // Type inference\n * type Names = InferEventNames<typeof eventRouter>;\n * // 'userCreated' | 'orderPlaced'\n *\n * type Payload = InferEventPayload<typeof eventRouter, 'userCreated'>;\n * // { userId: string }\n * ```\n */\nexport function defineEventRouter<\n TEvents extends Record<string, EventDef<any>>,\n>(events: TEvents): EventRouterDef<TEvents>\n{\n let contract: EventRouterContract | undefined;\n\n const router: EventRouterDef<TEvents> = {\n events,\n eventNames: Object.keys(events) as (keyof TEvents)[],\n get _contract()\n {\n return contract;\n },\n contract: (next) =>\n {\n assertStreamPath(next.streamPath);\n contract = next;\n\n return router;\n },\n _types: {} as EventRouterDef<TEvents>['_types'],\n };\n\n return router;\n}\n\n/**\n * A stream path is matched against the path `.events()` registers and written\n * into the contract as a URL path, so a relative one can never be right.\n */\nfunction assertStreamPath(streamPath: string | undefined): void\n{\n if (streamPath !== undefined && !streamPath.startsWith('/'))\n {\n throw new Error(\n `defineEventRouter(...).contract({ streamPath: \"${streamPath}\" }) is not an absolute path. `\n + 'The contract publishes it as the URL path a client opens, and createServer compares it with the '\n + 'path .events() registers. Start it with \"/\".',\n );\n }\n}\n","/**\n * WebSocket Handler\n *\n * Attaches a WebSocket server to an existing Node.js http.Server.\n * Handles authentication, event subscription, and client message routing.\n */\n\nimport type { Server } from 'node:http';\nimport type { EventDef } from '../types';\nimport type {\n WSRouterDef,\n WSHandlerConfig,\n WSHandlerAuthConfig,\n WSMessageHandlers,\n WSRawConnection,\n} from './types';\nimport type { SSETokenManager } from '../sse/token-manager';\nimport { logger } from '@spfn/core/logger';\n\nconst wsLogger = logger.child('@spfn/core:ws');\n\n/**\n * Decide whether a WebSocket upgrade's Origin is permitted.\n *\n * - no allow-list configured → allowed (check disabled)\n * - no Origin header (native/non-browser client) → allowed\n * - Origin present → must be in the allow-list\n */\nexport function isOriginAllowed(\n origin: string | undefined,\n allowList: Set<string> | null,\n): boolean\n{\n if (!allowList)\n {\n return true;\n }\n\n if (!origin)\n {\n return true;\n }\n\n return allowList.has(origin);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Attach a WebSocket server to a Node.js http.Server\n *\n * @returns cleanup function that closes the WebSocket server\n */\nexport async function attachWSHandler<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers,\n>(\n server: Server,\n router: WSRouterDef<TEvents, TMessages>,\n config: WSHandlerConfig & { path?: string } = {},\n tokenManager?: SSETokenManager,\n): Promise<() => Promise<void>>\n{\n const WebSocketServer = await loadWSServer();\n\n const {\n pingInterval = 30000,\n path = '/ws',\n maxPayload = 1_048_576,\n maxBufferedBytes = 1_048_576,\n maxConnections = 10_000,\n maxConnectionsPerSubject = 0,\n allowedOrigins,\n auth: authConfig,\n } = config;\n\n const originAllowList = allowedOrigins && allowedOrigins.length > 0\n ? new Set(allowedOrigins)\n : null;\n\n if (authConfig?.enabled && !tokenManager)\n {\n throw new Error(\n 'WebSocket auth.enabled=true requires a tokenManager. ' +\n 'Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer.',\n );\n }\n\n const wss = new WebSocketServer({ server, path, maxPayload });\n\n // Track live connections for graceful shutdown\n const clients = new Set<any>();\n // Live connection count per authenticated subject (for the per-subject cap)\n const subjectCounts = new Map<string, number>();\n\n wss.on('connection', (ws: any, req: any) =>\n {\n // Origin allow-list (cross-site WebSocket hijacking protection). Browsers\n // always send Origin on the upgrade; reject one that isn't allowed before\n // doing any work. A missing Origin (native/non-browser client, which can't\n // be CSRF'd into using ambient cookies) passes through.\n if (!isOriginAllowed(req.headers?.origin, originAllowList))\n {\n wsLogger.warn('WebSocket rejected: disallowed origin', { origin: req.headers?.origin });\n ws.close(1008, 'Origin not allowed');\n\n return;\n }\n\n // Global connection cap — reject before doing any work\n if (clients.size >= maxConnections)\n {\n ws.close(1013, 'Server at capacity');\n\n return;\n }\n\n clients.add(ws);\n ws.on('close', () => clients.delete(ws));\n handleConnection(ws, req, router, authConfig, tokenManager, {\n pingInterval,\n maxBufferedBytes,\n maxConnectionsPerSubject,\n subjectCounts,\n })\n .catch((err: Error) =>\n {\n wsLogger.error('WebSocket connection handler error', err);\n if (ws.readyState === 1) ws.close(1011, 'Internal server error');\n });\n });\n\n wss.on('error', (err: Error) =>\n {\n wsLogger.error('WebSocket server error', err);\n });\n\n wsLogger.info(`✓ WebSocket endpoint registered at ${path}`, {\n events: router.eventNames,\n auth: !!authConfig?.enabled,\n });\n\n return () => new Promise<void>((resolve, reject) =>\n {\n // Close all existing connections with 1001 Going Away\n for (const client of clients)\n {\n client.close(1001, 'Server shutting down');\n }\n clients.clear();\n\n wss.close((err?: Error) =>\n {\n if (err) reject(err);\n else resolve();\n });\n });\n}\n\n// ============================================================================\n// Connection Handler\n// ============================================================================\n\ninterface ConnectionOptions\n{\n pingInterval: number;\n maxBufferedBytes: number;\n maxConnectionsPerSubject: number;\n subjectCounts: Map<string, number>;\n}\n\nasync function handleConnection(\n ws: any,\n req: any,\n router: WSRouterDef<any, any>,\n authConfig: WSHandlerAuthConfig | undefined,\n tokenManager: SSETokenManager | undefined,\n opts: ConnectionOptions,\n): Promise<void>\n{\n const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;\n // Register close handler before any await — ensures we never miss the event even during auth\n let pingTimer: ReturnType<typeof setInterval> | undefined;\n let connectionUnsubscribes: (() => void)[] = [];\n let subscribedEvents: string[] = [];\n ws.on('close', () =>\n {\n clearInterval(pingTimer);\n connectionUnsubscribes.forEach(fn => fn());\n if (subscribedEvents.length > 0)\n wsLogger.info('WebSocket connection closed', { events: subscribedEvents });\n });\n\n const url = parseURL(req);\n if (!url)\n {\n ws.close(1002, 'Invalid request URL');\n\n return;\n }\n\n // ── 1. Authenticate ──\n const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : undefined);\n if (subject === false)\n {\n ws.close(4001, 'Missing token');\n\n return;\n }\n if (subject === null)\n {\n ws.close(4001, 'Invalid or expired token');\n\n return;\n }\n\n // ── 2. Resolve subscribed events ──\n const requestedEvents = parseRequestedEvents(url, router.eventNames as string[]);\n if (requestedEvents.length === 0)\n {\n ws.close(4000, 'No valid event names specified');\n\n return;\n }\n\n // ── 3. Authorize ──\n const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);\n if (allowedEvents === null)\n {\n ws.close(4003, 'Not authorized for any requested events');\n\n return;\n }\n\n // ── Per-subject connection cap (authenticated subjects only) ──\n if (maxConnectionsPerSubject > 0 && typeof subject === 'string')\n {\n const current = subjectCounts.get(subject) ?? 0;\n if (current >= maxConnectionsPerSubject)\n {\n ws.close(1013, 'Too many connections for this subject');\n\n return;\n }\n\n subjectCounts.set(subject, current + 1);\n ws.on('close', () =>\n {\n const remaining = (subjectCounts.get(subject) ?? 1) - 1;\n if (remaining <= 0) subjectCounts.delete(subject);\n else subjectCounts.set(subject, remaining);\n });\n }\n\n subscribedEvents = allowedEvents;\n wsLogger.info('WebSocket connection established', {\n events: allowedEvents,\n subject: subject ?? undefined,\n });\n\n // ── 4. Build connection wrapper ──\n const connection = createConnection(ws, maxBufferedBytes);\n\n // ── 5. Subscribe to server-push events ──\n connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);\n\n // If socket closed during auth awaits, clean up and bail\n if (ws.readyState !== 1)\n {\n connectionUnsubscribes.forEach(fn => fn());\n connectionUnsubscribes = [];\n\n return;\n }\n\n // ── 6. Handle incoming messages ──\n ws.on('message', (data: Buffer | string) =>\n {\n onClientMessage(data, router, connection, subject)\n .catch((err: Error) => wsLogger.error('Unhandled message error', err));\n });\n\n // ── 7. Keep-alive ping with liveness (pong) tracking ──\n // Without reaping un-ponged sockets, a half-open connection (sleeping device,\n // NAT drop with no FIN) lingers with its subscriptions, timer, and buffers.\n if (pingInterval > 0)\n {\n ws.isAlive = true;\n ws.on('pong', () =>\n {\n ws.isAlive = true;\n });\n\n pingTimer = setInterval(() =>\n {\n if (ws.readyState !== 1) return;\n\n if (ws.isAlive === false)\n {\n // No pong since the previous tick — drop the dead/half-open socket\n ws.terminate();\n\n return;\n }\n\n ws.isAlive = false;\n ws.ping();\n }, pingInterval);\n }\n\n // ── 9. Send connected ack ──\n connection.send('__connected', {\n subscribedEvents: allowedEvents,\n timestamp: Date.now(),\n });\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction parseURL(req: any): URL | null\n{\n try\n {\n return new URL(req.url ?? '/', 'ws://localhost');\n }\n catch\n {\n return null;\n }\n}\n\n/**\n * Resolve subject from token\n * - undefined: no auth required\n * - false: token param missing (when required)\n * - null: token invalid/expired\n * - string: authenticated subject\n */\nasync function resolveSubject(\n url: URL,\n tokenManager?: SSETokenManager,\n): Promise<string | undefined | false | null>\n{\n if (!tokenManager)\n {\n return undefined;\n }\n\n const token = url.searchParams.get('token');\n if (!token)\n {\n return false;\n }\n\n return await tokenManager.verify(token);\n}\n\nfunction parseRequestedEvents(url: URL, validEventNames: string[]): string[]\n{\n const eventsParam = url.searchParams.get('events');\n if (!eventsParam)\n {\n return [];\n }\n\n return eventsParam\n .split(',')\n .map(e => e.trim())\n .filter(e => validEventNames.includes(e));\n}\n\nasync function resolveAllowedEvents(\n subject: string | undefined,\n requestedEvents: string[],\n authConfig?: WSHandlerAuthConfig,\n): Promise<string[] | null>\n{\n if (!subject || !authConfig?.authorize)\n {\n return requestedEvents;\n }\n\n const allowed = await authConfig.authorize(subject, requestedEvents);\n\n return allowed.length === 0 ? null : allowed;\n}\n\n/**\n * Send a JSON frame with backpressure protection. If the socket's outbound\n * buffer is already past the cap, the consumer is too slow — close the\n * connection (1013) instead of buffering more and risking OOM. The client\n * reconnects and re-subscribes.\n */\nexport function safeSend(ws: any, frame: unknown, maxBufferedBytes: number): void\n{\n if (ws.readyState !== 1) return;\n\n if (ws.bufferedAmount > maxBufferedBytes)\n {\n ws.close(1013, 'Send buffer overflow');\n\n return;\n }\n\n try\n {\n ws.send(JSON.stringify(frame));\n }\n catch\n {\n // Socket closed between the readyState check and send — ignore\n }\n}\n\nfunction createConnection(ws: any, maxBufferedBytes: number): WSRawConnection\n{\n return {\n send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),\n close: (code, reason) => ws.close(code, reason),\n };\n}\n\nfunction subscribeEvents(\n ws: any,\n router: WSRouterDef<any, any>,\n allowedEvents: string[],\n subject: string | undefined,\n authConfig: WSHandlerAuthConfig | undefined,\n maxBufferedBytes: number,\n): (() => void)[]\n{\n const unsubscribes: (() => void)[] = [];\n\n for (const eventName of allowedEvents)\n {\n const eventDef = router.events[eventName];\n if (!eventDef) continue;\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n if (ws.readyState !== 1) return;\n\n if (subject && authConfig?.filter?.[eventName])\n {\n if (!authConfig.filter[eventName](subject, payload)) return;\n }\n\n safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n return unsubscribes;\n}\n\nasync function onClientMessage(\n data: Buffer | string,\n router: WSRouterDef<any, any>,\n connection: WSRawConnection,\n subject: string | undefined,\n): Promise<void>\n{\n let message: { type?: string; data?: unknown };\n\n try\n {\n message = JSON.parse(data.toString());\n }\n catch\n {\n return;\n }\n\n const { type, data: payload } = message;\n if (!type) return;\n\n const handler = router.messages[type];\n if (!handler) return;\n\n try\n {\n await handler({ payload, subject, ws: connection });\n }\n catch (err)\n {\n wsLogger.error(`WebSocket message handler error: ${type}`, err as Error);\n }\n}\n\n// ============================================================================\n// Dynamic import for optional 'ws' dependency\n// ============================================================================\n\n/**\n * Resolve the WebSocketServer constructor from the 'ws' module object.\n *\n * ws is a dual package, so the module shape depends on the loader path:\n * - ESM (exports.import → wrapper.mjs): WebSocketServer is a named export;\n * the default export is the bare WebSocket class with no properties on it.\n * - CJS (require interop): module.exports = WebSocket with\n * WebSocket.WebSocketServer attached, surfaced under .default.\n * Named exports must win — checking .default first crashes on the ESM path.\n */\nexport function resolveWSServerCtor(mod: any): any\n{\n return mod.WebSocketServer ?? mod.Server\n ?? mod.default?.WebSocketServer ?? mod.default?.Server;\n}\n\nasync function loadWSServer(): Promise<any>\n{\n let mod: any;\n\n try\n {\n mod = await import('ws');\n }\n catch\n {\n throw new Error(\n '@spfn/core WebSocket support requires the \"ws\" package.\\n' +\n 'Install it with: pnpm add ws',\n );\n }\n\n const WSS = resolveWSServerCtor(mod);\n\n if (typeof WSS !== 'function')\n {\n throw new Error(\n 'WebSocketServer not found in ws module. ' +\n 'Ensure ws@^8 is installed: pnpm add ws',\n );\n }\n\n return WSS;\n}\n","/**\n * WebSocket Module\n *\n * Type-safe WebSocket server with event-based pub/sub and bidirectional messaging.\n *\n * @example Server setup\n * ```typescript\n * // src/server/ws.ts\n * import { defineWSRouter } from '@spfn/core/event/ws';\n * import { defineEvent } from '@spfn/core/event';\n * import { Type } from '@sinclair/typebox';\n *\n * const userUpdated = defineEvent('user.updated', Type.Object({ userId: Type.String() }));\n * const notification = defineEvent('notification', Type.Object({ message: Type.String() }));\n *\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * },\n * });\n *\n * export type WSRouter = typeof wsRouter;\n *\n * // server.config.ts\n * defineServerConfig()\n * .websockets(wsRouter)\n * .build();\n * ```\n *\n * @example Client usage\n * ```typescript\n * import { createWSClient } from '@spfn/core/event/ws/client';\n * import type { WSRouter } from '@/server/ws';\n *\n * const client = createWSClient<WSRouter>();\n *\n * client.subscribe({\n * events: ['userUpdated', 'notification'],\n * handlers: {\n * userUpdated: ({ userId }) => console.log(userId),\n * notification: ({ message }) => console.log(message),\n * },\n * });\n *\n * client.send('ping', {});\n * ```\n */\n\nimport type { EventDef } from '../types';\nimport type { WSRouterDef, WSMessageHandlers } from './types';\n\nexport { attachWSHandler } from './handler';\nexport type {\n WSRouterDef,\n WSHandlerConfig,\n WSAuthConfig,\n WSHandlerAuthConfig,\n WSMessageContext,\n WSMessageHandlerFn,\n WSMessageHandlers,\n WSRawConnection,\n WSClientConfig,\n WSConnectionState,\n WSEventHandlers,\n WSSubscribeOptions,\n WSUnsubscribe,\n} from './types';\n\n/**\n * Define a WebSocket router\n *\n * Combines server→client event push with client→server message handlers.\n *\n * @example\n * ```typescript\n * export const wsRouter = defineWSRouter({\n * events: { userUpdated, notification },\n * messages: {\n * ping: ({ ws }) => ws.send('pong', {}),\n * 'chat.send': ({ payload, subject }) => handleChat(payload, subject),\n * },\n * });\n * ```\n */\nexport function defineWSRouter<\n TEvents extends Record<string, EventDef<any>>,\n TMessages extends WSMessageHandlers = WSMessageHandlers,\n>(def: {\n events: TEvents;\n messages?: TMessages;\n}): WSRouterDef<TEvents, TMessages>\n{\n return {\n events: def.events,\n eventNames: Object.keys(def.events) as (keyof TEvents)[],\n messages: (def.messages ?? {}) as TMessages,\n _types: {} as WSRouterDef<TEvents, TMessages>['_types'],\n };\n}\n"]}
@@ -1,8 +1,8 @@
1
1
  import { E as EventRouterDef, I as InferEventNames } from '../../router-CX5uVjmU.js';
2
- import { S as SSEClientConfig, a as SSESubscribeOptions, b as SSEUnsubscribe, c as SSEConnectionState } from '../../types-CurMab2Q.js';
2
+ import { S as SSEClientConfig, a as SSESubscribeOptions, b as SSEUnsubscribe, c as SSEConnectionState } from '../../types-BrO7j6mR.js';
3
3
  import '@sinclair/typebox';
4
4
  import 'hono';
5
- import '../../token-manager-Ckjt2-qq.js';
5
+ import '../../token-manager-CvOsAbnK.js';
6
6
 
7
7
  /**
8
8
  * SSE Client
@@ -45,6 +45,10 @@ var SSE_DEFAULTS = {
45
45
  host: typeof process !== "undefined" ? process.env.NEXT_PUBLIC_SPFN_API_URL || "http://localhost:8790" : "http://localhost:8790",
46
46
  pathname: "/events/stream"
47
47
  };
48
+ function isUnauthorized(error) {
49
+ const answered = error;
50
+ return answered?.statusCode === 401 || answered?.status === 401;
51
+ }
48
52
  function createSSEClient(config = {}) {
49
53
  const {
50
54
  url,
@@ -59,7 +63,7 @@ function createSSEClient(config = {}) {
59
63
  const baseUrl = url || `${host}${pathname}`;
60
64
  let state = "closed";
61
65
  let active = null;
62
- function closeConn(conn) {
66
+ function closeConn(conn, reason) {
63
67
  if (conn.closed) {
64
68
  return;
65
69
  }
@@ -76,7 +80,7 @@ function createSSEClient(config = {}) {
76
80
  active = null;
77
81
  state = "closed";
78
82
  }
79
- conn.onClose?.();
83
+ conn.onClose?.(reason);
80
84
  }
81
85
  function subscribe(options) {
82
86
  const { events, handlers, onOpen, onError, onReconnect, onClose, onSubscriptionChange, onConnected } = options;
@@ -115,10 +119,14 @@ function createSSEClient(config = {}) {
115
119
  setupSubscriptionChange(conn.eventSource, onSubscriptionChange);
116
120
  setupReconnect(onReconnect);
117
121
  };
118
- init().catch(() => {
122
+ init().catch((error) => {
119
123
  if (conn.closed) {
120
124
  return;
121
125
  }
126
+ if (isUnauthorized(error)) {
127
+ closeConn(conn, "unauthorized");
128
+ return;
129
+ }
122
130
  state = "error";
123
131
  attemptReconnect(onReconnect);
124
132
  });
@@ -261,7 +269,7 @@ function createAuthSSEClient(config = {}) {
261
269
  body: JSON.stringify({})
262
270
  });
263
271
  if (!res.ok) {
264
- throw new Error(`Failed to acquire SSE token: ${res.status}`);
272
+ throw Object.assign(new Error(`Failed to acquire SSE token: ${res.status}`), { status: res.status });
265
273
  }
266
274
  const data = await res.json();
267
275
  return data.token;