import { EventSlot } from '../library-ts/browser/events/EventSlot.js'; import { ActivityAnalyser } from '../library-ts/browser/dom/ActivityAnalyser.js'; // Central token-lifecycle owner for the browser session. Runs periodically and on // user activity, keeping the session's cookies fresh via a cheap authenticated ping. // Uses Web Locks to elect exactly one leader tab; other tabs follow state via // BroadcastChannel. The actual refresh decision is server-authoritative (see // PROACTIVE_REFRESH_MARGIN_SEC in auth-connector's jwtMiddleware) — this class // never inspects or compares token expiry itself. export type AuthState = 'valid' | 'refreshing' | 'expired' | 'network-error'; type AuthChannelMessage = | { type: 'state'; value: AuthState } | { type: 'request-state' }; const CHANNEL_NAME = 'roject-auth'; const LOCK_NAME = 'roject-token-updater-leader'; export class TokenUpdater { static readonly CHECK_INTERVAL_MS = 5 * 60 * 1000; readonly onStateChanged = new EventSlot(); private _state: AuthState = 'valid'; get state(): AuthState { return this._state; } private readonly _activity = new ActivityAnalyser(); private _checking = false; private _isLeader = false; private readonly _channel = new BroadcastChannel( CHANNEL_NAME ); start(): void { this._channel.addEventListener( 'message', ( e: MessageEvent ) => { const msg = e.data as AuthChannelMessage; if ( this._isLeader ) { if ( msg.type === 'request-state' ) this._channel.postMessage( { type: 'state', value: this._state } ); return; } if ( msg.type === 'state' ) this._setState( msg.value ); } ); if ( !( 'locks' in navigator ) ) { this._becomeLeader(); return; } // Ask the current leader (if any) for its state so this tab syncs immediately. this._channel.postMessage( { type: 'request-state' } ); // Queue for the exclusive lock. The first tab gets it immediately; subsequent // tabs wait silently (listening via BroadcastChannel) until the current holder // closes, then automatically become the new leader. void navigator.locks.request( LOCK_NAME, async () => { this._becomeLeader(); await new Promise( () => {} ); // hold the lock until tab closes } ); } private _becomeLeader(): void { this._isLeader = true; this._activity.start(); this._activity.onActive.addListener( () => this._check() ); setInterval( () => this._check(), TokenUpdater.CHECK_INTERVAL_MS ); void this._check(); } private async _check(): Promise { if ( this._checking ) return; this._checking = true; this._setState( 'refreshing' ); try { const res = await fetch( '/api/auth/me' ); if ( res.ok ) this._setState( 'valid' ); else if ( res.status === 401 ) this._setState( 'expired' ); else this._setState( 'network-error' ); } catch { this._setState( 'network-error' ); } finally { this._checking = false; } } private _setState( state: AuthState ): void { if ( this._state === state ) return; this._state = state; this.onStateChanged.dispatch( state ); if ( this._isLeader ) this._channel.postMessage( { type: 'state', value: state } ); } }