@camstack/sdk 1.2.133 → 1.2.135
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +196 -119
- package/dist/index.d.ts +1 -1
- package/dist/index.js +197 -120
- package/dist/system.d.ts +34 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -654,6 +654,119 @@ SuperJSON.registerCustom;
|
|
|
654
654
|
SuperJSON.registerSymbol;
|
|
655
655
|
SuperJSON.allowErrorProps;
|
|
656
656
|
//#endregion
|
|
657
|
+
//#region src/session-keeper.ts
|
|
658
|
+
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
659
|
+
var RETRY_MS = 3e4;
|
|
660
|
+
var MIN_DELAY_MS = 1e3;
|
|
661
|
+
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
662
|
+
function jwtExpiresAt(token) {
|
|
663
|
+
const part = token.split(".")[1];
|
|
664
|
+
if (part === void 0) return null;
|
|
665
|
+
try {
|
|
666
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
667
|
+
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
668
|
+
const payload = JSON.parse(json);
|
|
669
|
+
if (payload === null || typeof payload !== "object") return null;
|
|
670
|
+
const exp = Reflect.get(payload, "exp");
|
|
671
|
+
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
672
|
+
} catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
677
|
+
function isSessionRefusal(error) {
|
|
678
|
+
if (error === null || typeof error !== "object") return false;
|
|
679
|
+
const data = Reflect.get(error, "data");
|
|
680
|
+
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
681
|
+
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
682
|
+
}
|
|
683
|
+
var SessionKeeper = class {
|
|
684
|
+
options;
|
|
685
|
+
current = null;
|
|
686
|
+
cancel = null;
|
|
687
|
+
inflight = null;
|
|
688
|
+
now;
|
|
689
|
+
setTimer;
|
|
690
|
+
constructor(options) {
|
|
691
|
+
this.options = options;
|
|
692
|
+
this.now = options.now ?? Date.now;
|
|
693
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
694
|
+
const handle = setTimeout(fn, ms);
|
|
695
|
+
return () => clearTimeout(handle);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
699
|
+
adopt(tokens) {
|
|
700
|
+
this.current = tokens;
|
|
701
|
+
this.schedule();
|
|
702
|
+
}
|
|
703
|
+
/** The tokens the keeper holds now. */
|
|
704
|
+
tokens() {
|
|
705
|
+
return this.current;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Refresh now if the access token expires within `marginMs` (default: the
|
|
709
|
+
* keeper's margin) — call it before reconnecting after the app was in the
|
|
710
|
+
* background. Resolves when done; concurrent callers share one refresh.
|
|
711
|
+
*/
|
|
712
|
+
async ensureFresh(marginMs = this.margin()) {
|
|
713
|
+
const current = this.current;
|
|
714
|
+
if (current?.refreshToken === void 0) return;
|
|
715
|
+
const expiresAt = this.expiresAtOf(current);
|
|
716
|
+
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
717
|
+
await this.refreshNow();
|
|
718
|
+
}
|
|
719
|
+
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
720
|
+
refreshNow() {
|
|
721
|
+
if (this.inflight !== null) return this.inflight;
|
|
722
|
+
const refreshToken = this.current?.refreshToken;
|
|
723
|
+
if (refreshToken === void 0) return Promise.resolve(null);
|
|
724
|
+
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
725
|
+
this.current = next;
|
|
726
|
+
this.options.onTokens(next);
|
|
727
|
+
this.schedule();
|
|
728
|
+
return next;
|
|
729
|
+
}).catch((error) => {
|
|
730
|
+
if (isSessionRefusal(error)) {
|
|
731
|
+
this.stop();
|
|
732
|
+
this.current = null;
|
|
733
|
+
this.options.onExpired();
|
|
734
|
+
} else this.arm(RETRY_MS);
|
|
735
|
+
return null;
|
|
736
|
+
}).finally(() => {
|
|
737
|
+
this.inflight = null;
|
|
738
|
+
});
|
|
739
|
+
return this.inflight;
|
|
740
|
+
}
|
|
741
|
+
stop() {
|
|
742
|
+
this.cancel?.();
|
|
743
|
+
this.cancel = null;
|
|
744
|
+
}
|
|
745
|
+
margin() {
|
|
746
|
+
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
747
|
+
}
|
|
748
|
+
expiresAtOf(tokens) {
|
|
749
|
+
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
750
|
+
}
|
|
751
|
+
schedule() {
|
|
752
|
+
this.stop();
|
|
753
|
+
const current = this.current;
|
|
754
|
+
if (current?.refreshToken === void 0) return;
|
|
755
|
+
const expiresAt = this.expiresAtOf(current);
|
|
756
|
+
if (expiresAt === null) return;
|
|
757
|
+
const remaining = expiresAt - this.now();
|
|
758
|
+
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
759
|
+
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
760
|
+
}
|
|
761
|
+
arm(ms) {
|
|
762
|
+
this.stop();
|
|
763
|
+
this.cancel = this.setTimer(() => {
|
|
764
|
+
this.cancel = null;
|
|
765
|
+
this.refreshNow();
|
|
766
|
+
}, ms);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
//#endregion
|
|
657
770
|
//#region src/system.ts
|
|
658
771
|
/**
|
|
659
772
|
* System — single, unified entry point for the camstack client API.
|
|
@@ -673,6 +786,27 @@ function refreshFlag(options) {
|
|
|
673
786
|
var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
|
|
674
787
|
var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
|
|
675
788
|
var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
|
|
789
|
+
/** Close code the hub sends a socket whose bearer resolved to nobody. */
|
|
790
|
+
var SESSION_ENDED_CLOSE_CODE = 4401;
|
|
791
|
+
/** A token this close to its `exp` is refreshed before it rides a handshake. */
|
|
792
|
+
var HANDSHAKE_EXPIRY_SKEW_MS = 15e3;
|
|
793
|
+
/**
|
|
794
|
+
* Procedures a client calls BEFORE it holds a usable credential (login legs,
|
|
795
|
+
* the refresh itself, the login-method list). They run over anonymous HTTP:
|
|
796
|
+
* the socket answers a dead bearer by closing, and these calls are exactly
|
|
797
|
+
* what recovers from one.
|
|
798
|
+
*/
|
|
799
|
+
var PRE_AUTH_PATHS = new Set([
|
|
800
|
+
"auth.refreshSession",
|
|
801
|
+
"auth.listLoginMethods",
|
|
802
|
+
"auth.getServerName",
|
|
803
|
+
"auth.login",
|
|
804
|
+
"auth.loginVerifyTotp",
|
|
805
|
+
"auth.loginBeginPasskey",
|
|
806
|
+
"auth.loginVerifyPasskey",
|
|
807
|
+
"auth.passkeyLoginBegin",
|
|
808
|
+
"auth.passkeyLoginFinish"
|
|
809
|
+
]);
|
|
676
810
|
var WS_RECONNECT_MAX_RETRY_DELAY_MS = 3e4;
|
|
677
811
|
var System = class {
|
|
678
812
|
/** Active server base URL. Mutable via `switchServerUrl()` so the
|
|
@@ -685,6 +819,7 @@ var System = class {
|
|
|
685
819
|
onConnectionChange;
|
|
686
820
|
token;
|
|
687
821
|
getLanguage;
|
|
822
|
+
refreshCredential;
|
|
688
823
|
_trpcClient;
|
|
689
824
|
_wsClient = null;
|
|
690
825
|
/**
|
|
@@ -701,6 +836,13 @@ var System = class {
|
|
|
701
836
|
mirror = null;
|
|
702
837
|
mirrorInit = null;
|
|
703
838
|
connected = false;
|
|
839
|
+
/**
|
|
840
|
+
* The token the current socket's handshake carried (`undefined` token =
|
|
841
|
+
* anonymous handshake); `null` until a handshake has been attempted.
|
|
842
|
+
*/
|
|
843
|
+
_wsHandshake = null;
|
|
844
|
+
/** The token the hub closed a socket for (4401): never handshaken again. */
|
|
845
|
+
_rejectedToken;
|
|
704
846
|
connectedPromise = null;
|
|
705
847
|
_systemProxy;
|
|
706
848
|
_connectionVersion = 0;
|
|
@@ -716,6 +858,7 @@ var System = class {
|
|
|
716
858
|
this._serverUrl = config.serverUrl.replace(/\/+$/, "");
|
|
717
859
|
this.token = config.token;
|
|
718
860
|
this.getLanguage = config.getLanguage ?? (() => void 0);
|
|
861
|
+
this.refreshCredential = config.refreshCredential;
|
|
719
862
|
const isBrowser = typeof window !== "undefined";
|
|
720
863
|
this.useWs = config.useWebSocket ?? isBrowser;
|
|
721
864
|
this.onConnectionChange = config.onConnectionChange;
|
|
@@ -853,6 +996,7 @@ var System = class {
|
|
|
853
996
|
this.resetEventSubs();
|
|
854
997
|
this.connected = false;
|
|
855
998
|
this.connectedPromise = null;
|
|
999
|
+
this._wsHandshake = null;
|
|
856
1000
|
this._trpcClient = this.buildTrpcClient();
|
|
857
1001
|
this._systemProxy = (0, _camstack_types_client.createSystemProxy)(this._trpcClient);
|
|
858
1002
|
}
|
|
@@ -1123,6 +1267,16 @@ var System = class {
|
|
|
1123
1267
|
this.token = token;
|
|
1124
1268
|
}
|
|
1125
1269
|
/**
|
|
1270
|
+
* True when the live WebSocket was handshaken with a token other than the
|
|
1271
|
+
* current one (anonymous, or rotated since). The socket authenticates once,
|
|
1272
|
+
* at the handshake, so such a socket answers every protected call
|
|
1273
|
+
* UNAUTHORIZED until `reconnect()`. Always false for HTTP transport.
|
|
1274
|
+
*/
|
|
1275
|
+
isSocketTokenStale() {
|
|
1276
|
+
if (!this.useWs || this._wsHandshake === null) return false;
|
|
1277
|
+
return this._wsHandshake.token !== this.token;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1126
1280
|
* Synchronous snapshot of every device matching the optional filters.
|
|
1127
1281
|
* Backed by the `SystemMirror` warm-boot cache — call `init()` first
|
|
1128
1282
|
* (or `awaitReady()`) before invoking. Returns an empty array if the
|
|
@@ -1352,6 +1506,27 @@ var System = class {
|
|
|
1352
1506
|
get wsClient() {
|
|
1353
1507
|
return this._wsClient;
|
|
1354
1508
|
}
|
|
1509
|
+
/**
|
|
1510
|
+
* The token the next socket handshake may carry. An expired token, or one
|
|
1511
|
+
* the hub just closed with 4401, is never sent: the credential is refreshed
|
|
1512
|
+
* over HTTP first, and when that does not yield a usable token the
|
|
1513
|
+
* handshake is anonymous (the app learns of a refused refresh from its
|
|
1514
|
+
* session keeper and routes to login; a transient failure retries later).
|
|
1515
|
+
*/
|
|
1516
|
+
async handshakeToken() {
|
|
1517
|
+
const dead = (token) => {
|
|
1518
|
+
if (token === this._rejectedToken) return true;
|
|
1519
|
+
const exp = jwtExpiresAt(token);
|
|
1520
|
+
return exp !== null && exp - Date.now() <= HANDSHAKE_EXPIRY_SKEW_MS;
|
|
1521
|
+
};
|
|
1522
|
+
const initial = this.token;
|
|
1523
|
+
if (initial === void 0 || !dead(initial)) return initial;
|
|
1524
|
+
if (this.refreshCredential !== void 0) try {
|
|
1525
|
+
await this.refreshCredential(initial === this._rejectedToken ? "rejected" : "expired");
|
|
1526
|
+
} catch {}
|
|
1527
|
+
const current = this.token;
|
|
1528
|
+
return current !== void 0 && !dead(current) ? current : void 0;
|
|
1529
|
+
}
|
|
1355
1530
|
buildTrpcClient() {
|
|
1356
1531
|
const headers = () => {
|
|
1357
1532
|
const h = {};
|
|
@@ -1367,10 +1542,12 @@ var System = class {
|
|
|
1367
1542
|
const epoch = this._wsEpoch;
|
|
1368
1543
|
this._wsClient = (0, _trpc_client.createWSClient)({
|
|
1369
1544
|
url: wsUrl,
|
|
1370
|
-
connectionParams: () => {
|
|
1545
|
+
connectionParams: async () => {
|
|
1546
|
+
const token = await this.handshakeToken();
|
|
1547
|
+
if (epoch === this._wsEpoch) this._wsHandshake = { token };
|
|
1371
1548
|
const lang = this.getLanguage();
|
|
1372
1549
|
return {
|
|
1373
|
-
...
|
|
1550
|
+
...token !== void 0 ? { token } : {},
|
|
1374
1551
|
...lang ? { lang } : {}
|
|
1375
1552
|
};
|
|
1376
1553
|
},
|
|
@@ -1385,14 +1562,27 @@ var System = class {
|
|
|
1385
1562
|
this._connectionVersion += 1;
|
|
1386
1563
|
this.emitConnectionEvent("connected");
|
|
1387
1564
|
},
|
|
1388
|
-
onClose: () => {
|
|
1565
|
+
onClose: (cause) => {
|
|
1389
1566
|
if (epoch !== this._wsEpoch) return;
|
|
1567
|
+
if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
|
|
1390
1568
|
this.emitConnectionEvent("disconnected");
|
|
1391
1569
|
}
|
|
1392
1570
|
});
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1571
|
+
const anonymousHeaders = () => {
|
|
1572
|
+
const lang = this.getLanguage();
|
|
1573
|
+
return lang ? { "x-camstack-lang": lang } : {};
|
|
1574
|
+
};
|
|
1575
|
+
return (0, _trpc_client.createTRPCClient)({ links: [(0, _trpc_client.splitLink)({
|
|
1576
|
+
condition: (op) => PRE_AUTH_PATHS.has(op.path),
|
|
1577
|
+
true: (0, _trpc_client.httpLink)({
|
|
1578
|
+
url: `${this._serverUrl}/trpc`,
|
|
1579
|
+
headers: anonymousHeaders,
|
|
1580
|
+
transformer: SuperJSON
|
|
1581
|
+
}),
|
|
1582
|
+
false: (0, _trpc_client.wsLink)({
|
|
1583
|
+
client: this._wsClient,
|
|
1584
|
+
transformer: SuperJSON
|
|
1585
|
+
})
|
|
1396
1586
|
})] });
|
|
1397
1587
|
}
|
|
1398
1588
|
this._wsClient = null;
|
|
@@ -1461,119 +1651,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
|
|
|
1461
1651
|
}
|
|
1462
1652
|
}
|
|
1463
1653
|
//#endregion
|
|
1464
|
-
//#region src/session-keeper.ts
|
|
1465
|
-
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
1466
|
-
var RETRY_MS = 3e4;
|
|
1467
|
-
var MIN_DELAY_MS = 1e3;
|
|
1468
|
-
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
1469
|
-
function jwtExpiresAt(token) {
|
|
1470
|
-
const part = token.split(".")[1];
|
|
1471
|
-
if (part === void 0) return null;
|
|
1472
|
-
try {
|
|
1473
|
-
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
1474
|
-
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
1475
|
-
const payload = JSON.parse(json);
|
|
1476
|
-
if (payload === null || typeof payload !== "object") return null;
|
|
1477
|
-
const exp = Reflect.get(payload, "exp");
|
|
1478
|
-
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
1479
|
-
} catch {
|
|
1480
|
-
return null;
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
1484
|
-
function isSessionRefusal(error) {
|
|
1485
|
-
if (error === null || typeof error !== "object") return false;
|
|
1486
|
-
const data = Reflect.get(error, "data");
|
|
1487
|
-
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
1488
|
-
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
1489
|
-
}
|
|
1490
|
-
var SessionKeeper = class {
|
|
1491
|
-
options;
|
|
1492
|
-
current = null;
|
|
1493
|
-
cancel = null;
|
|
1494
|
-
inflight = null;
|
|
1495
|
-
now;
|
|
1496
|
-
setTimer;
|
|
1497
|
-
constructor(options) {
|
|
1498
|
-
this.options = options;
|
|
1499
|
-
this.now = options.now ?? Date.now;
|
|
1500
|
-
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
1501
|
-
const handle = setTimeout(fn, ms);
|
|
1502
|
-
return () => clearTimeout(handle);
|
|
1503
|
-
});
|
|
1504
|
-
}
|
|
1505
|
-
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
1506
|
-
adopt(tokens) {
|
|
1507
|
-
this.current = tokens;
|
|
1508
|
-
this.schedule();
|
|
1509
|
-
}
|
|
1510
|
-
/** The tokens the keeper holds now. */
|
|
1511
|
-
tokens() {
|
|
1512
|
-
return this.current;
|
|
1513
|
-
}
|
|
1514
|
-
/**
|
|
1515
|
-
* Refresh now if the access token expires within `marginMs` (default: the
|
|
1516
|
-
* keeper's margin) — call it before reconnecting after the app was in the
|
|
1517
|
-
* background. Resolves when done; concurrent callers share one refresh.
|
|
1518
|
-
*/
|
|
1519
|
-
async ensureFresh(marginMs = this.margin()) {
|
|
1520
|
-
const current = this.current;
|
|
1521
|
-
if (current?.refreshToken === void 0) return;
|
|
1522
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1523
|
-
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
1524
|
-
await this.refreshNow();
|
|
1525
|
-
}
|
|
1526
|
-
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
1527
|
-
refreshNow() {
|
|
1528
|
-
if (this.inflight !== null) return this.inflight;
|
|
1529
|
-
const refreshToken = this.current?.refreshToken;
|
|
1530
|
-
if (refreshToken === void 0) return Promise.resolve(null);
|
|
1531
|
-
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
1532
|
-
this.current = next;
|
|
1533
|
-
this.options.onTokens(next);
|
|
1534
|
-
this.schedule();
|
|
1535
|
-
return next;
|
|
1536
|
-
}).catch((error) => {
|
|
1537
|
-
if (isSessionRefusal(error)) {
|
|
1538
|
-
this.stop();
|
|
1539
|
-
this.current = null;
|
|
1540
|
-
this.options.onExpired();
|
|
1541
|
-
} else this.arm(RETRY_MS);
|
|
1542
|
-
return null;
|
|
1543
|
-
}).finally(() => {
|
|
1544
|
-
this.inflight = null;
|
|
1545
|
-
});
|
|
1546
|
-
return this.inflight;
|
|
1547
|
-
}
|
|
1548
|
-
stop() {
|
|
1549
|
-
this.cancel?.();
|
|
1550
|
-
this.cancel = null;
|
|
1551
|
-
}
|
|
1552
|
-
margin() {
|
|
1553
|
-
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
1554
|
-
}
|
|
1555
|
-
expiresAtOf(tokens) {
|
|
1556
|
-
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
1557
|
-
}
|
|
1558
|
-
schedule() {
|
|
1559
|
-
this.stop();
|
|
1560
|
-
const current = this.current;
|
|
1561
|
-
if (current?.refreshToken === void 0) return;
|
|
1562
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1563
|
-
if (expiresAt === null) return;
|
|
1564
|
-
const remaining = expiresAt - this.now();
|
|
1565
|
-
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
1566
|
-
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
1567
|
-
}
|
|
1568
|
-
arm(ms) {
|
|
1569
|
-
this.stop();
|
|
1570
|
-
this.cancel = this.setTimer(() => {
|
|
1571
|
-
this.cancel = null;
|
|
1572
|
-
this.refreshNow();
|
|
1573
|
-
}, ms);
|
|
1574
|
-
}
|
|
1575
|
-
};
|
|
1576
|
-
//#endregion
|
|
1577
1654
|
//#region src/detection.ts
|
|
1578
1655
|
/**
|
|
1579
1656
|
* Detection classes — shared between CamStack app and proxy.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { System, createSystem, raceFastestEndpoint } from "./system.js";
|
|
2
2
|
export type { LoginOptions, LoginOutcome, RefreshedSession } from "./system.js";
|
|
3
3
|
export { isSessionRefusal, jwtExpiresAt, SessionKeeper, type SessionKeeperOptions, type SessionTokens } from "./session-keeper.js";
|
|
4
|
-
export type { SystemConfig, UserInfo, SystemLiveEvent, SystemLiveEventListener, SecondFactorKind } from "./system.js";
|
|
4
|
+
export type { SystemConfig, CredentialRefreshReason, UserInfo, SystemLiveEvent, SystemLiveEventListener, SecondFactorKind } from "./system.js";
|
|
5
5
|
export type { DeviceInfo } from "@camstack/types";
|
|
6
6
|
export type { BackendAppRouter } from "./backend-router.js";
|
|
7
7
|
export type { BackendAppRouter as AppRouter } from "./backend-router.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createTRPCClient, createWSClient, httpLink, wsLink } from "@trpc/client";
|
|
1
|
+
import { createTRPCClient, createWSClient, httpLink, splitLink, wsLink } from "@trpc/client";
|
|
2
2
|
import { SystemMirror, createSystemProxy } from "@camstack/types/client";
|
|
3
3
|
//#region ../../node_modules/superjson/dist/double-indexed-kv.js
|
|
4
4
|
var DoubleIndexedKV = class {
|
|
@@ -653,6 +653,119 @@ SuperJSON.registerCustom;
|
|
|
653
653
|
SuperJSON.registerSymbol;
|
|
654
654
|
SuperJSON.allowErrorProps;
|
|
655
655
|
//#endregion
|
|
656
|
+
//#region src/session-keeper.ts
|
|
657
|
+
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
658
|
+
var RETRY_MS = 3e4;
|
|
659
|
+
var MIN_DELAY_MS = 1e3;
|
|
660
|
+
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
661
|
+
function jwtExpiresAt(token) {
|
|
662
|
+
const part = token.split(".")[1];
|
|
663
|
+
if (part === void 0) return null;
|
|
664
|
+
try {
|
|
665
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
666
|
+
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
667
|
+
const payload = JSON.parse(json);
|
|
668
|
+
if (payload === null || typeof payload !== "object") return null;
|
|
669
|
+
const exp = Reflect.get(payload, "exp");
|
|
670
|
+
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
676
|
+
function isSessionRefusal(error) {
|
|
677
|
+
if (error === null || typeof error !== "object") return false;
|
|
678
|
+
const data = Reflect.get(error, "data");
|
|
679
|
+
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
680
|
+
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
681
|
+
}
|
|
682
|
+
var SessionKeeper = class {
|
|
683
|
+
options;
|
|
684
|
+
current = null;
|
|
685
|
+
cancel = null;
|
|
686
|
+
inflight = null;
|
|
687
|
+
now;
|
|
688
|
+
setTimer;
|
|
689
|
+
constructor(options) {
|
|
690
|
+
this.options = options;
|
|
691
|
+
this.now = options.now ?? Date.now;
|
|
692
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
693
|
+
const handle = setTimeout(fn, ms);
|
|
694
|
+
return () => clearTimeout(handle);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
698
|
+
adopt(tokens) {
|
|
699
|
+
this.current = tokens;
|
|
700
|
+
this.schedule();
|
|
701
|
+
}
|
|
702
|
+
/** The tokens the keeper holds now. */
|
|
703
|
+
tokens() {
|
|
704
|
+
return this.current;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Refresh now if the access token expires within `marginMs` (default: the
|
|
708
|
+
* keeper's margin) — call it before reconnecting after the app was in the
|
|
709
|
+
* background. Resolves when done; concurrent callers share one refresh.
|
|
710
|
+
*/
|
|
711
|
+
async ensureFresh(marginMs = this.margin()) {
|
|
712
|
+
const current = this.current;
|
|
713
|
+
if (current?.refreshToken === void 0) return;
|
|
714
|
+
const expiresAt = this.expiresAtOf(current);
|
|
715
|
+
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
716
|
+
await this.refreshNow();
|
|
717
|
+
}
|
|
718
|
+
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
719
|
+
refreshNow() {
|
|
720
|
+
if (this.inflight !== null) return this.inflight;
|
|
721
|
+
const refreshToken = this.current?.refreshToken;
|
|
722
|
+
if (refreshToken === void 0) return Promise.resolve(null);
|
|
723
|
+
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
724
|
+
this.current = next;
|
|
725
|
+
this.options.onTokens(next);
|
|
726
|
+
this.schedule();
|
|
727
|
+
return next;
|
|
728
|
+
}).catch((error) => {
|
|
729
|
+
if (isSessionRefusal(error)) {
|
|
730
|
+
this.stop();
|
|
731
|
+
this.current = null;
|
|
732
|
+
this.options.onExpired();
|
|
733
|
+
} else this.arm(RETRY_MS);
|
|
734
|
+
return null;
|
|
735
|
+
}).finally(() => {
|
|
736
|
+
this.inflight = null;
|
|
737
|
+
});
|
|
738
|
+
return this.inflight;
|
|
739
|
+
}
|
|
740
|
+
stop() {
|
|
741
|
+
this.cancel?.();
|
|
742
|
+
this.cancel = null;
|
|
743
|
+
}
|
|
744
|
+
margin() {
|
|
745
|
+
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
746
|
+
}
|
|
747
|
+
expiresAtOf(tokens) {
|
|
748
|
+
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
749
|
+
}
|
|
750
|
+
schedule() {
|
|
751
|
+
this.stop();
|
|
752
|
+
const current = this.current;
|
|
753
|
+
if (current?.refreshToken === void 0) return;
|
|
754
|
+
const expiresAt = this.expiresAtOf(current);
|
|
755
|
+
if (expiresAt === null) return;
|
|
756
|
+
const remaining = expiresAt - this.now();
|
|
757
|
+
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
758
|
+
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
759
|
+
}
|
|
760
|
+
arm(ms) {
|
|
761
|
+
this.stop();
|
|
762
|
+
this.cancel = this.setTimer(() => {
|
|
763
|
+
this.cancel = null;
|
|
764
|
+
this.refreshNow();
|
|
765
|
+
}, ms);
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
//#endregion
|
|
656
769
|
//#region src/system.ts
|
|
657
770
|
/**
|
|
658
771
|
* System — single, unified entry point for the camstack client API.
|
|
@@ -672,6 +785,27 @@ function refreshFlag(options) {
|
|
|
672
785
|
var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
|
|
673
786
|
var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
|
|
674
787
|
var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
|
|
788
|
+
/** Close code the hub sends a socket whose bearer resolved to nobody. */
|
|
789
|
+
var SESSION_ENDED_CLOSE_CODE = 4401;
|
|
790
|
+
/** A token this close to its `exp` is refreshed before it rides a handshake. */
|
|
791
|
+
var HANDSHAKE_EXPIRY_SKEW_MS = 15e3;
|
|
792
|
+
/**
|
|
793
|
+
* Procedures a client calls BEFORE it holds a usable credential (login legs,
|
|
794
|
+
* the refresh itself, the login-method list). They run over anonymous HTTP:
|
|
795
|
+
* the socket answers a dead bearer by closing, and these calls are exactly
|
|
796
|
+
* what recovers from one.
|
|
797
|
+
*/
|
|
798
|
+
var PRE_AUTH_PATHS = new Set([
|
|
799
|
+
"auth.refreshSession",
|
|
800
|
+
"auth.listLoginMethods",
|
|
801
|
+
"auth.getServerName",
|
|
802
|
+
"auth.login",
|
|
803
|
+
"auth.loginVerifyTotp",
|
|
804
|
+
"auth.loginBeginPasskey",
|
|
805
|
+
"auth.loginVerifyPasskey",
|
|
806
|
+
"auth.passkeyLoginBegin",
|
|
807
|
+
"auth.passkeyLoginFinish"
|
|
808
|
+
]);
|
|
675
809
|
var WS_RECONNECT_MAX_RETRY_DELAY_MS = 3e4;
|
|
676
810
|
var System = class {
|
|
677
811
|
/** Active server base URL. Mutable via `switchServerUrl()` so the
|
|
@@ -684,6 +818,7 @@ var System = class {
|
|
|
684
818
|
onConnectionChange;
|
|
685
819
|
token;
|
|
686
820
|
getLanguage;
|
|
821
|
+
refreshCredential;
|
|
687
822
|
_trpcClient;
|
|
688
823
|
_wsClient = null;
|
|
689
824
|
/**
|
|
@@ -700,6 +835,13 @@ var System = class {
|
|
|
700
835
|
mirror = null;
|
|
701
836
|
mirrorInit = null;
|
|
702
837
|
connected = false;
|
|
838
|
+
/**
|
|
839
|
+
* The token the current socket's handshake carried (`undefined` token =
|
|
840
|
+
* anonymous handshake); `null` until a handshake has been attempted.
|
|
841
|
+
*/
|
|
842
|
+
_wsHandshake = null;
|
|
843
|
+
/** The token the hub closed a socket for (4401): never handshaken again. */
|
|
844
|
+
_rejectedToken;
|
|
703
845
|
connectedPromise = null;
|
|
704
846
|
_systemProxy;
|
|
705
847
|
_connectionVersion = 0;
|
|
@@ -715,6 +857,7 @@ var System = class {
|
|
|
715
857
|
this._serverUrl = config.serverUrl.replace(/\/+$/, "");
|
|
716
858
|
this.token = config.token;
|
|
717
859
|
this.getLanguage = config.getLanguage ?? (() => void 0);
|
|
860
|
+
this.refreshCredential = config.refreshCredential;
|
|
718
861
|
const isBrowser = typeof window !== "undefined";
|
|
719
862
|
this.useWs = config.useWebSocket ?? isBrowser;
|
|
720
863
|
this.onConnectionChange = config.onConnectionChange;
|
|
@@ -852,6 +995,7 @@ var System = class {
|
|
|
852
995
|
this.resetEventSubs();
|
|
853
996
|
this.connected = false;
|
|
854
997
|
this.connectedPromise = null;
|
|
998
|
+
this._wsHandshake = null;
|
|
855
999
|
this._trpcClient = this.buildTrpcClient();
|
|
856
1000
|
this._systemProxy = createSystemProxy(this._trpcClient);
|
|
857
1001
|
}
|
|
@@ -1122,6 +1266,16 @@ var System = class {
|
|
|
1122
1266
|
this.token = token;
|
|
1123
1267
|
}
|
|
1124
1268
|
/**
|
|
1269
|
+
* True when the live WebSocket was handshaken with a token other than the
|
|
1270
|
+
* current one (anonymous, or rotated since). The socket authenticates once,
|
|
1271
|
+
* at the handshake, so such a socket answers every protected call
|
|
1272
|
+
* UNAUTHORIZED until `reconnect()`. Always false for HTTP transport.
|
|
1273
|
+
*/
|
|
1274
|
+
isSocketTokenStale() {
|
|
1275
|
+
if (!this.useWs || this._wsHandshake === null) return false;
|
|
1276
|
+
return this._wsHandshake.token !== this.token;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1125
1279
|
* Synchronous snapshot of every device matching the optional filters.
|
|
1126
1280
|
* Backed by the `SystemMirror` warm-boot cache — call `init()` first
|
|
1127
1281
|
* (or `awaitReady()`) before invoking. Returns an empty array if the
|
|
@@ -1351,6 +1505,27 @@ var System = class {
|
|
|
1351
1505
|
get wsClient() {
|
|
1352
1506
|
return this._wsClient;
|
|
1353
1507
|
}
|
|
1508
|
+
/**
|
|
1509
|
+
* The token the next socket handshake may carry. An expired token, or one
|
|
1510
|
+
* the hub just closed with 4401, is never sent: the credential is refreshed
|
|
1511
|
+
* over HTTP first, and when that does not yield a usable token the
|
|
1512
|
+
* handshake is anonymous (the app learns of a refused refresh from its
|
|
1513
|
+
* session keeper and routes to login; a transient failure retries later).
|
|
1514
|
+
*/
|
|
1515
|
+
async handshakeToken() {
|
|
1516
|
+
const dead = (token) => {
|
|
1517
|
+
if (token === this._rejectedToken) return true;
|
|
1518
|
+
const exp = jwtExpiresAt(token);
|
|
1519
|
+
return exp !== null && exp - Date.now() <= HANDSHAKE_EXPIRY_SKEW_MS;
|
|
1520
|
+
};
|
|
1521
|
+
const initial = this.token;
|
|
1522
|
+
if (initial === void 0 || !dead(initial)) return initial;
|
|
1523
|
+
if (this.refreshCredential !== void 0) try {
|
|
1524
|
+
await this.refreshCredential(initial === this._rejectedToken ? "rejected" : "expired");
|
|
1525
|
+
} catch {}
|
|
1526
|
+
const current = this.token;
|
|
1527
|
+
return current !== void 0 && !dead(current) ? current : void 0;
|
|
1528
|
+
}
|
|
1354
1529
|
buildTrpcClient() {
|
|
1355
1530
|
const headers = () => {
|
|
1356
1531
|
const h = {};
|
|
@@ -1366,10 +1541,12 @@ var System = class {
|
|
|
1366
1541
|
const epoch = this._wsEpoch;
|
|
1367
1542
|
this._wsClient = createWSClient({
|
|
1368
1543
|
url: wsUrl,
|
|
1369
|
-
connectionParams: () => {
|
|
1544
|
+
connectionParams: async () => {
|
|
1545
|
+
const token = await this.handshakeToken();
|
|
1546
|
+
if (epoch === this._wsEpoch) this._wsHandshake = { token };
|
|
1370
1547
|
const lang = this.getLanguage();
|
|
1371
1548
|
return {
|
|
1372
|
-
...
|
|
1549
|
+
...token !== void 0 ? { token } : {},
|
|
1373
1550
|
...lang ? { lang } : {}
|
|
1374
1551
|
};
|
|
1375
1552
|
},
|
|
@@ -1384,14 +1561,27 @@ var System = class {
|
|
|
1384
1561
|
this._connectionVersion += 1;
|
|
1385
1562
|
this.emitConnectionEvent("connected");
|
|
1386
1563
|
},
|
|
1387
|
-
onClose: () => {
|
|
1564
|
+
onClose: (cause) => {
|
|
1388
1565
|
if (epoch !== this._wsEpoch) return;
|
|
1566
|
+
if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
|
|
1389
1567
|
this.emitConnectionEvent("disconnected");
|
|
1390
1568
|
}
|
|
1391
1569
|
});
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1570
|
+
const anonymousHeaders = () => {
|
|
1571
|
+
const lang = this.getLanguage();
|
|
1572
|
+
return lang ? { "x-camstack-lang": lang } : {};
|
|
1573
|
+
};
|
|
1574
|
+
return createTRPCClient({ links: [splitLink({
|
|
1575
|
+
condition: (op) => PRE_AUTH_PATHS.has(op.path),
|
|
1576
|
+
true: httpLink({
|
|
1577
|
+
url: `${this._serverUrl}/trpc`,
|
|
1578
|
+
headers: anonymousHeaders,
|
|
1579
|
+
transformer: SuperJSON
|
|
1580
|
+
}),
|
|
1581
|
+
false: wsLink({
|
|
1582
|
+
client: this._wsClient,
|
|
1583
|
+
transformer: SuperJSON
|
|
1584
|
+
})
|
|
1395
1585
|
})] });
|
|
1396
1586
|
}
|
|
1397
1587
|
this._wsClient = null;
|
|
@@ -1460,119 +1650,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
|
|
|
1460
1650
|
}
|
|
1461
1651
|
}
|
|
1462
1652
|
//#endregion
|
|
1463
|
-
//#region src/session-keeper.ts
|
|
1464
|
-
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
1465
|
-
var RETRY_MS = 3e4;
|
|
1466
|
-
var MIN_DELAY_MS = 1e3;
|
|
1467
|
-
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
1468
|
-
function jwtExpiresAt(token) {
|
|
1469
|
-
const part = token.split(".")[1];
|
|
1470
|
-
if (part === void 0) return null;
|
|
1471
|
-
try {
|
|
1472
|
-
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
1473
|
-
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
1474
|
-
const payload = JSON.parse(json);
|
|
1475
|
-
if (payload === null || typeof payload !== "object") return null;
|
|
1476
|
-
const exp = Reflect.get(payload, "exp");
|
|
1477
|
-
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
1478
|
-
} catch {
|
|
1479
|
-
return null;
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
1483
|
-
function isSessionRefusal(error) {
|
|
1484
|
-
if (error === null || typeof error !== "object") return false;
|
|
1485
|
-
const data = Reflect.get(error, "data");
|
|
1486
|
-
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
1487
|
-
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
1488
|
-
}
|
|
1489
|
-
var SessionKeeper = class {
|
|
1490
|
-
options;
|
|
1491
|
-
current = null;
|
|
1492
|
-
cancel = null;
|
|
1493
|
-
inflight = null;
|
|
1494
|
-
now;
|
|
1495
|
-
setTimer;
|
|
1496
|
-
constructor(options) {
|
|
1497
|
-
this.options = options;
|
|
1498
|
-
this.now = options.now ?? Date.now;
|
|
1499
|
-
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
1500
|
-
const handle = setTimeout(fn, ms);
|
|
1501
|
-
return () => clearTimeout(handle);
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
1505
|
-
adopt(tokens) {
|
|
1506
|
-
this.current = tokens;
|
|
1507
|
-
this.schedule();
|
|
1508
|
-
}
|
|
1509
|
-
/** The tokens the keeper holds now. */
|
|
1510
|
-
tokens() {
|
|
1511
|
-
return this.current;
|
|
1512
|
-
}
|
|
1513
|
-
/**
|
|
1514
|
-
* Refresh now if the access token expires within `marginMs` (default: the
|
|
1515
|
-
* keeper's margin) — call it before reconnecting after the app was in the
|
|
1516
|
-
* background. Resolves when done; concurrent callers share one refresh.
|
|
1517
|
-
*/
|
|
1518
|
-
async ensureFresh(marginMs = this.margin()) {
|
|
1519
|
-
const current = this.current;
|
|
1520
|
-
if (current?.refreshToken === void 0) return;
|
|
1521
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1522
|
-
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
1523
|
-
await this.refreshNow();
|
|
1524
|
-
}
|
|
1525
|
-
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
1526
|
-
refreshNow() {
|
|
1527
|
-
if (this.inflight !== null) return this.inflight;
|
|
1528
|
-
const refreshToken = this.current?.refreshToken;
|
|
1529
|
-
if (refreshToken === void 0) return Promise.resolve(null);
|
|
1530
|
-
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
1531
|
-
this.current = next;
|
|
1532
|
-
this.options.onTokens(next);
|
|
1533
|
-
this.schedule();
|
|
1534
|
-
return next;
|
|
1535
|
-
}).catch((error) => {
|
|
1536
|
-
if (isSessionRefusal(error)) {
|
|
1537
|
-
this.stop();
|
|
1538
|
-
this.current = null;
|
|
1539
|
-
this.options.onExpired();
|
|
1540
|
-
} else this.arm(RETRY_MS);
|
|
1541
|
-
return null;
|
|
1542
|
-
}).finally(() => {
|
|
1543
|
-
this.inflight = null;
|
|
1544
|
-
});
|
|
1545
|
-
return this.inflight;
|
|
1546
|
-
}
|
|
1547
|
-
stop() {
|
|
1548
|
-
this.cancel?.();
|
|
1549
|
-
this.cancel = null;
|
|
1550
|
-
}
|
|
1551
|
-
margin() {
|
|
1552
|
-
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
1553
|
-
}
|
|
1554
|
-
expiresAtOf(tokens) {
|
|
1555
|
-
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
1556
|
-
}
|
|
1557
|
-
schedule() {
|
|
1558
|
-
this.stop();
|
|
1559
|
-
const current = this.current;
|
|
1560
|
-
if (current?.refreshToken === void 0) return;
|
|
1561
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1562
|
-
if (expiresAt === null) return;
|
|
1563
|
-
const remaining = expiresAt - this.now();
|
|
1564
|
-
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
1565
|
-
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
1566
|
-
}
|
|
1567
|
-
arm(ms) {
|
|
1568
|
-
this.stop();
|
|
1569
|
-
this.cancel = this.setTimer(() => {
|
|
1570
|
-
this.cancel = null;
|
|
1571
|
-
this.refreshNow();
|
|
1572
|
-
}, ms);
|
|
1573
|
-
}
|
|
1574
|
-
};
|
|
1575
|
-
//#endregion
|
|
1576
1653
|
//#region src/detection.ts
|
|
1577
1654
|
/**
|
|
1578
1655
|
* Detection classes — shared between CamStack app and proxy.
|
package/dist/system.d.ts
CHANGED
|
@@ -56,7 +56,18 @@ export interface SystemConfig {
|
|
|
56
56
|
* from `Accept-Language`.
|
|
57
57
|
*/
|
|
58
58
|
readonly getLanguage?: () => string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Obtain a fresh access token (normally `SessionKeeper.refreshNow` /
|
|
61
|
+
* `ensureFresh`, which ends in `setToken`). Called BEFORE a socket handshake
|
|
62
|
+
* whose token is expired or was just refused with close code 4401. The
|
|
63
|
+
* refresh itself runs over HTTP (`refreshSession` never uses the socket). A
|
|
64
|
+
* rejection is swallowed: the handshake then goes out anonymous rather than
|
|
65
|
+
* carrying a token the hub is known to refuse.
|
|
66
|
+
*/
|
|
67
|
+
readonly refreshCredential?: (reason: CredentialRefreshReason) => Promise<void>;
|
|
59
68
|
}
|
|
69
|
+
/** Why `SystemConfig.refreshCredential` is asked: the token's own `exp`, or a 4401 close. */
|
|
70
|
+
export type CredentialRefreshReason = "expired" | "rejected";
|
|
60
71
|
type ConnectionListener = (state: BackendConnectionState, version: number) => void;
|
|
61
72
|
/** Options of every login leg. */
|
|
62
73
|
export interface LoginOptions {
|
|
@@ -114,6 +125,7 @@ export declare class System {
|
|
|
114
125
|
private readonly onConnectionChange;
|
|
115
126
|
private token;
|
|
116
127
|
private readonly getLanguage;
|
|
128
|
+
private readonly refreshCredential;
|
|
117
129
|
private _trpcClient;
|
|
118
130
|
private _wsClient;
|
|
119
131
|
/**
|
|
@@ -130,6 +142,13 @@ export declare class System {
|
|
|
130
142
|
private mirror;
|
|
131
143
|
private mirrorInit;
|
|
132
144
|
private connected;
|
|
145
|
+
/**
|
|
146
|
+
* The token the current socket's handshake carried (`undefined` token =
|
|
147
|
+
* anonymous handshake); `null` until a handshake has been attempted.
|
|
148
|
+
*/
|
|
149
|
+
private _wsHandshake;
|
|
150
|
+
/** The token the hub closed a socket for (4401): never handshaken again. */
|
|
151
|
+
private _rejectedToken;
|
|
133
152
|
private connectedPromise;
|
|
134
153
|
private _systemProxy;
|
|
135
154
|
private _connectionVersion;
|
|
@@ -388,6 +407,13 @@ export declare class System {
|
|
|
388
407
|
/** Update the auth token (e.g. after login or token refresh). */
|
|
389
408
|
setToken(token: string): void;
|
|
390
409
|
/**
|
|
410
|
+
* True when the live WebSocket was handshaken with a token other than the
|
|
411
|
+
* current one (anonymous, or rotated since). The socket authenticates once,
|
|
412
|
+
* at the handshake, so such a socket answers every protected call
|
|
413
|
+
* UNAUTHORIZED until `reconnect()`. Always false for HTTP transport.
|
|
414
|
+
*/
|
|
415
|
+
isSocketTokenStale(): boolean;
|
|
416
|
+
/**
|
|
391
417
|
* Synchronous snapshot of every device matching the optional filters.
|
|
392
418
|
* Backed by the `SystemMirror` warm-boot cache — call `init()` first
|
|
393
419
|
* (or `awaitReady()`) before invoking. Returns an empty array if the
|
|
@@ -494,6 +520,14 @@ export declare class System {
|
|
|
494
520
|
* (e.g. for keep-alive metrics). Rebuilt on `reconnect()`.
|
|
495
521
|
*/
|
|
496
522
|
get wsClient(): TrpcWsClient | null;
|
|
523
|
+
/**
|
|
524
|
+
* The token the next socket handshake may carry. An expired token, or one
|
|
525
|
+
* the hub just closed with 4401, is never sent: the credential is refreshed
|
|
526
|
+
* over HTTP first, and when that does not yield a usable token the
|
|
527
|
+
* handshake is anonymous (the app learns of a refused refresh from its
|
|
528
|
+
* session keeper and routes to login; a transient failure retries later).
|
|
529
|
+
*/
|
|
530
|
+
private handshakeToken;
|
|
497
531
|
private buildTrpcClient;
|
|
498
532
|
}
|
|
499
533
|
/** Create a `System` instance. Convenience factory. */
|