mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
122 lines
4.4 KiB
TypeScript
122 lines
4.4 KiB
TypeScript
import { logger } from '@/lib/logger';
|
|
import {
|
|
disabledByEnv, effectiveEndpoint, getCurrentVersion, isStatusStale, loadState, saveState,
|
|
} from './state';
|
|
import { fetchStatus } from './fetcher';
|
|
import type { UpdateStatus } from './types';
|
|
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
|
|
|
const HOUR_MS = 60 * 60 * 1000;
|
|
const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing
|
|
const FIRST_DELAY_MS = 30 * 1000; // first check ~30s after boot
|
|
const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15 min
|
|
|
|
let currentTimer: NodeJS.Timeout | null = null;
|
|
|
|
/** What checkOnce reports while NO_THIRD_PARTY_TRAFFIC is on. */
|
|
export const NO_THIRD_PARTY_ERROR = 'off: no third-party traffic';
|
|
|
|
function jitteredDelay(base: number): number {
|
|
const j = (Math.random() * 2 - 1) * JITTER_MS;
|
|
return Math.max(60_000, base + j);
|
|
}
|
|
|
|
export async function checkOnce(opts?: { reason?: string }): Promise<{
|
|
ok: boolean;
|
|
status?: UpdateStatus;
|
|
error?: string;
|
|
}> {
|
|
if (disabledByEnv()) return { ok: false, error: 'disabled by env' };
|
|
if (await isNoThirdPartyTrafficOn()) return { ok: false, error: NO_THIRD_PARTY_ERROR };
|
|
|
|
const state = await loadState();
|
|
const endpoint = effectiveEndpoint(state);
|
|
if (!endpoint) return { ok: false, error: 'endpoint blank' };
|
|
|
|
const current = getCurrentVersion();
|
|
if (!current) return { ok: false, error: 'current version unset' };
|
|
|
|
const now = new Date().toISOString();
|
|
const result = await fetchStatus(endpoint, current);
|
|
|
|
const next = await loadState();
|
|
next.lastCheckedAt = now;
|
|
if (result.ok) {
|
|
next.lastSuccessAt = now;
|
|
next.status = result.status;
|
|
}
|
|
await saveState(next);
|
|
|
|
logger.info('version-check: ran', {
|
|
ok: result.ok,
|
|
severity: result.ok ? result.status.severity : null,
|
|
reason: opts?.reason ?? 'scheduled',
|
|
});
|
|
|
|
if (result.ok) return { ok: true, status: result.status };
|
|
return { ok: false, error: result.error };
|
|
}
|
|
|
|
async function scheduleNext(delayMs: number): Promise<void> {
|
|
if (currentTimer) clearTimeout(currentTimer);
|
|
const at = new Date(Date.now() + delayMs).toISOString();
|
|
const state = await loadState();
|
|
state.nextScheduledAt = at;
|
|
await saveState(state);
|
|
currentTimer = setTimeout(() => { void tick(); }, delayMs);
|
|
// Don't keep the process alive just for this.
|
|
currentTimer.unref?.();
|
|
}
|
|
|
|
async function tick(): Promise<void> {
|
|
const result = await checkOnce({ reason: 'scheduled' });
|
|
// The scheduler keeps running while third-party traffic is off, so turning
|
|
// the switch off at runtime resumes the checks; checkOnce skips meanwhile.
|
|
const skipped = result.error === NO_THIRD_PARTY_ERROR;
|
|
const delay = result.ok || skipped ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
|
|
await scheduleNext(delay);
|
|
}
|
|
|
|
// Idempotent - safe to call from instrumentation hot-reload in dev.
|
|
export async function startScheduler(): Promise<void> {
|
|
if (disabledByEnv()) {
|
|
logger.info('version-check: scheduler not started (disabled by env)');
|
|
return;
|
|
}
|
|
const state = await loadState();
|
|
if (!effectiveEndpoint(state)) {
|
|
logger.info('version-check: scheduler not started (no endpoint)');
|
|
return;
|
|
}
|
|
// State carried over from a previous build: the cached status is about a
|
|
// version we no longer run, and its schedule would keep that stale answer
|
|
// (e.g. "update available" for the version we just upgraded from) on screen
|
|
// for up to an hour. Drop it and check promptly instead (#913).
|
|
const stale = isStatusStale(state.status);
|
|
if (stale) {
|
|
logger.info('version-check: discarding status from a previous version', {
|
|
cached: state.status?.current ?? null,
|
|
running: getCurrentVersion(),
|
|
});
|
|
state.status = null;
|
|
state.nextScheduledAt = null;
|
|
await saveState(state);
|
|
}
|
|
|
|
// If a previous schedule was still in the future, honor it (don't blast on
|
|
// every restart). Cap at one hour so a wildly-in-the-future timestamp can't
|
|
// permanently silence the check.
|
|
let delay = FIRST_DELAY_MS;
|
|
if (!stale && state.nextScheduledAt) {
|
|
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
|
|
if (remaining > 0) delay = Math.min(remaining, HOUR_MS + JITTER_MS);
|
|
}
|
|
await scheduleNext(delay);
|
|
logger.info('version-check: scheduler started', { nextInMs: delay });
|
|
}
|
|
|
|
export async function stopScheduler(): Promise<void> {
|
|
if (currentTimer) clearTimeout(currentTimer);
|
|
currentTimer = null;
|
|
}
|