119 lines
3.3 KiB
TypeScript
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;
|
||
|
|
}
|
||
|
|
}
|