| import type { AppContext, AppModule } from '@/app/app-context'; |
| import { startSmartPollLoop, VisibilityHub, type SmartPollLoopHandle } from '@/services/runtime'; |
|
|
| export interface RefreshRegistration { |
| name: string; |
| fn: () => Promise<boolean | void>; |
| intervalMs: number; |
| condition?: () => boolean; |
| runImmediately?: boolean; |
| } |
|
|
| export class RefreshScheduler implements AppModule { |
| private ctx: AppContext; |
| private refreshRunners = new Map<string, { loop: SmartPollLoopHandle; intervalMs: number }>(); |
| private flushTimeoutIds = new Set<ReturnType<typeof setTimeout>>(); |
| private hiddenSince = 0; |
| private visibilityHub = new VisibilityHub(); |
|
|
| constructor(ctx: AppContext) { |
| this.ctx = ctx; |
| } |
|
|
| init(): void {} |
|
|
| destroy(): void { |
| for (const timeoutId of this.flushTimeoutIds) { |
| clearTimeout(timeoutId); |
| } |
| this.flushTimeoutIds.clear(); |
| for (const { loop } of this.refreshRunners.values()) { |
| loop.stop(); |
| } |
| this.refreshRunners.clear(); |
| this.visibilityHub.destroy(); |
| } |
|
|
| setHiddenSince(ts: number): void { |
| this.hiddenSince = ts; |
| } |
|
|
| getHiddenSince(): number { |
| return this.hiddenSince; |
| } |
|
|
| scheduleRefresh( |
| name: string, |
| fn: () => Promise<boolean | void>, |
| intervalMs: number, |
| condition?: () => boolean, |
| options: { runImmediately?: boolean } = {}, |
| ): void { |
| this.refreshRunners.get(name)?.loop.stop(); |
|
|
| const loop = startSmartPollLoop(async () => { |
| if (this.ctx.isDestroyed) return; |
| if (condition && !condition()) return; |
| if (this.ctx.inFlight.has(name)) return; |
|
|
| this.ctx.inFlight.add(name); |
| try { |
| return await fn(); |
| } finally { |
| this.ctx.inFlight.delete(name); |
| } |
| }, { |
| intervalMs, |
| pauseWhenHidden: true, |
| refreshOnVisible: false, |
| runImmediately: options.runImmediately ?? false, |
| maxBackoffMultiplier: 4, |
| visibilityHub: this.visibilityHub, |
| onError: (e) => { |
| console.error(`[App] Refresh ${name} failed:`, e); |
| }, |
| }); |
|
|
| this.refreshRunners.set(name, { loop, intervalMs }); |
| } |
|
|
| flushStaleRefreshes(): void { |
| if (!this.hiddenSince) return; |
| const hiddenMs = Date.now() - this.hiddenSince; |
| this.hiddenSince = 0; |
|
|
| for (const timeoutId of this.flushTimeoutIds) { |
| clearTimeout(timeoutId); |
| } |
| this.flushTimeoutIds.clear(); |
|
|
| |
| const stale: { loop: SmartPollLoopHandle; intervalMs: number }[] = []; |
| for (const entry of this.refreshRunners.values()) { |
| if (hiddenMs >= entry.intervalMs) { |
| stale.push(entry); |
| } |
| } |
| stale.sort((a, b) => a.intervalMs - b.intervalMs); |
|
|
| |
| const FLUSH_STAGGER_FAST_MS = 100; |
| const FLUSH_STAGGER_SLOW_MS = 300; |
| let stagger = 0; |
| let idx = 0; |
| for (const entry of stale) { |
| const delay = stagger; |
| stagger += (idx < 4) ? FLUSH_STAGGER_FAST_MS : FLUSH_STAGGER_SLOW_MS; |
| idx++; |
| const timeoutId = setTimeout(() => { |
| this.flushTimeoutIds.delete(timeoutId); |
| entry.loop.trigger(); |
| }, delay); |
| this.flushTimeoutIds.add(timeoutId); |
| } |
| } |
|
|
| registerAll(registrations: RefreshRegistration[]): void { |
| for (const reg of registrations) { |
| this.scheduleRefresh(reg.name, reg.fn, reg.intervalMs, reg.condition, { |
| runImmediately: reg.runImmediately, |
| }); |
| } |
| } |
| } |
|
|