@camstack/sdk 1.2.134 → 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 +180 -120
- package/dist/index.d.ts +1 -1
- package/dist/index.js +181 -121
- package/dist/system.d.ts +22 -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
|
/**
|
|
@@ -706,6 +841,8 @@ var System = class {
|
|
|
706
841
|
* anonymous handshake); `null` until a handshake has been attempted.
|
|
707
842
|
*/
|
|
708
843
|
_wsHandshake = null;
|
|
844
|
+
/** The token the hub closed a socket for (4401): never handshaken again. */
|
|
845
|
+
_rejectedToken;
|
|
709
846
|
connectedPromise = null;
|
|
710
847
|
_systemProxy;
|
|
711
848
|
_connectionVersion = 0;
|
|
@@ -721,6 +858,7 @@ var System = class {
|
|
|
721
858
|
this._serverUrl = config.serverUrl.replace(/\/+$/, "");
|
|
722
859
|
this.token = config.token;
|
|
723
860
|
this.getLanguage = config.getLanguage ?? (() => void 0);
|
|
861
|
+
this.refreshCredential = config.refreshCredential;
|
|
724
862
|
const isBrowser = typeof window !== "undefined";
|
|
725
863
|
this.useWs = config.useWebSocket ?? isBrowser;
|
|
726
864
|
this.onConnectionChange = config.onConnectionChange;
|
|
@@ -1368,6 +1506,27 @@ var System = class {
|
|
|
1368
1506
|
get wsClient() {
|
|
1369
1507
|
return this._wsClient;
|
|
1370
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
|
+
}
|
|
1371
1530
|
buildTrpcClient() {
|
|
1372
1531
|
const headers = () => {
|
|
1373
1532
|
const h = {};
|
|
@@ -1383,11 +1542,12 @@ var System = class {
|
|
|
1383
1542
|
const epoch = this._wsEpoch;
|
|
1384
1543
|
this._wsClient = (0, _trpc_client.createWSClient)({
|
|
1385
1544
|
url: wsUrl,
|
|
1386
|
-
connectionParams: () => {
|
|
1387
|
-
|
|
1545
|
+
connectionParams: async () => {
|
|
1546
|
+
const token = await this.handshakeToken();
|
|
1547
|
+
if (epoch === this._wsEpoch) this._wsHandshake = { token };
|
|
1388
1548
|
const lang = this.getLanguage();
|
|
1389
1549
|
return {
|
|
1390
|
-
...
|
|
1550
|
+
...token !== void 0 ? { token } : {},
|
|
1391
1551
|
...lang ? { lang } : {}
|
|
1392
1552
|
};
|
|
1393
1553
|
},
|
|
@@ -1402,14 +1562,27 @@ var System = class {
|
|
|
1402
1562
|
this._connectionVersion += 1;
|
|
1403
1563
|
this.emitConnectionEvent("connected");
|
|
1404
1564
|
},
|
|
1405
|
-
onClose: () => {
|
|
1565
|
+
onClose: (cause) => {
|
|
1406
1566
|
if (epoch !== this._wsEpoch) return;
|
|
1567
|
+
if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
|
|
1407
1568
|
this.emitConnectionEvent("disconnected");
|
|
1408
1569
|
}
|
|
1409
1570
|
});
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
+
})
|
|
1413
1586
|
})] });
|
|
1414
1587
|
}
|
|
1415
1588
|
this._wsClient = null;
|
|
@@ -1478,119 +1651,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
|
|
|
1478
1651
|
}
|
|
1479
1652
|
}
|
|
1480
1653
|
//#endregion
|
|
1481
|
-
//#region src/session-keeper.ts
|
|
1482
|
-
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
1483
|
-
var RETRY_MS = 3e4;
|
|
1484
|
-
var MIN_DELAY_MS = 1e3;
|
|
1485
|
-
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
1486
|
-
function jwtExpiresAt(token) {
|
|
1487
|
-
const part = token.split(".")[1];
|
|
1488
|
-
if (part === void 0) return null;
|
|
1489
|
-
try {
|
|
1490
|
-
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
1491
|
-
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
1492
|
-
const payload = JSON.parse(json);
|
|
1493
|
-
if (payload === null || typeof payload !== "object") return null;
|
|
1494
|
-
const exp = Reflect.get(payload, "exp");
|
|
1495
|
-
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
1496
|
-
} catch {
|
|
1497
|
-
return null;
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
1501
|
-
function isSessionRefusal(error) {
|
|
1502
|
-
if (error === null || typeof error !== "object") return false;
|
|
1503
|
-
const data = Reflect.get(error, "data");
|
|
1504
|
-
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
1505
|
-
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
1506
|
-
}
|
|
1507
|
-
var SessionKeeper = class {
|
|
1508
|
-
options;
|
|
1509
|
-
current = null;
|
|
1510
|
-
cancel = null;
|
|
1511
|
-
inflight = null;
|
|
1512
|
-
now;
|
|
1513
|
-
setTimer;
|
|
1514
|
-
constructor(options) {
|
|
1515
|
-
this.options = options;
|
|
1516
|
-
this.now = options.now ?? Date.now;
|
|
1517
|
-
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
1518
|
-
const handle = setTimeout(fn, ms);
|
|
1519
|
-
return () => clearTimeout(handle);
|
|
1520
|
-
});
|
|
1521
|
-
}
|
|
1522
|
-
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
1523
|
-
adopt(tokens) {
|
|
1524
|
-
this.current = tokens;
|
|
1525
|
-
this.schedule();
|
|
1526
|
-
}
|
|
1527
|
-
/** The tokens the keeper holds now. */
|
|
1528
|
-
tokens() {
|
|
1529
|
-
return this.current;
|
|
1530
|
-
}
|
|
1531
|
-
/**
|
|
1532
|
-
* Refresh now if the access token expires within `marginMs` (default: the
|
|
1533
|
-
* keeper's margin) — call it before reconnecting after the app was in the
|
|
1534
|
-
* background. Resolves when done; concurrent callers share one refresh.
|
|
1535
|
-
*/
|
|
1536
|
-
async ensureFresh(marginMs = this.margin()) {
|
|
1537
|
-
const current = this.current;
|
|
1538
|
-
if (current?.refreshToken === void 0) return;
|
|
1539
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1540
|
-
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
1541
|
-
await this.refreshNow();
|
|
1542
|
-
}
|
|
1543
|
-
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
1544
|
-
refreshNow() {
|
|
1545
|
-
if (this.inflight !== null) return this.inflight;
|
|
1546
|
-
const refreshToken = this.current?.refreshToken;
|
|
1547
|
-
if (refreshToken === void 0) return Promise.resolve(null);
|
|
1548
|
-
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
1549
|
-
this.current = next;
|
|
1550
|
-
this.options.onTokens(next);
|
|
1551
|
-
this.schedule();
|
|
1552
|
-
return next;
|
|
1553
|
-
}).catch((error) => {
|
|
1554
|
-
if (isSessionRefusal(error)) {
|
|
1555
|
-
this.stop();
|
|
1556
|
-
this.current = null;
|
|
1557
|
-
this.options.onExpired();
|
|
1558
|
-
} else this.arm(RETRY_MS);
|
|
1559
|
-
return null;
|
|
1560
|
-
}).finally(() => {
|
|
1561
|
-
this.inflight = null;
|
|
1562
|
-
});
|
|
1563
|
-
return this.inflight;
|
|
1564
|
-
}
|
|
1565
|
-
stop() {
|
|
1566
|
-
this.cancel?.();
|
|
1567
|
-
this.cancel = null;
|
|
1568
|
-
}
|
|
1569
|
-
margin() {
|
|
1570
|
-
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
1571
|
-
}
|
|
1572
|
-
expiresAtOf(tokens) {
|
|
1573
|
-
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
1574
|
-
}
|
|
1575
|
-
schedule() {
|
|
1576
|
-
this.stop();
|
|
1577
|
-
const current = this.current;
|
|
1578
|
-
if (current?.refreshToken === void 0) return;
|
|
1579
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1580
|
-
if (expiresAt === null) return;
|
|
1581
|
-
const remaining = expiresAt - this.now();
|
|
1582
|
-
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
1583
|
-
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
1584
|
-
}
|
|
1585
|
-
arm(ms) {
|
|
1586
|
-
this.stop();
|
|
1587
|
-
this.cancel = this.setTimer(() => {
|
|
1588
|
-
this.cancel = null;
|
|
1589
|
-
this.refreshNow();
|
|
1590
|
-
}, ms);
|
|
1591
|
-
}
|
|
1592
|
-
};
|
|
1593
|
-
//#endregion
|
|
1594
1654
|
//#region src/detection.ts
|
|
1595
1655
|
/**
|
|
1596
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
|
/**
|
|
@@ -705,6 +840,8 @@ var System = class {
|
|
|
705
840
|
* anonymous handshake); `null` until a handshake has been attempted.
|
|
706
841
|
*/
|
|
707
842
|
_wsHandshake = null;
|
|
843
|
+
/** The token the hub closed a socket for (4401): never handshaken again. */
|
|
844
|
+
_rejectedToken;
|
|
708
845
|
connectedPromise = null;
|
|
709
846
|
_systemProxy;
|
|
710
847
|
_connectionVersion = 0;
|
|
@@ -720,6 +857,7 @@ var System = class {
|
|
|
720
857
|
this._serverUrl = config.serverUrl.replace(/\/+$/, "");
|
|
721
858
|
this.token = config.token;
|
|
722
859
|
this.getLanguage = config.getLanguage ?? (() => void 0);
|
|
860
|
+
this.refreshCredential = config.refreshCredential;
|
|
723
861
|
const isBrowser = typeof window !== "undefined";
|
|
724
862
|
this.useWs = config.useWebSocket ?? isBrowser;
|
|
725
863
|
this.onConnectionChange = config.onConnectionChange;
|
|
@@ -1367,6 +1505,27 @@ var System = class {
|
|
|
1367
1505
|
get wsClient() {
|
|
1368
1506
|
return this._wsClient;
|
|
1369
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
|
+
}
|
|
1370
1529
|
buildTrpcClient() {
|
|
1371
1530
|
const headers = () => {
|
|
1372
1531
|
const h = {};
|
|
@@ -1382,11 +1541,12 @@ var System = class {
|
|
|
1382
1541
|
const epoch = this._wsEpoch;
|
|
1383
1542
|
this._wsClient = createWSClient({
|
|
1384
1543
|
url: wsUrl,
|
|
1385
|
-
connectionParams: () => {
|
|
1386
|
-
|
|
1544
|
+
connectionParams: async () => {
|
|
1545
|
+
const token = await this.handshakeToken();
|
|
1546
|
+
if (epoch === this._wsEpoch) this._wsHandshake = { token };
|
|
1387
1547
|
const lang = this.getLanguage();
|
|
1388
1548
|
return {
|
|
1389
|
-
...
|
|
1549
|
+
...token !== void 0 ? { token } : {},
|
|
1390
1550
|
...lang ? { lang } : {}
|
|
1391
1551
|
};
|
|
1392
1552
|
},
|
|
@@ -1401,14 +1561,27 @@ var System = class {
|
|
|
1401
1561
|
this._connectionVersion += 1;
|
|
1402
1562
|
this.emitConnectionEvent("connected");
|
|
1403
1563
|
},
|
|
1404
|
-
onClose: () => {
|
|
1564
|
+
onClose: (cause) => {
|
|
1405
1565
|
if (epoch !== this._wsEpoch) return;
|
|
1566
|
+
if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
|
|
1406
1567
|
this.emitConnectionEvent("disconnected");
|
|
1407
1568
|
}
|
|
1408
1569
|
});
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
+
})
|
|
1412
1585
|
})] });
|
|
1413
1586
|
}
|
|
1414
1587
|
this._wsClient = null;
|
|
@@ -1477,119 +1650,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
|
|
|
1477
1650
|
}
|
|
1478
1651
|
}
|
|
1479
1652
|
//#endregion
|
|
1480
|
-
//#region src/session-keeper.ts
|
|
1481
|
-
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
1482
|
-
var RETRY_MS = 3e4;
|
|
1483
|
-
var MIN_DELAY_MS = 1e3;
|
|
1484
|
-
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
1485
|
-
function jwtExpiresAt(token) {
|
|
1486
|
-
const part = token.split(".")[1];
|
|
1487
|
-
if (part === void 0) return null;
|
|
1488
|
-
try {
|
|
1489
|
-
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
1490
|
-
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
1491
|
-
const payload = JSON.parse(json);
|
|
1492
|
-
if (payload === null || typeof payload !== "object") return null;
|
|
1493
|
-
const exp = Reflect.get(payload, "exp");
|
|
1494
|
-
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
1495
|
-
} catch {
|
|
1496
|
-
return null;
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
1500
|
-
function isSessionRefusal(error) {
|
|
1501
|
-
if (error === null || typeof error !== "object") return false;
|
|
1502
|
-
const data = Reflect.get(error, "data");
|
|
1503
|
-
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
1504
|
-
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
1505
|
-
}
|
|
1506
|
-
var SessionKeeper = class {
|
|
1507
|
-
options;
|
|
1508
|
-
current = null;
|
|
1509
|
-
cancel = null;
|
|
1510
|
-
inflight = null;
|
|
1511
|
-
now;
|
|
1512
|
-
setTimer;
|
|
1513
|
-
constructor(options) {
|
|
1514
|
-
this.options = options;
|
|
1515
|
-
this.now = options.now ?? Date.now;
|
|
1516
|
-
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
1517
|
-
const handle = setTimeout(fn, ms);
|
|
1518
|
-
return () => clearTimeout(handle);
|
|
1519
|
-
});
|
|
1520
|
-
}
|
|
1521
|
-
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
1522
|
-
adopt(tokens) {
|
|
1523
|
-
this.current = tokens;
|
|
1524
|
-
this.schedule();
|
|
1525
|
-
}
|
|
1526
|
-
/** The tokens the keeper holds now. */
|
|
1527
|
-
tokens() {
|
|
1528
|
-
return this.current;
|
|
1529
|
-
}
|
|
1530
|
-
/**
|
|
1531
|
-
* Refresh now if the access token expires within `marginMs` (default: the
|
|
1532
|
-
* keeper's margin) — call it before reconnecting after the app was in the
|
|
1533
|
-
* background. Resolves when done; concurrent callers share one refresh.
|
|
1534
|
-
*/
|
|
1535
|
-
async ensureFresh(marginMs = this.margin()) {
|
|
1536
|
-
const current = this.current;
|
|
1537
|
-
if (current?.refreshToken === void 0) return;
|
|
1538
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1539
|
-
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
1540
|
-
await this.refreshNow();
|
|
1541
|
-
}
|
|
1542
|
-
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
1543
|
-
refreshNow() {
|
|
1544
|
-
if (this.inflight !== null) return this.inflight;
|
|
1545
|
-
const refreshToken = this.current?.refreshToken;
|
|
1546
|
-
if (refreshToken === void 0) return Promise.resolve(null);
|
|
1547
|
-
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
1548
|
-
this.current = next;
|
|
1549
|
-
this.options.onTokens(next);
|
|
1550
|
-
this.schedule();
|
|
1551
|
-
return next;
|
|
1552
|
-
}).catch((error) => {
|
|
1553
|
-
if (isSessionRefusal(error)) {
|
|
1554
|
-
this.stop();
|
|
1555
|
-
this.current = null;
|
|
1556
|
-
this.options.onExpired();
|
|
1557
|
-
} else this.arm(RETRY_MS);
|
|
1558
|
-
return null;
|
|
1559
|
-
}).finally(() => {
|
|
1560
|
-
this.inflight = null;
|
|
1561
|
-
});
|
|
1562
|
-
return this.inflight;
|
|
1563
|
-
}
|
|
1564
|
-
stop() {
|
|
1565
|
-
this.cancel?.();
|
|
1566
|
-
this.cancel = null;
|
|
1567
|
-
}
|
|
1568
|
-
margin() {
|
|
1569
|
-
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
1570
|
-
}
|
|
1571
|
-
expiresAtOf(tokens) {
|
|
1572
|
-
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
1573
|
-
}
|
|
1574
|
-
schedule() {
|
|
1575
|
-
this.stop();
|
|
1576
|
-
const current = this.current;
|
|
1577
|
-
if (current?.refreshToken === void 0) return;
|
|
1578
|
-
const expiresAt = this.expiresAtOf(current);
|
|
1579
|
-
if (expiresAt === null) return;
|
|
1580
|
-
const remaining = expiresAt - this.now();
|
|
1581
|
-
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
1582
|
-
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
1583
|
-
}
|
|
1584
|
-
arm(ms) {
|
|
1585
|
-
this.stop();
|
|
1586
|
-
this.cancel = this.setTimer(() => {
|
|
1587
|
-
this.cancel = null;
|
|
1588
|
-
this.refreshNow();
|
|
1589
|
-
}, ms);
|
|
1590
|
-
}
|
|
1591
|
-
};
|
|
1592
|
-
//#endregion
|
|
1593
1653
|
//#region src/detection.ts
|
|
1594
1654
|
/**
|
|
1595
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
|
/**
|
|
@@ -135,6 +147,8 @@ export declare class System {
|
|
|
135
147
|
* anonymous handshake); `null` until a handshake has been attempted.
|
|
136
148
|
*/
|
|
137
149
|
private _wsHandshake;
|
|
150
|
+
/** The token the hub closed a socket for (4401): never handshaken again. */
|
|
151
|
+
private _rejectedToken;
|
|
138
152
|
private connectedPromise;
|
|
139
153
|
private _systemProxy;
|
|
140
154
|
private _connectionVersion;
|
|
@@ -506,6 +520,14 @@ export declare class System {
|
|
|
506
520
|
* (e.g. for keep-alive metrics). Rebuilt on `reconnect()`.
|
|
507
521
|
*/
|
|
508
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;
|
|
509
531
|
private buildTrpcClient;
|
|
510
532
|
}
|
|
511
533
|
/** Create a `System` instance. Convenience factory. */
|