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

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.
@@ -66,22 +66,33 @@ function createBoundedWriter(stream, maxQueue, onClose) {
66
66
  var registryLogger = logger.child("@spfn/core:sse");
67
67
  var SUBSCRIPTION_GRANTED = "subscription.granted";
68
68
  var SUBSCRIPTION_REVOKED = "subscription.revoked";
69
+ var KEY_REVOKED = "key revoked";
69
70
  function createConnectionRegistry() {
70
71
  const bySubject = /* @__PURE__ */ new Map();
72
+ const byKey = /* @__PURE__ */ new Map();
71
73
  const pendingBySubject = /* @__PURE__ */ new Map();
72
- const beginAdmission = (subject, signal) => {
74
+ const pendingByKey = /* @__PURE__ */ new Map();
75
+ const beginAdmission = (subject, keyId, signal) => {
73
76
  const changes = [];
74
77
  addToSet(pendingBySubject, subject, changes);
75
- const end = endOnce(signal, () => removeFromSet(pendingBySubject, subject, changes));
78
+ addToOptionalSet(pendingByKey, keyId, changes);
79
+ const end = endOnce(signal, () => {
80
+ removeFromSet(pendingBySubject, subject, changes);
81
+ removeFromOptionalSet(pendingByKey, keyId, changes);
82
+ });
76
83
  return {
77
84
  cancel: end,
78
85
  open: (connection) => {
79
86
  if (!end()) {
80
- return false;
87
+ return "aborted during admission";
88
+ }
89
+ if (changes.some((change2) => change2.kind === "close")) {
90
+ return KEY_REVOKED;
81
91
  }
82
92
  applyRecorded(connection, changes);
83
93
  addToSet(bySubject, subject, connection);
84
- return true;
94
+ addToOptionalSet(byKey, connection.keyId, connection);
95
+ return null;
85
96
  }
86
97
  };
87
98
  };
@@ -101,15 +112,25 @@ function createConnectionRegistry() {
101
112
  });
102
113
  }
103
114
  };
