import { TokenUpdater, AuthState } from './TokenUpdater.js'; import { sleep } from '../library-ts/browser/animation/sleep.js'; export type CallTier = 'user' | 'editor' | 'silent'; const REFRESHING_WAIT_MS = 6_000; const RETRY_DELAYS_MS: Record<'editor' | 'silent', number[]> = { editor: [ 1_000, 3_000, 8_000 ], silent: [ 1_000, 3_000, 8_000 ], }; export class GuardedCallError extends Error { constructor( message: string, readonly tier: CallTier ) { super( message ); this.name = 'GuardedCallError'; } } export class GuardedCall { private static _instance: GuardedCall | null = null; static init( updater: TokenUpdater ): GuardedCall { this._instance = new GuardedCall( updater ); return this._instance; } static get(): GuardedCall { if ( !this._instance ) throw new Error( 'GuardedCall not initialized' ); return this._instance; } constructor( private readonly _updater: TokenUpdater ) {} async call( tier: CallTier, fn: () => Promise ): Promise { try { await this._preflight( tier ); } catch ( e ) { if ( tier !== 'silent' ) throw e; console.warn( '[auth] guarded call skipped — session expired', e ); return new Response( null, { status: 0 } ); } return this._executeWithRetry( tier, fn ); } private async _preflight( tier: CallTier ): Promise { const state = this._updater.state; if ( state === 'expired' ) throw new GuardedCallError( 'Session expired', tier ); if ( state === 'refreshing' ) await this._waitForNotRefreshing( tier ); } private _waitForNotRefreshing( tier: CallTier ): Promise { return new Promise( ( resolve, reject ) => { const timer = setTimeout( () => { this._updater.onStateChanged.removeListener( handler ); resolve(); }, REFRESHING_WAIT_MS ); const handler = ( state: AuthState ) => { if ( state === 'refreshing' ) return; clearTimeout( timer ); this._updater.onStateChanged.removeListener( handler ); if ( state === 'expired' ) reject( new GuardedCallError( 'Session expired', tier ) ); else resolve(); }; this._updater.onStateChanged.addListener( handler ); } ); } private async _executeWithRetry( tier: CallTier, fn: () => Promise ): Promise { const delays: number[] = tier === 'user' ? [] : RETRY_DELAYS_MS[ tier ]; let lastError: unknown; for ( let attempt = 0; attempt <= delays.length; attempt++ ) { if ( attempt > 0 ) await sleep( delays[ attempt - 1 ] ); try { const res = await fn(); if ( res.ok || res.status === 401 ) return res; throw new GuardedCallError( `HTTP ${ res.status }`, tier ); } catch ( e ) { lastError = e; const sessionDead = this._updater.state === 'expired'; if ( tier === 'user' ) throw e; if ( tier === 'editor' && sessionDead ) throw e; if ( sessionDead ) break; if ( tier === 'silent' ) console.warn( '[auth] guarded call failed (attempt', attempt + 1, ')', e ); } } if ( tier === 'silent' ) { console.warn( '[auth] guarded call giving up after retries' ); return new Response( null, { status: 0 } ); } throw lastError; } }