@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.
- 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/event/README.md +66 -6
- package/src/state/README.md +9 -1
package/dist/server/index.js
CHANGED
|
@@ -487,22 +487,33 @@ function createBoundedWriter(stream, maxQueue, onClose) {
|
|
|
487
487
|
var registryLogger = logger.child("@spfn/core:sse");
|
|
488
488
|
var SUBSCRIPTION_GRANTED = "subscription.granted";
|
|
489
489
|
var SUBSCRIPTION_REVOKED = "subscription.revoked";
|
|
490
|
+
var KEY_REVOKED = "key revoked";
|
|
490
491
|
function createConnectionRegistry() {
|
|
491
492
|
const bySubject = /* @__PURE__ */ new Map();
|
|
493
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
492
494
|
const pendingBySubject = /* @__PURE__ */ new Map();
|
|
493
|
-
const
|
|
495
|
+
const pendingByKey = /* @__PURE__ */ new Map();
|
|
496
|
+
const beginAdmission = (subject, keyId, signal) => {
|
|
494
497
|
const changes = [];
|
|
495
498
|
addToSet(pendingBySubject, subject, changes);
|
|
496
|
-
|
|
499
|
+
addToOptionalSet(pendingByKey, keyId, changes);
|
|
500
|
+
const end = endOnce(signal, () => {
|
|
501
|
+
removeFromSet(pendingBySubject, subject, changes);
|
|
502
|
+
removeFromOptionalSet(pendingByKey, keyId, changes);
|
|
503
|
+
});
|
|
497
504
|
return {
|
|
498
505
|
cancel: end,
|
|
499
506
|
open: (connection) => {
|
|
500
507
|
if (!end()) {
|
|
501
|
-
return
|
|
508
|
+
return "aborted during admission";
|
|
509
|
+
}
|
|
510
|
+
if (changes.some((change2) => change2.kind === "close")) {
|
|
511
|
+
return KEY_REVOKED;
|
|
502
512
|
}
|
|
503
513
|
applyRecorded(connection, changes);
|
|
504
514
|
addToSet(bySubject, subject, connection);
|
|
505
|
-
|
|
515
|
+
addToOptionalSet(byKey, connection.keyId, connection);
|
|
516
|
+
return null;
|
|
506
517
|
}
|
|
507
518
|
};
|
|
508
519
|
};
|
|
@@ -522,15 +533,25 @@ function createConnectionRegistry() {
|
|
|
522
533
|
});
|
|
523
534
|
}
|
|
524
535
|
};
|
|
536
|
+
const closeByKey = (keyId) => {
|
|
537
|
+
const connections = [...byKey.get(keyId) ?? []];
|
|
538
|
+
pendingByKey.get(keyId)?.forEach((changes) => changes.push({ kind: "close" }));
|
|
539
|
+
connections.forEach((connection) => connection.close(KEY_REVOKED));
|
|
540
|
+
return connections.length;
|
|
541
|
+
};
|
|
525
542
|
return {
|
|
526
543
|
beginAdmission,
|
|
544
|
+
closeByKey,
|
|
527
545
|
remove: (connection) => {
|
|
528
546
|
if (connection.subject) {
|
|
529
547
|
removeFromSet(bySubject, connection.subject, connection);
|
|
530
548
|
}
|
|
549
|
+
removeFromOptionalSet(byKey, connection.keyId, connection);
|
|
531
550
|
},
|
|
532
551
|
/** Open connections only — a pending admission is not counted. */
|
|
533
552
|
count: (subject) => bySubject.get(subject)?.size ?? 0,
|
|
553
|
+
/** Open connections whose token was minted with `keyId`. */
|
|
554
|
+
countByKey: (keyId) => byKey.get(keyId)?.size ?? 0,
|
|
534
555
|
pendingCount: (subject) => pendingBySubject.get(subject)?.size ?? 0,
|
|
535
556
|
grant: (subject, eventName, match) => change("grant", subject, eventName, match),
|
|
536
557
|
revoke: (subject, eventName, match) => change("revoke", subject, eventName, match)
|
|
@@ -558,6 +579,16 @@ function addToSet(map, key, value) {
|
|
|
558
579
|
set.add(value);
|
|
559
580
|
map.set(key, set);
|
|
560
581
|
}
|
|
582
|
+
function addToOptionalSet(map, key, value) {
|
|
583
|
+
if (key) {
|
|
584
|
+
addToSet(map, key, value);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function removeFromOptionalSet(map, key, value) {
|
|
588
|
+
if (key) {
|
|
589
|
+
removeFromSet(map, key, value);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
561
592
|
function removeFromSet(map, key, value) {
|
|
562
593
|
const set = map.get(key);
|
|
563
594
|
set?.delete(value);
|
|
@@ -569,10 +600,11 @@ function matchesRequest(match, request) {
|
|
|
569
600
|
return match === void 0 || Object.entries(match.query).every(([field, value]) => Object.hasOwn(request.query, field) && request.query[field] === value);
|
|
570
601
|
}
|
|
571
602
|
function applyRecorded(connection, changes) {
|
|
572
|
-
changes.forEach((
|
|
573
|
-
if (!matchesRequest(match, connection.request)) {
|
|
603
|
+
changes.forEach((change) => {
|
|
604
|
+
if (change.kind === "close" || !matchesRequest(change.match, connection.request)) {
|
|
574
605
|
return;
|
|
575
606
|
}
|
|
607
|
+
const { kind, eventName } = change;
|
|
576
608
|
const [from, to] = kind === "grant" ? [connection.denied, connection.allowed] : [connection.allowed, connection.denied];
|
|
577
609
|
if (from.delete(eventName)) {
|
|
578
610
|
to.add(eventName);
|
|
@@ -602,6 +634,12 @@ function unsubscribeAll(connection) {
|
|
|
602
634
|
connection.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
603
635
|
connection.unsubscribes.clear();
|
|
604
636
|
}
|
|
637
|
+
logger.child("@spfn/core:sse");
|
|
638
|
+
var REGISTRIES_KEY = Symbol.for("@spfn/core:sse-key-close-registries");
|
|
639
|
+
var registries = globalThis[REGISTRIES_KEY] ??= /* @__PURE__ */ new Set();
|
|
640
|
+
function registerKeyCloseRegistry(registry) {
|
|
641
|
+
registries.add(registry);
|
|
642
|
+
}
|
|
605
643
|
|
|
606
644
|
// src/event/sse/handler.ts
|
|
607
645
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
@@ -634,6 +672,7 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
634
672
|
} = config;
|
|
635
673
|
const registry = createConnectionRegistry();
|
|
636
674
|
registerSubscriptionControls(router, registry);
|
|
675
|
+
registerKeyCloseRegistry(registry);
|
|
637
676
|
const streamConfig = { router, authConfig, registry, pingInterval, maxQueue };
|
|
638
677
|
const handler = async (c) => {
|
|
639
678
|
const admission = await admitConnection(c, streamConfig, tokenManager);
|
|
@@ -655,7 +694,9 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
655
694
|
/** @internal Open registered connections of a subject (tests). */
|
|
656
695
|
_openConnections: (subject) => registry.count(subject),
|
|
657
696
|
/** @internal Connects of a subject still in admission (tests). */
|
|
658
|
-
_pendingAdmissions: (subject) => registry.pendingCount(subject)
|
|
697
|
+
_pendingAdmissions: (subject) => registry.pendingCount(subject),
|
|
698
|
+
/** @internal Open registered connections whose token was minted with a key (tests). */
|
|
699
|
+
_openConnectionsByKey: (keyId) => registry.countByKey(keyId)
|
|
659
700
|
});
|
|
660
701
|
}
|
|
661
702
|
function registerSubscriptionControls(router, registry) {
|
|
@@ -667,17 +708,37 @@ function registerSubscriptionControls(router, registry) {
|
|
|
667
708
|
}
|
|
668
709
|
}
|
|
669
710
|
async function admitConnection(c, config, tokenManager) {
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
if (subject === false) {
|
|
711
|
+
const token = await takeToken(c, tokenManager);
|
|
712
|
+
if (token === false) {
|
|
673
713
|
return c.json({ error: "Missing token parameter" }, 401);
|
|
674
714
|
}
|
|
675
|
-
if (
|
|
676
|
-
return c
|
|
715
|
+
if (token === null) {
|
|
716
|
+
return invalidToken(c);
|
|
677
717
|
}
|
|
718
|
+
return whilePending(
|
|
719
|
+
token?.subject ? config.registry.beginAdmission(token.subject, token.keyId, c.req.raw.signal) : void 0,
|
|
720
|
+
() => checkConnection(c, config, token, tokenManager)
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
function invalidToken(c) {
|
|
724
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
725
|
+
}
|
|
726
|
+
async function checkConnection(c, config, token, tokenManager) {
|
|
727
|
+
if (token && tokenManager && !await tokenManager.checkKey(token)) {
|
|
728
|
+
return invalidToken(c);
|
|
729
|
+
}
|
|
730
|
+
const subject = token?.subject;
|
|
678
731
|
if (subject) {
|
|
679
732
|
c.set("sseSubject", subject);
|
|
680
733
|
}
|
|
734
|
+
const requested = validateRequestedEvents(c, config.router);
|
|
735
|
+
if (requested instanceof Response) {
|
|
736
|
+
return requested;
|
|
737
|
+
}
|
|
738
|
+
const authorized = await authorizeConnection(c, config.router, config.authConfig, subject, requested);
|
|
739
|
+
return authorized instanceof Response ? authorized : { ...authorized, keyId: token?.keyId };
|
|
740
|
+
}
|
|
741
|
+
function validateRequestedEvents(c, router) {
|
|
681
742
|
const requested = parseRequestedEvents(c);
|
|
682
743
|
if (!requested) {
|
|
683
744
|
return c.json({ error: "Missing events parameter" }, 400);
|
|
@@ -691,10 +752,7 @@ async function admitConnection(c, config, tokenManager) {
|
|
|
691
752
|
validEvents: validEventNames
|
|
692
753
|
}, 400);
|
|
693
754
|
}
|
|
694
|
-
return
|
|
695
|
-
subject ? registry.beginAdmission(subject, c.req.raw.signal) : void 0,
|
|
696
|
-
() => authorizeConnection(c, router, authConfig, subject, requested)
|
|
697
|
-
);
|
|
755
|
+
return requested;
|
|
698
756
|
}
|
|
699
757
|
async function whilePending(pending, check) {
|
|
700
758
|
let outcome;
|
|
@@ -793,6 +851,7 @@ async function runStream(c, stream, admission, config) {
|
|
|
793
851
|
};
|
|
794
852
|
const connection = {
|
|
795
853
|
subject,
|
|
854
|
+
keyId: admission.keyId,
|
|
796
855
|
requested: admission.requested,
|
|
797
856
|
allowed: new Set(allowed),
|
|
798
857
|
denied: new Set(denied),
|
|
@@ -802,10 +861,12 @@ async function runStream(c, stream, admission, config) {
|
|
|
802
861
|
notify: (frame, eventName) => {
|
|
803
862
|
if (connectionDead) return;
|
|
804
863
|
hold.send(() => writer.enqueue({ event: frame, data: JSON.stringify({ event: eventName }) }));
|
|
805
|
-
}
|
|
864
|
+
},
|
|
865
|
+
close: cleanup
|
|
806
866
|
};
|
|
807
|
-
|
|
808
|
-
|
|
867
|
+
const refusal = admission.pending?.open(connection);
|
|
868
|
+
if (refusal) {
|
|
869
|
+
cleanup(refusal);
|
|
809
870
|
return;
|
|
810
871
|
}
|
|
811
872
|
void openSubscriptions(connection, writer, hold, {
|
|
@@ -955,7 +1016,7 @@ function passesFrameFilters(eventName, eventDef, payload, ctx) {
|
|
|
955
1016
|
return false;
|
|
956
1017
|
}
|
|
957
1018
|
}
|
|
958
|
-
async function
|
|
1019
|
+
async function takeToken(c, tokenManager) {
|
|
959
1020
|
if (!tokenManager) {
|
|
960
1021
|
return void 0;
|
|
961
1022
|
}
|
|
@@ -963,7 +1024,7 @@ async function authenticateToken(c, tokenManager) {
|
|
|
963
1024
|
if (!token) {
|
|
964
1025
|
return false;
|
|
965
1026
|
}
|
|
966
|
-
return
|
|
1027
|
+
return tokenManager.take(token);
|
|
967
1028
|
}
|
|
968
1029
|
function parseRequestedEvents(c) {
|
|
969
1030
|
const eventsParam = c.req.query("events");
|
|
@@ -988,6 +1049,7 @@ var DEFAULT_SSE_STREAM_PATH = "/events/stream";
|
|
|
988
1049
|
function sseTokenPath(streamPath) {
|
|
989
1050
|
return streamPath.replace(/\/[^/]+$/, "/token");
|
|
990
1051
|
}
|
|
1052
|
+
var tokenLogger = logger.child("@spfn/core:sse");
|
|
991
1053
|
function hashToken(token) {
|
|
992
1054
|
return createHash("sha256").update(token).digest("hex");
|
|
993
1055
|
}
|
|
@@ -1050,10 +1112,12 @@ var CacheTokenStore = class {
|
|
|
1050
1112
|
var SSETokenManager = class {
|
|
1051
1113
|
store;
|
|
1052
1114
|
ttl;
|
|
1115
|
+
verifyKey;
|
|
1053
1116
|
cleanupTimer = null;
|
|
1054
1117
|
constructor(config) {
|
|
1055
1118
|
this.ttl = config?.ttl ?? 3e4;
|
|
1056
1119
|
this.store = config?.store ?? new InMemoryTokenStore();
|
|
1120
|
+
this.verifyKey = config?.verifyKey;
|
|
1057
1121
|
const cleanupInterval = config?.cleanupInterval ?? 6e4;
|
|
1058
1122
|
this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
|
|
1059
1123
|
this.cleanupTimer.unref();
|
|
@@ -1068,13 +1132,16 @@ var SSETokenManager = class {
|
|
|
1068
1132
|
return this.ttl;
|
|
1069
1133
|
}
|
|
1070
1134
|
/**
|
|
1071
|
-
* Issue a new one-time-use token for the given subject
|
|
1135
|
+
* Issue a new one-time-use token for the given subject. `keyId` tags it
|
|
1136
|
+
* with the device key it is minted for; an empty one leaves it untagged.
|
|
1072
1137
|
*/
|
|
1073
|
-
async issue(subject) {
|
|
1138
|
+
async issue(subject, options) {
|
|
1074
1139
|
const token = randomBytes(32).toString("hex");
|
|
1140
|
+
const keyId = options?.keyId || void 0;
|
|
1075
1141
|
await this.store.set(token, {
|
|
1076
1142
|
subject,
|
|
1077
|
-
expiresAt: Date.now() + this.ttl
|
|
1143
|
+
expiresAt: Date.now() + this.ttl,
|
|
1144
|
+
...keyId ? { keyId } : {}
|
|
1078
1145
|
});
|
|
1079
1146
|
return token;
|
|
1080
1147
|
}
|
|
@@ -1083,11 +1150,43 @@ var SSETokenManager = class {
|
|
|
1083
1150
|
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
1084
1151
|
*/
|
|
1085
1152
|
async verify(token) {
|
|
1153
|
+
return (await this.consume(token))?.subject ?? null;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Consume a token and answer what it was minted with: `null` when it is
|
|
1157
|
+
* unknown, expired, already consumed, or its key no longer holds
|
|
1158
|
+
* (`verifyKey`). The token is gone either way. `take` + `checkKey`.
|
|
1159
|
+
*/
|
|
1160
|
+
async consume(token) {
|
|
1161
|
+
const data = await this.take(token);
|
|
1162
|
+
return data && await this.checkKey(data) ? data : null;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* The store half of `consume`: take the token out of the store and answer
|
|
1166
|
+
* what it was minted with, `null` when it is unknown, expired or already
|
|
1167
|
+
* consumed — its key is not checked yet (`checkKey`). For a caller that
|
|
1168
|
+
* has to register the connection before the key check, so a revocation
|
|
1169
|
+
* landing during the check reaches it.
|
|
1170
|
+
*/
|
|
1171
|
+
async take(token) {
|
|
1086
1172
|
const data = await this.store.consume(token);
|
|
1087
|
-
|
|
1088
|
-
|
|
1173
|
+
return data && data.expiresAt > Date.now() ? data : null;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* The key half of `consume`: `verifyKey` on a tagged token; an untagged
|
|
1177
|
+
* one, or no verifier, holds without a call. A throw refuses and is warned
|
|
1178
|
+
* without its text — it may carry what the verifier read.
|
|
1179
|
+
*/
|
|
1180
|
+
async checkKey(data) {
|
|
1181
|
+
if (!data.keyId || !this.verifyKey) {
|
|
1182
|
+
return true;
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
return await this.verifyKey(data.keyId) === true;
|
|
1186
|
+
} catch {
|
|
1187
|
+
tokenLogger.warn("SSE token key check threw \u2014 token refused");
|
|
1188
|
+
return false;
|
|
1089
1189
|
}
|
|
1090
|
-
return data.subject;
|
|
1091
1190
|
}
|
|
1092
1191
|
/**
|
|
1093
1192
|
* Cleanup timer and resources
|
|
@@ -1106,6 +1205,8 @@ var PUBLISH_TIMEOUT_MS = 5e3;
|
|
|
1106
1205
|
var SUBSCRIBE_TIMEOUT_MS = 5e3;
|
|
1107
1206
|
var WARN_THROTTLE_MS = 3e4;
|
|
1108
1207
|
var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
|
|
1208
|
+
var KEY_CLOSE_RETRY_MS = PUBLISH_BREAKER_COOLDOWN_MS + 500;
|
|
1209
|
+
var PENDING_KEY_CLOSE_CAP = 1e3;
|
|
1109
1210
|
function withTimeout(promise, ms, label) {
|
|
1110
1211
|
return new Promise((resolve2, reject) => {
|
|
1111
1212
|
const timer = setTimeout(() => {
|
|
@@ -1140,8 +1241,76 @@ var state = globalThis[STATE_KEY] ??= {
|
|
|
1140
1241
|
pubSubCache: void 0,
|
|
1141
1242
|
subscriber: void 0,
|
|
1142
1243
|
channelPrefix: void 0,
|
|
1143
|
-
wired: /* @__PURE__ */ new Map()
|
|
1244
|
+
wired: /* @__PURE__ */ new Map(),
|
|
1245
|
+
keyCloseDeferrals: /* @__PURE__ */ new Map()
|
|
1144
1246
|
};
|
|
1247
|
+
function createKeyCloseDeferral(resend) {
|
|
1248
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1249
|
+
let timer;
|
|
1250
|
+
let flushing = false;
|
|
1251
|
+
let disposed = false;
|
|
1252
|
+
const arm = () => {
|
|
1253
|
+
if (timer || disposed) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
timer = setTimeout(() => {
|
|
1257
|
+
timer = void 0;
|
|
1258
|
+
void flush();
|
|
1259
|
+
}, KEY_CLOSE_RETRY_MS);
|
|
1260
|
+
timer.unref?.();
|
|
1261
|
+
};
|
|
1262
|
+
const disarm = () => {
|
|
1263
|
+
clearTimeout(timer);
|
|
1264
|
+
timer = void 0;
|
|
1265
|
+
};
|
|
1266
|
+
const flush = async () => {
|
|
1267
|
+
if (flushing || pending.size === 0) {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
flushing = true;
|
|
1271
|
+
const snapshot = [...pending.values()];
|
|
1272
|
+
pending.clear();
|
|
1273
|
+
for (const message of snapshot) {
|
|
1274
|
+
await resend(message);
|
|
1275
|
+
}
|
|
1276
|
+
flushing = false;
|
|
1277
|
+
if (pending.size > 0) {
|
|
1278
|
+
arm();
|
|
1279
|
+
} else {
|
|
1280
|
+
disarm();
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
return {
|
|
1284
|
+
defer: (message) => {
|
|
1285
|
+
if (disposed) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
pending.delete(message.keyId);
|
|
1289
|
+
pending.set(message.keyId, message);
|
|
1290
|
+
dropOldestPastCap(pending);
|
|
1291
|
+
throttledWarn("key-close-deferred", "SSE key close deferred \u2014 re-sent when the pub/sub transport recovers", {
|
|
1292
|
+
channel: CONTROL_CHANNEL,
|
|
1293
|
+
pending: pending.size
|
|
1294
|
+
});
|
|
1295
|
+
arm();
|
|
1296
|
+
},
|
|
1297
|
+
flush,
|
|
1298
|
+
dispose: () => {
|
|
1299
|
+
disposed = true;
|
|
1300
|
+
disarm();
|
|
1301
|
+
pending.clear();
|
|
1302
|
+
},
|
|
1303
|
+
keyIds: () => [...pending.keys()]
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function dropOldestPastCap(pending) {
|
|
1307
|
+
const dropped = pending.size - PENDING_KEY_CLOSE_CAP;
|
|
1308
|
+
if (dropped <= 0) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
[...pending.keys()].slice(0, dropped).forEach((keyId) => pending.delete(keyId));
|
|
1312
|
+
throttledWarn("key-close-dropped", "SSE key closes dropped \u2014 too many waiting to be re-sent", { dropped });
|
|
1313
|
+
}
|
|
1145
1314
|
function resolveChannelPrefix(override) {
|
|
1146
1315
|
return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
|
|
1147
1316
|
}
|
|
@@ -1181,7 +1350,13 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
|
|
|
1181
1350
|
void handler(message);
|
|
1182
1351
|
}
|
|
1183
1352
|
};
|
|
1184
|
-
|
|
1353
|
+
let keyCloses;
|
|
1354
|
+
const deferIfKeyClose = (name, message) => {
|
|
1355
|
+
if (name === CONTROL_CHANNEL && isKeyCloseMessage(message)) {
|
|
1356
|
+
keyCloses.defer(message);
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
const cache2 = {
|
|
1185
1360
|
publish: async (name, message) => {
|
|
1186
1361
|
const channel = channelPrefix + name;
|
|
1187
1362
|
let serialized;
|
|
@@ -1205,6 +1380,7 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
|
|
|
1205
1380
|
const ready = client.status === void 0 || client.status === "ready";
|
|
1206
1381
|
const now = Date.now();
|
|
1207
1382
|
if (!ready || degradedUntil > 0 && now < degradedUntil) {
|
|
1383
|
+
deferIfKeyClose(name, message);
|
|
1208
1384
|
deliverLocal(channel, JSON.parse(serialized));
|
|
1209
1385
|
return;
|
|
1210
1386
|
}
|
|
@@ -1214,18 +1390,17 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
|
|
|
1214
1390
|
try {
|
|
1215
1391
|
await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
|
|
1216
1392
|
degradedUntil = 0;
|
|
1393
|
+
void keyCloses.flush();
|
|
1217
1394
|
if (!subscriberHealthy()) {
|
|
1218
1395
|
throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
|
|
1219
1396
|
channel
|
|
1220
1397
|
});
|
|
1221
1398
|
deliverLocal(channel, JSON.parse(serialized));
|
|
1222
1399
|
}
|
|
1223
|
-
} catch
|
|
1400
|
+
} catch {
|
|
1224
1401
|
degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
|
|
1225
|
-
throttledWarn(
|
|
1226
|
-
|
|
1227
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1228
|
-
});
|
|
1402
|
+
throttledWarn(`publish-failed:${channel}`, "Pub/sub publish failed \u2014 local delivery only", { channel });
|
|
1403
|
+
deferIfKeyClose(name, message);
|
|
1229
1404
|
deliverLocal(channel, JSON.parse(serialized));
|
|
1230
1405
|
}
|
|
1231
1406
|
},
|
|
@@ -1244,6 +1419,14 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
|
|
|
1244
1419
|
handlers.set(channel, handler);
|
|
1245
1420
|
}
|
|
1246
1421
|
};
|
|
1422
|
+
keyCloses = createKeyCloseDeferral((message) => cache2.publish(CONTROL_CHANNEL, message));
|
|
1423
|
+
state.keyCloseDeferrals.set(cache2, keyCloses);
|
|
1424
|
+
return cache2;
|
|
1425
|
+
}
|
|
1426
|
+
var CONTROL_CHANNEL = "$control";
|
|
1427
|
+
function isKeyCloseMessage(message) {
|
|
1428
|
+
const candidate = message;
|
|
1429
|
+
return !!candidate && candidate.kind === "close" && typeof candidate.keyId === "string" && candidate.keyId !== "" && typeof candidate.origin === "string";
|
|
1247
1430
|
}
|
|
1248
1431
|
async function wireEventRouterCache(router, options = {}) {
|
|
1249
1432
|
if (options.multiInstance === false) {
|
|
@@ -1341,6 +1524,8 @@ async function closeEventTransport() {
|
|
|
1341
1524
|
for (const event of state.wired.values()) {
|
|
1342
1525
|
event._resetCache?.();
|
|
1343
1526
|
}
|
|
1527
|
+
state.keyCloseDeferrals.forEach((deferral) => deferral.dispose());
|
|
1528
|
+
state.keyCloseDeferrals.clear();
|
|
1344
1529
|
state.subscriber = void 0;
|
|
1345
1530
|
state.pubSubCache = void 0;
|
|
1346
1531
|
state.channelPrefix = void 0;
|
|
@@ -2573,12 +2758,13 @@ async function registerSSEEndpoint(app, config) {
|
|
|
2573
2758
|
const tokenPath = sseTokenPath(streamPath);
|
|
2574
2759
|
const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
|
|
2575
2760
|
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
2761
|
+
const getKeyId = authConfig.getKeyId ?? ((c) => c.get("auth")?.keyId);
|
|
2576
2762
|
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
2577
2763
|
const subject = getSubject(c);
|
|
2578
2764
|
if (!subject) {
|
|
2579
2765
|
return c.json({ error: "Unable to identify subject" }, 401);
|
|
2580
2766
|
}
|
|
2581
|
-
const token = await tokenManager.issue(subject);
|
|
2767
|
+
const token = await tokenManager.issue(subject, { keyId: getKeyId(c) ?? void 0 });
|
|
2582
2768
|
return c.json({ token });
|
|
2583
2769
|
});
|
|
2584
2770
|
if (debug) {
|
|
@@ -3318,10 +3504,10 @@ function readPort(value) {
|
|
|
3318
3504
|
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
3319
3505
|
}
|
|
3320
3506
|
logger.child("@spfn/core:env-registry");
|
|
3321
|
-
function validateAllEnv(
|
|
3507
|
+
function validateAllEnv(registries2) {
|
|
3322
3508
|
const errors = [];
|
|
3323
3509
|
const warnings = [];
|
|
3324
|
-
for (const registry of
|
|
3510
|
+
for (const registry of registries2) {
|
|
3325
3511
|
const result = registry.validateAll();
|
|
3326
3512
|
errors.push(...result.errors);
|
|
3327
3513
|
warnings.push(...result.warnings);
|