import { decodeJwt } from 'jose'; // Refetch this long before the token expires. export const MARGIN_MS = 60_000; // Floor for short-lived tokens, so a backend issuing them cannot cause a refetch loop. export const MIN_DELAY_MS = 10_000; // setTimeout overflows (fires immediately) above this delay. const MAX_TIMEOUT_MS = 2 ** 31 - 1; /** * Fetches a token, hands it to a callback, and refetches shortly before it expires. * @internal */ export class TokenRefresher { private timer?: ReturnType; // Bumped by stop() so a fetch already in flight can tell it was cancelled. private generation = 0; constructor(private readonly fetchToken: () => Promise) {} start( onToken: (token: string) => void, onError: (error: Error) => void, ): void { this.stop(); const generation = this.generation; // The executor turns a synchronous throw from fetchToken into a rejection. new Promise((resolve) => resolve(this.fetchToken())).then( (token) => { if (generation !== this.generation) return; let exp: unknown; try { exp = decodeJwt(token).exp; } catch { // Unreadable token: deliver it but cannot schedule a refresh. } if (typeof exp !== 'number') { onToken(token); return; } if (exp * 1000 <= Date.now()) { onError(new Error('Fetched token has already expired')); return; } const delay = exp * 1000 - Date.now() - MARGIN_MS; // Arm before delivering, so a stop() called from onToken cancels it. this.timer = setTimeout( () => this.start(onToken, onError), Math.min(Math.max(delay, MIN_DELAY_MS), MAX_TIMEOUT_MS), ); onToken(token); }, (error: unknown) => { if (generation !== this.generation) return; onError(error instanceof Error ? error : new Error(String(error))); }, ); } stop(): void { this.generation++; clearTimeout(this.timer); this.timer = undefined; } }