Files
2026-07-13 13:39:12 +08:00

207 lines
6.4 KiB
TypeScript

/**
* AutoRefreshDaemon — Background cookie validity checker for web-cookie providers
*
* Periodically checks stored credentials for web-cookie providers by making
* lightweight requests to their home pages. If a credential is expired, it
* logs a warning and marks the credential for re-authentication.
*
* The daemon does NOT automatically re-login (that requires user interaction
* for security). It alerts the system so higher-level components can decide
* what to do (e.g., fallback to another provider, prompt user to re-login).
*/
import { TOKEN_EXTRACTION_CONFIGS } from "./tokenExtractionConfig";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface DaemonStatus {
running: boolean;
checkedProviderCount: number;
expiredCredentials: string[];
lastRun: number | null;
}
interface StoredCredentialEntry {
providerId: string;
value: string;
storedAt: number;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const DEFAULT_CHECK_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
const MIN_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute minimum
// ─── Daemon ─────────────────────────────────────────────────────────────────
class AutoRefreshDaemon {
private timerId: ReturnType<typeof setInterval> | null = null;
private running = false;
private checkIntervalMs: number;
private expiredCredentials: string[] = [];
private lastRun: number | null = null;
/** In-memory store of web-cookie credentials (real persistence uses SQLite) */
private credentialStore = new Map<string, StoredCredentialEntry>();
constructor(checkIntervalMs = DEFAULT_CHECK_INTERVAL_MS) {
this.checkIntervalMs = Math.max(checkIntervalMs, MIN_CHECK_INTERVAL_MS);
}
/**
* Register a credential for auto-refresh monitoring.
* Called when credentials are extracted/updated.
*/
registerCredential(providerId: string, value: string): void {
this.credentialStore.set(providerId, {
providerId,
value,
storedAt: Date.now(),
});
}
/**
* Remove a credential from monitoring (e.g., provider deleted)
*/
unregisterCredential(providerId: string): void {
this.credentialStore.delete(providerId);
}
/**
* Start the daemon — begins periodic credential checks
*/
start(): void {
if (this.running) return;
this.running = true;
// Run an initial check immediately
this.check().catch(() => {});
this.timerId = setInterval(() => {
this.check().catch(() => {});
}, this.checkIntervalMs);
// Don't keep the process alive solely for this periodic daemon.
(this.timerId as { unref?: () => void })?.unref?.();
console.log(
`[AutoRefreshDaemon] Started — checking ${this.credentialStore.size} credentials every ${this.checkIntervalMs / 1000}s`
);
}
/**
* Stop the daemon
*/
stop(): void {
if (!this.running) return;
this.running = false;
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
}
console.log("[AutoRefreshDaemon] Stopped");
}
/**
* Check all stored credentials for validity.
* Makes a lightweight HEAD/GET request to the provider's home page.
*/
async check(): Promise<void> {
this.lastRun = Date.now();
const newlyExpired: string[] = [];
const entries = [...this.credentialStore.entries()];
for (const [providerId] of entries) {
const config = TOKEN_EXTRACTION_CONFIGS.get(providerId);
if (!config) {
this.credentialStore.delete(providerId);
continue;
}
try {
const isValid = await this.validateCredential(providerId, config.homeUrl);
if (!isValid) {
newlyExpired.push(providerId);
console.warn(
`[AutoRefreshDaemon] Credential expired for "${providerId}" (${config.displayName})`
);
}
} catch {
// Network errors are non-fatal — retry next cycle
}
}
// Update expired list
for (const id of newlyExpired) {
if (!this.expiredCredentials.includes(id)) {
this.expiredCredentials.push(id);
}
}
}
/**
* Validate a credential by making a request to the provider's home page.
* Returns true if the response suggests the credential is still valid.
*/
private async validateCredential(providerId: string, homeUrl: string): Promise<boolean> {
const entry = this.credentialStore.get(providerId);
if (!entry) return false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(homeUrl, {
method: "HEAD",
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
},
});
// A valid credential typically returns 200 (occasionally 301/302)
// 401/403 strongly suggest expired credential
if (response.status === 401 || response.status === 403) {
return false;
}
return true;
} catch {
// Network errors (timeout, DNS failure) don't mean the credential is bad
return true;
} finally {
clearTimeout(timeout);
}
}
/**
* Get the current daemon status
*/
getStatus(): DaemonStatus {
return {
running: this.running,
checkedProviderCount: this.credentialStore.size,
expiredCredentials: [...this.expiredCredentials],
lastRun: this.lastRun,
};
}
/**
* Clear expired credentials list (e.g., after re-authentication)
*/
clearExpired(): void {
this.expiredCredentials = [];
}
/**
* Restart the daemon (useful when config changes)
*/
restart(): void {
this.stop();
this.start();
}
}
// ─── Singleton ──────────────────────────────────────────────────────────────
export const autoRefreshDaemon = new AutoRefreshDaemon();