rojects/source/auth/GuardedCall.ts

119 lines
3.3 KiB
TypeScript

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<Response> ): Promise<Response>
{
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<void>
{
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<void>
{
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<Response> ): Promise<Response>
{
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;
}
}