115
+ const closeByKey = (keyId) => {
116
+ const connections = [...byKey.get(keyId) ?? []];
117
+ pendingByKey.get(keyId)?.forEach((changes) => changes.push({ kind: "close" }));
118
+ connections.forEach((connection) => connection.close(KEY_REVOKED));
119
+ return connections.length;
120
+ };
104
121
  return {
105
122
  beginAdmission,
123
+ closeByKey,
106
124
  remove: (connection) => {
107
125
  if (connection.subject) {
108
126
  removeFromSet(bySubject, connection.subject, connection);
109
127
  }
128
+ removeFromOptionalSet(byKey, connection.keyId, connection);
110
129
  },
111
130
  /** Open connections only — a pending admission is not counted. */
112
131
  count: (subject) => bySubject.get(subject)?.size ?? 0,
132
+ /** Open connections whose token was minted with `keyId`. */
133
+ countByKey: (keyId) => byKey.get(keyId)?.size ?? 0,
113
134
  pendingCount: (subject) => pendingBySubject.get(subject)?.size ?? 0,
114
135
  grant: (subject, eventName, match) => change("grant", subject, eventName, match),
115
136
  revoke: (subject, eventName, match) => change("revoke", subject, eventName, match)
@@ -137,6 +158,16 @@ function addToSet(map, key, value) {
137
158
  set.add(value);
138
159
  map.set(key, set);
139
160
  }
161
+ function addToOptionalSet(map, key, value) {
162
+ if (key) {
163
+ addToSet(map, key, value);
164
+ }
165
+ }
166
+ function removeFromOptionalSet(map, key, value) {
167
+ if (key) {
168
+ removeFromSet(map, key, value);
169
+ }
170
+ }
140
171
  function removeFromSet(map, key, value) {
141
172
  const set = map.get(key);
142
173
  set?.delete(value);
@@ -148,10 +179,11 @@ function matchesRequest(match, request) {
148
179
  return match === void 0 || Object.entries(match.query).every(([field, value]) => Object.hasOwn(request.query, field) && request.query[field] === value);
149
180
  }
150
181
  function applyRecorded(connection, changes) {
151
- changes.forEach(({ kind, eventName, match }) => {
152
- if (!matchesRequest(match, connection.request)) {
182
+ changes.forEach((change) => {
183
+ if (change.kind === "close" || !matchesRequest(change.match, connection.request)) {
153
184
  return;
154
185
  }
186
+ const { kind, eventName } = change;
155
187
  const [from, to] = kind === "grant" ? [connection.denied, connection.allowed] : [connection.allowed, connection.denied];
156
188
  if (from.delete(eventName)) {
157
189
  to.add(eventName);
@@ -181,6 +213,24 @@ function unsubscribeAll(connection) {
181
213
  connection.unsubscribes.forEach((unsubscribe) => unsubscribe());
182
214
  connection.unsubscribes.clear();
183
215
  }
216
+ var keyCloseLogger = logger.child("@spfn/core:sse");
217
+ var REGISTRIES_KEY = Symbol.for("@spfn/core:sse-key-close-registries");
218
+ var registries = globalThis[REGISTRIES_KEY] ??= /* @__PURE__ */ new Set();
219
+ function registerKeyCloseRegistry(registry) {
220
+ registries.add(registry);
221
+ }
222
+ function applyKeyClose(keyId) {
223
+ let closed = 0;
224
+ registries.forEach((registry) => {
225
+ closed += registry.closeByKey(keyId);
226
+ });
227
+ if (closed > 0) {
228
+ keyCloseLogger.info("SSE connections closed", { count: closed, reason: "key revoked" });
229
+ } else {
230
+ keyCloseLogger.debug("SSE key close reached no open connection", { reason: "key revoked" });
231
+ }
232
+ return closed;
233
+ }
184
234
 
185
235
  // src/event/sse/handler.ts
186
236
  var sseLogger = logger.child("@spfn/core:sse");
@@ -213,6 +263,7 @@ function createSSEHandler(router, config = {}, tokenManager) {
213
263
  } = config;
214
264
  const registry = createConnectionRegistry();
215
265
  registerSubscriptionControls(router, registry);
266
+ registerKeyCloseRegistry(registry);
216
267
  const streamConfig = { router, authConfig, registry, pingInterval, maxQueue };
217
268
  const handler = async (c) => {
218
269
  const admission = await admitConnection(c, streamConfig, tokenManager);
@@ -234,7 +285,9 @@ function createSSEHandler(router, config = {}, tokenManager) {
234
285
  /** @internal Open registered connections of a subject (tests). */
235
286
  _openConnections: (subject) => registry.count(subject),
236
287
  /** @internal Connects of a subject still in admission (tests). */
237
- _pendingAdmissions: (subject) => registry.pendingCount(subject)
288
+ _pendingAdmissions: (subject) => registry.pendingCount(subject),
289
+ /** @internal Open registered connections whose token was minted with a key (tests). */
290
+ _openConnectionsByKey: (keyId) => registry.countByKey(keyId)
238
291
  });
239
292
  }
240
293
  function registerSubscriptionControls(router, registry) {
@@ -246,17 +299,37 @@ function registerSubscriptionControls(router, registry) {
246
299
  }
247
300
  }
248
301
  async function admitConnection(c, config, tokenManager) {
249
- const { router, authConfig, registry } = config;
250
- const subject = await authenticateToken(c, tokenManager);
251
- if (subject === false) {
302
+ const token = await takeToken(c, tokenManager);
303
+ if (token === false) {
252
304
  return c.json({ error: "Missing token parameter" }, 401);
253
305
  }
254
- if (subject === null) {
255
- return c.json({ error: "Invalid or expired token" }, 401);
306
+ if (token === null) {
307
+ return invalidToken(c);
308
+ }
309
+ return whilePending(
310
+ token?.subject ? config.registry.beginAdmission(token.subject, token.keyId, c.req.raw.signal) : void 0,
311
+ () => checkConnection(c, config, token, tokenManager)
312
+ );
313
+ }
314
+ function invalidToken(c) {
315
+ return c.json({ error: "Invalid or expired token" }, 401);
316
+ }
317
+ async function checkConnection(c, config, token, tokenManager) {
318
+ if (token && tokenManager && !await tokenManager.checkKey(token)) {
319
+ return invalidToken(c);
256
320
  }
321
+ const subject = token?.subject;
257
322
  if (subject) {
258
323
  c.set("sseSubject", subject);
259
324
  }
325
+ const requested = validateRequestedEvents(c, config.router);
326
+ if (requested instanceof Response) {
327
+ return requested;
328
+ }
329
+ const authorized = await authorizeConnection(c, config.router, config.authConfig, subject, requested);
330
+ return authorized instanceof Response ? authorized : { ...authorized, keyId: token?.keyId };
331
+ }
332
+ function validateRequestedEvents(c, router) {
260
333
  const requested = parseRequestedEvents(c);
261
334
  if (!requested) {
262
335
  return c.json({ error: "Missing events parameter" }, 400);
@@ -270,10 +343,7 @@ async function admitConnection(c, config, tokenManager) {
270
343
  validEvents: validEventNames
271
344
  }, 400);
272
345
  }
273
- return whilePending(
274
- subject ? registry.beginAdmission(subject, c.req.raw.signal) : void 0,
275
- () => authorizeConnection(c, router, authConfig, subject, requested)
276
- );
346
+ return requested;
277
347
  }
278
348
  async function whilePending(pending, check) {
279
349
  let outcome;
@@ -372,6 +442,7 @@ async function runStream(c, stream, admission, config) {
372
442
  };
373
443
  const connection = {
374
444
  subject,
445
+ keyId: admission.keyId,
375
446
  requested: admission.requested,
376
447
  allowed: new Set(allowed),
377
448
  denied: new Set(denied),
@@ -381,10 +452,12 @@ async function runStream(c, stream, admission, config) {
381
452
  notify: (frame, eventName) => {
382
453
  if (connectionDead) return;
383
454
  hold.send(() => writer.enqueue({ event: frame, data: JSON.stringify({ event: eventName }) }));
384
- }
455
+ },
456
+ close: cleanup
385
457
  };
386
- if (admission.pending?.open(connection) === false) {
387
- cleanup("aborted during admission");
458
+ const refusal = admission.pending?.open(connection);
459
+ if (refusal) {
460
+ cleanup(refusal);
388
461
  return;
389
462
  }
390
463
  void openSubscriptions(connection, writer, hold, {
@@ -534,7 +607,7 @@ function passesFrameFilters(eventName, eventDef, payload, ctx) {
534
607
  return false;
535
608
  }
536
609
  }
537
- async function authenticateToken(c, tokenManager) {
610
+ async function takeToken(c, tokenManager) {
538
611
  if (!tokenManager) {
539
612
  return void 0;
540
613
  }
@@ -542,7 +615,7 @@ async function authenticateToken(c, tokenManager) {
542
615
  if (!token) {
543
616
  return false;
544
617
  }
545
- return await tokenManager.verify(token);
618
+ return tokenManager.take(token);
546
619
  }
547
620
  function parseRequestedEvents(c) {
548
621
  const eventsParam = c.req.query("events");
@@ -561,6 +634,7 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
561
634
  }
562
635
  return allowed;
563
636
  }
637
+ var tokenLogger = logger.child("@spfn/core:sse");
564
638
  function hashToken(token) {
565
639
  return createHash("sha256").update(token).digest("hex");
566
640
  }
@@ -623,10 +697,12 @@ var CacheTokenStore = class {
623
697
  var SSETokenManager = class {
624
698
  store;
625
699
  ttl;
700
+ verifyKey;
626
701
  cleanupTimer = null;
627
702
  constructor(config) {
628
703
  this.ttl = config?.ttl ?? 3e4;
629
704
  this.store = config?.store ?? new InMemoryTokenStore();
705
+ this.verifyKey = config?.verifyKey;
630
706
  const cleanupInterval = config?.cleanupInterval ?? 6e4;
631
707
  this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
632
708
  this.cleanupTimer.unref();
@@ -641,13 +717,16 @@ var SSETokenManager = class {
641
717
  return this.ttl;
642
718
  }
643
719
  /**
644
- * Issue a new one-time-use token for the given subject
720
+ * Issue a new one-time-use token for the given subject. `keyId` tags it
721
+ * with the device key it is minted for; an empty one leaves it untagged.
645
722
  */
646
- async issue(subject) {
723
+ async issue(subject, options) {
647
724
  const token = randomBytes(32).toString("hex");
725
+ const keyId = options?.keyId || void 0;
648
726
  await this.store.set(token, {
649
727
  subject,
650
- expiresAt: Date.now() + this.ttl
728
+ expiresAt: Date.now() + this.ttl,
729
+ ...keyId ? { keyId } : {}
651
730
  });
652
731
  return token;
653
732
  }
@@ -656,11 +735,43 @@ var SSETokenManager = class {
656
735
  * @returns subject string if valid, null if invalid/expired/already consumed
657
736
  */
658
737
  async verify(token) {
738
+ return (await this.consume(token))?.subject ?? null;
739
+ }
740
+ /**
741
+ * Consume a token and answer what it was minted with: `null` when it is
742
+ * unknown, expired, already consumed, or its key no longer holds
743
+ * (`verifyKey`). The token is gone either way. `take` + `checkKey`.
744
+ */
745
+ async consume(token) {
746
+ const data = await this.take(token);
747
+ return data && await this.checkKey(data) ? data : null;
748
+ }
749
+ /**
750
+ * The store half of `consume`: take the token out of the store and answer
751
+ * what it was minted with, `null` when it is unknown, expired or already
752
+ * consumed — its key is not checked yet (`checkKey`). For a caller that
753
+ * has to register the connection before the key check, so a revocation
754
+ * landing during the check reaches it.
755
+ */
756
+ async take(token) {
659
757
  const data = await this.store.consume(token);
660
- if (!data || data.expiresAt <= Date.now()) {
661
- return null;
758
+ return data && data.expiresAt > Date.now() ? data : null;
759
+ }
760
+ /**
761
+ * The key half of `consume`: `verifyKey` on a tagged token; an untagged
762
+ * one, or no verifier, holds without a call. A throw refuses and is warned
763
+ * without its text — it may carry what the verifier read.
764
+ */
765
+ async checkKey(data) {
766
+ if (!data.keyId || !this.verifyKey) {
767
+ return true;
768
+ }
769
+ try {
770
+ return await this.verifyKey(data.keyId) === true;
771
+ } catch {
772
+ tokenLogger.warn("SSE token key check threw \u2014 token refused");
773
+ return false;
662
774
  }
663
- return data.subject;
664
775
  }
665
776
  /**
666
777
  * Cleanup timer and resources
@@ -672,7 +783,305 @@ var SSETokenManager = class {
672
783
  }
673
784
  }
674
785
  };
786
+ var transportLogger = logger.child("@spfn/core:event-transport");
787
+ var DEFAULT_CHANNEL_PREFIX = "spfn:sse:";
788
+ var PUBLISH_TIMEOUT_MS = 5e3;
789
+ var SUBSCRIBE_TIMEOUT_MS = 5e3;
790
+ var WARN_THROTTLE_MS = 3e4;
791
+ var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
792
+ var KEY_CLOSE_RETRY_MS = PUBLISH_BREAKER_COOLDOWN_MS + 500;
793
+ var PENDING_KEY_CLOSE_CAP = 1e3;
794
+ function withTimeout(promise, ms, label) {
795
+ return new Promise((resolve, reject) => {
796
+ const timer = setTimeout(() => {
797
+ reject(new Error(`${label} timed out after ${ms}ms`));
798
+ }, ms);
799
+ timer.unref?.();
800
+ promise.then(
801
+ (value) => {
802
+ clearTimeout(timer);
803
+ resolve(value);
804
+ },
805
+ (err) => {
806
+ clearTimeout(timer);
807
+ reject(err);
808
+ }
809
+ );
810
+ });
811
+ }
812
+ var warnThrottle = /* @__PURE__ */ new Map();
813
+ function throttledWarn(key, message, fields) {
814
+ const now = Date.now();
815
+ const entry = warnThrottle.get(key);
816
+ if (entry && now - entry.last < WARN_THROTTLE_MS) {
817
+ entry.suppressed++;
818
+ return;
819
+ }
820
+ transportLogger.warn(message, entry && entry.suppressed > 0 ? { ...fields, suppressedSinceLast: entry.suppressed } : fields);
821
+ warnThrottle.set(key, { last: now, suppressed: 0 });
822
+ }
823
+ var STATE_KEY = Symbol.for("@spfn/core:event-transport");
824
+ var state = globalThis[STATE_KEY] ??= {
825
+ pubSubCache: void 0,
826
+ subscriber: void 0,
827
+ channelPrefix: void 0,
828
+ wired: /* @__PURE__ */ new Map(),
829
+ keyCloseDeferrals: /* @__PURE__ */ new Map()
830
+ };
831
+ function createKeyCloseDeferral(resend) {
832
+ const pending = /* @__PURE__ */ new Map();
833
+ let timer;
834
+ let flushing = false;
835
+ let disposed = false;
836
+ const arm = () => {
837
+ if (timer || disposed) {
838
+ return;
839
+ }
840
+ timer = setTimeout(() => {
841
+ timer = void 0;
842
+ void flush();
843
+ }, KEY_CLOSE_RETRY_MS);
844
+ timer.unref?.();
845
+ };
846
+ const disarm = () => {
847
+ clearTimeout(timer);
848
+ timer = void 0;
849
+ };
850
+ const flush = async () => {
851
+ if (flushing || pending.size === 0) {
852
+ return;
853
+ }
854
+ flushing = true;
855
+ const snapshot = [...pending.values()];
856
+ pending.clear();
857
+ for (const message of snapshot) {
858
+ await resend(message);
859
+ }
860
+ flushing = false;
861
+ if (pending.size > 0) {
862
+ arm();
863
+ } else {
864
+ disarm();
865
+ }
866
+ };
867
+ return {
868
+ defer: (message) => {
869
+ if (disposed) {
870
+ return;
871
+ }
872
+ pending.delete(message.keyId);
873
+ pending.set(message.keyId, message);
874
+ dropOldestPastCap(pending);
875
+ throttledWarn("key-close-deferred", "SSE key close deferred \u2014 re-sent when the pub/sub transport recovers", {
876
+ channel: CONTROL_CHANNEL,
877
+ pending: pending.size
878
+ });
879
+ arm();
880
+ },
881
+ flush,
882
+ dispose: () => {
883
+ disposed = true;
884
+ disarm();
885
+ pending.clear();
886
+ },
887
+ keyIds: () => [...pending.keys()]
888
+ };
889
+ }
890
+ function dropOldestPastCap(pending) {
891
+ const dropped = pending.size - PENDING_KEY_CLOSE_CAP;
892
+ if (dropped <= 0) {
893
+ return;
894
+ }
895
+ [...pending.keys()].slice(0, dropped).forEach((keyId) => pending.delete(keyId));
896
+ throttledWarn("key-close-dropped", "SSE key closes dropped \u2014 too many waiting to be re-sent", { dropped });
897
+ }
898
+ function resolveChannelPrefix(override) {
899
+ return process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
900
+ }
901
+ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
902
+ let subscriber;
903
+ const handlers = /* @__PURE__ */ new Map();
904
+ let degradedUntil = 0;
905
+ const ensureSubscriber = () => {
906
+ if (subscriber) {
907
+ return subscriber;
908
+ }
909
+ subscriber = client.duplicate();
910
+ onSubscriber?.(subscriber);
911
+ subscriber.on("message", (channel, raw) => {
912
+ const handler = handlers.get(channel);
913
+ if (!handler) {
914
+ return;
915
+ }
916
+ let payload;
917
+ try {
918
+ payload = JSON.parse(raw);
919
+ } catch {
920
+ transportLogger.warn("Pub/sub message parse failed \u2014 dropped", { channel });
921
+ return;
922
+ }
923
+ void handler(payload);
924
+ });
925
+ subscriber.on("error", (err) => {
926
+ throttledWarn("subscriber-error", "Pub/sub subscriber error", { error: err.message });
927
+ });
928
+ return subscriber;
929
+ };
930
+ const subscriberHealthy = () => !!subscriber && (subscriber.status === void 0 || subscriber.status === "ready");
931
+ const deliverLocal = (channel, message) => {
932
+ const handler = handlers.get(channel);
933
+ if (handler) {
934
+ void handler(message);
935
+ }
936
+ };
937
+ let keyCloses;
938
+ const deferIfKeyClose = (name, message) => {
939
+ if (name === CONTROL_CHANNEL && isKeyCloseMessage(message)) {
940
+ keyCloses.defer(message);
941
+ }
942
+ };
943
+ const cache = {
944
+ publish: async (name, message) => {
945
+ const channel = channelPrefix + name;
946
+ let serialized;
947
+ try {
948
+ serialized = JSON.stringify(message ?? null);
949
+ } catch (err) {
950
+ transportLogger.warn("Pub/sub payload serialization failed \u2014 local delivery only", {
951
+ channel,
952
+ error: err instanceof Error ? err.message : String(err)
953
+ });
954
+ deliverLocal(channel, message ?? null);
955
+ return;
956
+ }
957
+ if (serialized === void 0) {
958
+ transportLogger.warn("Pub/sub payload not serializable (function/symbol) \u2014 local delivery only", {
959
+ channel
960
+ });
961
+ deliverLocal(channel, message ?? null);
962
+ return;
963
+ }
964
+ const ready = client.status === void 0 || client.status === "ready";
965
+ const now = Date.now();
966
+ if (!ready || degradedUntil > 0 && now < degradedUntil) {
967
+ deferIfKeyClose(name, message);
968
+ deliverLocal(channel, JSON.parse(serialized));
969
+ return;
970
+ }
971
+ if (degradedUntil > 0) {
972
+ degradedUntil = now + PUBLISH_TIMEOUT_MS;
973
+ }
974
+ try {
975
+ await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
976
+ degradedUntil = 0;
977
+ void keyCloses.flush();
978
+ if (!subscriberHealthy()) {
979
+ throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
980
+ channel
981
+ });
982
+ deliverLocal(channel, JSON.parse(serialized));
983
+ }
984
+ } catch {
985
+ degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
986
+ throttledWarn(`publish-failed:${channel}`, "Pub/sub publish failed \u2014 local delivery only", { channel });
987
+ deferIfKeyClose(name, message);
988
+ deliverLocal(channel, JSON.parse(serialized));
989
+ }
990
+ },
991
+ subscribe: async (name, handler) => {
992
+ const channel = channelPrefix + name;
993
+ const sub = ensureSubscriber();
994
+ try {
995
+ await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, "pub/sub subscribe");
996
+ } catch (err) {
997
+ if (handlers.size === 0 && subscriber === sub) {
998
+ subscriber = void 0;
999
+ void sub.quit?.().catch(() => void 0);
1000
+ }
1001
+ throw err;
1002
+ }
1003
+ handlers.set(channel, handler);
1004
+ }
1005
+ };
1006
+ keyCloses = createKeyCloseDeferral((message) => cache.publish(CONTROL_CHANNEL, message));
1007
+ state.keyCloseDeferrals.set(cache, keyCloses);
1008
+ return cache;
1009
+ }
1010
+ var CONTROL_CHANNEL = "$control";
1011
+ var controlBindings = /* @__PURE__ */ new WeakMap();
1012
+ function ensureControlBinding(cache) {
1013
+ let binding = controlBindings.get(cache);
1014
+ if (!binding) {
1015
+ binding = {
1016
+ origin: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
1017
+ events: /* @__PURE__ */ new Map(),
1018
+ subscribing: void 0
1019
+ };
1020
+ controlBindings.set(cache, binding);
1021
+ }
1022
+ return binding;
1023
+ }
1024
+ function isKeyCloseMessage(message) {
1025
+ const candidate = message;
1026
+ return !!candidate && candidate.kind === "close" && typeof candidate.keyId === "string" && candidate.keyId !== "" && typeof candidate.origin === "string";
1027
+ }
1028
+ async function publishKeyClose(cache, keyId) {
1029
+ const message = { kind: "close", keyId, origin: ensureControlBinding(cache).origin };
1030
+ await cache.publish(CONTROL_CHANNEL, message).catch(() => {
1031
+ throttledWarn("key-close-publish-failed", "SSE key close publish failed \u2014 closed on this pod only", {
1032
+ channel: CONTROL_CHANNEL
1033
+ });
1034
+ });
1035
+ }
1036
+ async function resolveEventPubSubCache() {
1037
+ return state.pubSubCache ?? await resolvePubSubCache();
1038
+ }
1039
+ async function resolvePubSubCache(channelPrefix) {
1040
+ const resolvedPrefix = resolveChannelPrefix();
1041
+ if (state.pubSubCache) {
1042
+ if (state.channelPrefix !== resolvedPrefix) {
1043
+ transportLogger.warn("Conflicting channelPrefix ignored \u2014 the transport prefix is process-global (first wiring wins).", {
1044
+ active: state.channelPrefix,
1045
+ ignored: resolvedPrefix
1046
+ });
1047
+ }
1048
+ return state.pubSubCache;
1049
+ }
1050
+ let client;
1051
+ try {
1052
+ const { getCache } = await import('@spfn/core/cache');
1053
+ client = getCache();
1054
+ } catch {
1055
+ return void 0;
1056
+ }
1057
+ if (!client) {
1058
+ return void 0;
1059
+ }
1060
+ if (!process.env.SPFN_SSE_CHANNEL_PREFIX) {
1061
+ transportLogger.warn(
1062
+ "Event fan-out is on the default channel prefix (spfn:sse:). Apps sharing one Redis must set channelPrefix or SPFN_SSE_CHANNEL_PREFIX to avoid cross-app leakage."
1063
+ );
1064
+ }
1065
+ state.channelPrefix = resolvedPrefix;
1066
+ state.pubSubCache = createRedisPubSubCache(
1067
+ client,
1068
+ resolvedPrefix,
1069
+ (connection) => {
1070
+ state.subscriber = connection;
1071
+ }
1072
+ );
1073
+ return state.pubSubCache;
1074
+ }
1075
+
1076
+ // src/event/sse/close-by-key.ts
1077
+ async function closeSSEConnectionsByKey(keyId) {
1078
+ applyKeyClose(keyId);
1079
+ const cache = await resolveEventPubSubCache();
1080
+ if (cache) {
1081
+ await publishKeyClose(cache, keyId);
1082
+ }
1083
+ }
675
1084
 
676
- export { CacheTokenStore, SSETokenManager, createSSEHandler };
1085
+ export { CacheTokenStore, SSETokenManager, closeSSEConnectionsByKey, createSSEHandler };
677
1086
  //# sourceMappingURL=index.js.map
678
1087
  //# sourceMappingURL=index.js.map