Files
dyolink/frontend/src/lib/auth/proactiveRefresh.ts

156 lines
3.9 KiB
TypeScript
Raw Normal View History

import { authApi } from '@/lib/api/auth';
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
/** Refresh this long before the access JWT expires. */
const REFRESH_BUFFER_MS = 2 * 60 * 1000;
/** Safety cap — never call /auth/refresh more than once per minute per tab. */
const MIN_REFRESH_GAP_MS = 60 * 1000;
let timerId: ReturnType<typeof setTimeout> | null = null;
let refreshInFlight: Promise<string | undefined> | null = null;
let lastRefreshAt = 0;
export function setAccessTokenExpiresAt(iso: string): void {
if (typeof window === 'undefined') {
return;
}
sessionStorage.setItem(STORAGE_KEY, iso);
}
export function clearAccessTokenExpiresAt(): void {
if (typeof window === 'undefined') {
return;
}
sessionStorage.removeItem(STORAGE_KEY);
}
export function rememberAccessTokenExpiresAt(
iso: string | undefined | null,
): void {
if (iso) {
setAccessTokenExpiresAt(iso);
}
}
function getAccessTokenExpiresAtMs(): number | null {
if (typeof window === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const ms = Date.parse(raw);
return Number.isFinite(ms) ? ms : null;
}
function clearTimer(): void {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
}
function computeDelayMs(expiresAtMs: number): number {
const refreshAt = expiresAtMs - REFRESH_BUFFER_MS;
const delay = refreshAt - Date.now();
return Math.max(delay, MIN_REFRESH_GAP_MS);
}
function shouldRefreshNow(expiresAtMs: number): boolean {
return expiresAtMs - REFRESH_BUFFER_MS <= Date.now();
}
async function refreshAccessTokenWithOrg(): Promise<string | undefined> {
if (refreshInFlight) {
return refreshInFlight;
}
if (Date.now() - lastRefreshAt < MIN_REFRESH_GAP_MS) {
const expiresAtMs = getAccessTokenExpiresAtMs();
return expiresAtMs
? new Date(expiresAtMs).toISOString()
: undefined;
}
refreshInFlight = (async () => {
try {
const result = await authApi.refreshSessionFromCookies();
const expiresAt = result.data?.accessTokenExpiresAt;
rememberAccessTokenExpiresAt(expiresAt);
const orgId = localStorage.getItem('currentOrganizationId');
if (orgId) {
try {
await authApi.selectOrganization(orgId);
} catch {
/* refresh may already preserve organizationId on the JWT */
}
}
lastRefreshAt = Date.now();
return expiresAt;
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
function scheduleNextRefresh(reschedule: () => void): void {
clearTimer();
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return;
}
const expiresAtMs = getAccessTokenExpiresAtMs();
if (!expiresAtMs) {
return;
}
timerId = setTimeout(() => {
void refreshAccessTokenWithOrg()
.catch(() => {
/* reactive refresh / next visibility check will recover */
})
.finally(reschedule);
}, computeDelayMs(expiresAtMs));
}
/** Keeps the access cookie fresh while the tab is visible. Returns a cleanup function. */
export function startProactiveSessionRefresh(): () => void {
if (typeof window === 'undefined') {
return () => undefined;
}
const reschedule = () => scheduleNextRefresh(reschedule);
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
const expiresAtMs = getAccessTokenExpiresAtMs();
if (expiresAtMs && shouldRefreshNow(expiresAtMs)) {
void refreshAccessTokenWithOrg()
.catch(() => undefined)
.finally(reschedule);
} else {
reschedule();
}
return;
}
clearTimer();
};
document.addEventListener('visibilitychange', onVisibilityChange);
reschedule();
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
clearTimer();
};
}