@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.
- package/README.md +1 -1
- package/dist/db/index.d.ts +254 -84
- package/dist/db/index.js +400 -25
- package/dist/db/index.js.map +1 -1
- package/dist/event/index.d.ts +2 -2
- package/dist/event/index.js +32 -4
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +12 -4
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +40 -5
- package/dist/event/sse/index.js +437 -28
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +223 -37
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +8 -1
- package/dist/state/client.js +15 -5
- package/dist/state/client.js.map +1 -1
- package/dist/state/react.d.ts +3 -1
- package/dist/state/react.js.map +1 -1
- package/dist/{token-manager-Ckjt2-qq.d.ts → token-manager-CvOsAbnK.d.ts} +47 -3
- package/dist/{types-CurMab2Q.d.ts → types-BrO7j6mR.d.ts} +17 -3
- package/dist/{types-DncqXfWN.d.ts → types-DH6d2eOb.d.ts} +1 -1
- package/package.json +1 -1
- package/src/db/README.md +109 -6
- package/src/db/schema/README.md +19 -1
- package/src/event/README.md +66 -6
- package/src/state/README.md +9 -1
package/dist/event/sse/index.js
CHANGED
|
@@ -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
|
|
74
|
+
const pendingByKey = /* @__PURE__ */ new Map();
|
|
75
|
+
const beginAdmission = (subject, keyId, signal) => {
|
|
73
76
|
const changes = [];
|
|
74
77
|
addToSet(pendingBySubject, subject, changes);
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|
|
250
|
-
|
|
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 (
|
|
255
|
-
return c
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
661
|
-
|
|
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
|