diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 1f825f4d..8862579b 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -1,8 +1,21 @@ import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' import type { UserFacingSocketConfig, WAVersion } from '../Types' -import { fetchLatestWaWebVersion } from '../Utils/generics' +import { getCachedVersion, refreshVersionCache, clearVersionCache, getVersionCacheStatus } from '../Utils/version-cache' +import type { VersionCacheLogger } from '../Utils/version-cache' import { makeCommunitiesSocket } from './communities' +/** + * Adapts Baileys logger to VersionCacheLogger interface + */ +const createCacheLogger = (logger: any): VersionCacheLogger | undefined => { + if (!logger) return undefined + return { + info: (obj: unknown, msg?: string) => logger.info(obj, msg), + debug: (obj: unknown, msg?: string) => logger.debug(obj, msg), + warn: (obj: unknown, msg?: string) => logger.warn(obj, msg) + } +} + /** * Compares two WhatsApp versions * @returns true if versions are different @@ -41,13 +54,16 @@ const makeWASocket = (config: UserFacingSocketConfig) => { * and periodic version checks (soft update - transparent to user). * * Features: - * - Fetches latest version on connect + * - **Shared cache**: 150 connections = 1 request (not 150) + * - **Persistent cache**: Survives server restarts + * - Fetches latest version on connect (uses cache if valid) * - Checks for new versions every 6 hours (configurable) * - Updates version on next natural reconnection (transparent) * - Emits 'version.update' event when new version is detected * * @example * ```typescript + * // All connections share the same cached version * const sock = await makeWASocketAutoVersion({ * auth: state, * versionCheckIntervalMs: 6 * 60 * 60 * 1000 // 6 hours (default) @@ -67,6 +83,8 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => } const logger = mergedConfig.logger + const cacheLogger = createCacheLogger(logger) + const checkIntervalMs = mergedConfig.versionCheckIntervalMs // Track version separately to avoid mutating config (Fix #7) let trackedVersion: WAVersion = [...mergedConfig.version] as WAVersion @@ -84,25 +102,31 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => } } - // Fetch latest version before connecting + // Fetch latest version using SHARED CACHE + // 150 connections starting = 1 request (deduplication) try { - logger?.info('Fetching latest WhatsApp Web version...') - const result = await fetchLatestWaWebVersion() + const { version, fromCache, age } = await getCachedVersion({ + cacheTtlMs: checkIntervalMs, + logger: cacheLogger + }) - if (result.isLatest) { - logger?.info({ version: result.version }, 'Using latest WhatsApp Web version') - mergedConfig.version = result.version - trackedVersion = [...result.version] as WAVersion - } else { - logger?.warn( - { error: result.error, fallbackVersion: mergedConfig.version }, - 'Failed to fetch latest version, using bundled version' - ) - } + logger?.info( + { + version, + fromCache, + ageMinutes: fromCache ? Math.round(age / 60000) : 0 + }, + fromCache + ? 'Using cached WhatsApp Web version' + : 'Fetched fresh WhatsApp Web version' + ) + + mergedConfig.version = version + trackedVersion = [...version] as WAVersion } catch (error) { logger?.warn( { error, fallbackVersion: mergedConfig.version }, - 'Error fetching latest version, using bundled version' + 'Error fetching version, using bundled version' ) } @@ -121,7 +145,6 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => }) // Setup periodic version check if interval > 0 - const checkIntervalMs = mergedConfig.versionCheckIntervalMs if (checkIntervalMs > 0) { logger?.info( { intervalHours: checkIntervalMs / (60 * 60 * 1000) }, @@ -137,7 +160,35 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => try { logger?.debug('Checking for WhatsApp Web version update...') - const result = await fetchLatestWaWebVersion() + + // Check cache status first + const cacheStatus = getVersionCacheStatus(checkIntervalMs) + + // Only refresh if cache is expired (one socket refreshes, others use cache) + let newVersion: WAVersion + let fetchSuccess = true + + if (cacheStatus.isExpired) { + logger?.debug('Cache expired, refreshing...') + const result = await refreshVersionCache({ logger: cacheLogger }) + newVersion = result.version + fetchSuccess = result.success + + // Don't update to fallback version on transient network errors + if (!fetchSuccess) { + logger?.warn( + { fallbackVersion: result.version }, + 'Failed to fetch latest version, keeping current version' + ) + return // Skip version update on fetch failure + } + } else if (cacheStatus.version) { + // Cache still valid, use cached version directly (no file I/O) + newVersion = cacheStatus.version + } else { + // No cache available, skip this check + return + } // Double-check socket is still open after async operation (Fix #8) if (isSocketClosed) { @@ -145,34 +196,32 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => return } - if (result.isLatest && versionsAreDifferent(trackedVersion, result.version)) { - const isCritical = isCriticalVersionChange(trackedVersion, result.version) + if (versionsAreDifferent(trackedVersion, newVersion)) { + const isCritical = isCriticalVersionChange(trackedVersion, newVersion) const previousVersion = trackedVersion logger?.info( { currentVersion: previousVersion, - newVersion: result.version, + newVersion: newVersion, isCritical }, 'New WhatsApp Web version detected! Will use on next reconnection.' ) // Update tracked version for next reconnection (Fix #7) - trackedVersion = [...result.version] as WAVersion + trackedVersion = [...newVersion] as WAVersion // Emit event for user to handle (only if socket still open) if (!isSocketClosed) { sock.ev.emit('version.update', { currentVersion: previousVersion, - newVersion: result.version, + newVersion: newVersion, isCritical }) } - } else if (result.isLatest) { - logger?.debug({ version: trackedVersion }, 'Version is up to date') } else { - logger?.warn({ error: result.error }, 'Failed to check for version update') + logger?.debug({ version: trackedVersion }, 'Version is up to date') } } catch (error) { logger?.warn({ error }, 'Error checking for version update') @@ -183,4 +232,7 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => return sock } +// Export cache utilities for manual control +export { getCachedVersion, refreshVersionCache, clearVersionCache, getVersionCacheStatus } + export default makeWASocket diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 9e4af329..1826cf29 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -33,5 +33,8 @@ export * from './cache-utils' export * from './circuit-breaker' export * from './retry-utils' +// Version management +export * from './version-cache' + // Event streaming export * from './baileys-event-stream' diff --git a/src/Utils/version-cache.ts b/src/Utils/version-cache.ts new file mode 100644 index 00000000..9941d890 --- /dev/null +++ b/src/Utils/version-cache.ts @@ -0,0 +1,290 @@ +import { promises as fs } from 'fs' +import { join } from 'path' +import { fetchLatestWaWebVersion } from './generics' +import type { WAVersion } from '../Types' + +/** + * Version cache entry stored in file + */ +interface VersionCacheEntry { + version: WAVersion + fetchedAt: number // timestamp in ms + source: 'online' | 'fallback' +} + +/** + * Logger interface for version cache operations + */ +export interface VersionCacheLogger { + info: (obj: unknown, msg?: string) => void + debug: (obj: unknown, msg?: string) => void + warn: (obj: unknown, msg?: string) => void +} + +/** + * In-memory cache to avoid file reads on every connection + */ +let memoryCache: VersionCacheEntry | null = null + +/** + * Promise to prevent concurrent fetches (deduplication) + */ +let fetchInProgress: Promise | null = null + +/** + * Default cache TTL: 6 hours + */ +const DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1000 + +/** + * Default cache file path. + * + * NOTE: Uses process.cwd() which may not be writable in some environments + * (containers, serverless, etc). In such cases, specify a custom `cacheFilePath` + * in the config pointing to a writable directory like `/tmp`. + */ +const DEFAULT_CACHE_FILE = join(process.cwd(), '.baileys-version-cache.json') + +/** + * Configuration for version cache + */ +export interface VersionCacheConfig { + /** Cache TTL in milliseconds (default: 6 hours) */ + cacheTtlMs?: number + /** + * Path to cache file (default: .baileys-version-cache.json in cwd) + * + * NOTE: If running in a container or serverless environment where cwd + * is not writable, specify a writable path like '/tmp/.baileys-version-cache.json' + */ + cacheFilePath?: string + /** Logger instance */ + logger?: VersionCacheLogger +} + +/** + * Result from refreshVersionCache with success status + */ +export interface RefreshVersionResult { + version: WAVersion + success: boolean + source: 'online' | 'fallback' +} + +/** + * Reads the cache from file + */ +async function readCacheFile(filePath: string): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8') + return JSON.parse(data) as VersionCacheEntry + } catch { + return null + } +} + +/** + * Writes the cache to file + */ +async function writeCacheFile(filePath: string, entry: VersionCacheEntry): Promise { + try { + await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8') + } catch { + // Ignore write errors - cache is optional + } +} + +/** + * Checks if cache entry is still valid + */ +function isCacheValid(entry: VersionCacheEntry | null, ttlMs: number): boolean { + if (!entry) return false + const age = Date.now() - entry.fetchedAt + return age < ttlMs +} + +/** + * Fetches version with deduplication (prevents 150 parallel requests) + */ +async function fetchVersionOnce( + cacheFilePath: string, + logger?: VersionCacheLogger +): Promise { + logger?.info({}, 'Fetching WhatsApp Web version (shared for all connections)...') + + const result = await fetchLatestWaWebVersion() + + const entry: VersionCacheEntry = { + version: result.version, + fetchedAt: Date.now(), + source: result.isLatest ? 'online' : 'fallback' + } + + // Update memory cache + memoryCache = entry + + // Persist to file (async, don't wait) + writeCacheFile(cacheFilePath, entry).catch(() => {}) + + logger?.info( + { version: entry.version, source: entry.source }, + 'Version fetched and cached' + ) + + return entry +} + +/** + * Gets the cached WhatsApp version, fetching if necessary. + * + * Features: + * - File-based persistence (survives restarts) + * - In-memory cache (fast access) + * - Request deduplication (150 connections = 1 request) + * - Configurable TTL + * + * @example + * ```typescript + * // All 150 connections share the same cached version + * const { version } = await getCachedVersion() + * const sock = makeWASocket({ version, auth }) + * ``` + */ +export async function getCachedVersion( + config: VersionCacheConfig = {} +): Promise<{ version: WAVersion; fromCache: boolean; age: number; source: 'online' | 'fallback' | 'memory' | 'file' }> { + const { + cacheTtlMs = DEFAULT_CACHE_TTL_MS, + cacheFilePath = DEFAULT_CACHE_FILE, + logger + } = config + + // 1. Check memory cache first (fastest) + if (isCacheValid(memoryCache, cacheTtlMs)) { + const age = Date.now() - memoryCache!.fetchedAt + logger?.debug({ age: Math.round(age / 1000) + 's' }, 'Using memory cached version') + return { version: memoryCache!.version, fromCache: true, age, source: 'memory' } + } + + // 2. Check file cache (survives restarts) + const fileCache = await readCacheFile(cacheFilePath) + if (isCacheValid(fileCache, cacheTtlMs)) { + memoryCache = fileCache // Update memory cache + const age = Date.now() - fileCache!.fetchedAt + logger?.debug({ age: Math.round(age / 1000) + 's' }, 'Using file cached version') + return { version: fileCache!.version, fromCache: true, age, source: 'file' } + } + + // 3. Need to fetch - but deduplicate concurrent requests + // If 150 connections start at once, only 1 fetch happens + if (!fetchInProgress) { + fetchInProgress = fetchVersionOnce(cacheFilePath, logger) + .finally(() => { fetchInProgress = null }) + } + + const entry = await fetchInProgress + return { version: entry.version, fromCache: false, age: 0, source: entry.source } +} + +/** + * Clears the version cache (memory and file). + * Also cancels any in-progress fetch to prevent it from restoring the cache. + */ +export async function clearVersionCache( + cacheFilePath: string = DEFAULT_CACHE_FILE +): Promise { + // Wait for any in-progress fetch to complete before clearing + // This prevents the fetch from restoring the cache after we clear it + if (fetchInProgress) { + try { + await fetchInProgress + } catch { + // Ignore fetch errors during clear + } + } + + memoryCache = null + fetchInProgress = null + + try { + await fs.unlink(cacheFilePath) + } catch { + // Ignore if file doesn't exist + } +} + +/** + * Forces a refresh of the cached version. + * Returns success status to indicate if online fetch succeeded or fell back to bundled version. + * + * @returns Object with version, success status, and source + */ +export async function refreshVersionCache( + config: VersionCacheConfig = {} +): Promise { + const { cacheFilePath = DEFAULT_CACHE_FILE, logger } = config + + // Wait for any existing fetch to complete first (deduplication) + if (fetchInProgress) { + try { + const existing = await fetchInProgress + // If there's already a fresh fetch in progress, return its result + return { + version: existing.version, + success: existing.source === 'online', + source: existing.source + } + } catch { + // Ignore and proceed with new fetch + } + } + + // Clear existing cache + memoryCache = null + + // Fetch fresh + const entry = await fetchVersionOnce(cacheFilePath, logger) + + return { + version: entry.version, + success: entry.source === 'online', + source: entry.source + } +} + +/** + * Gets cache status information + */ +export function getVersionCacheStatus( + cacheTtlMs: number = DEFAULT_CACHE_TTL_MS +): { + hasCache: boolean + version: WAVersion | null + age: number | null + isExpired: boolean + expiresIn: number | null + source: 'online' | 'fallback' | null +} { + if (!memoryCache) { + return { + hasCache: false, + version: null, + age: null, + isExpired: true, + expiresIn: null, + source: null + } + } + + const age = Date.now() - memoryCache.fetchedAt + const isExpired = age >= cacheTtlMs + + return { + hasCache: true, + version: memoryCache.version, + age, + isExpired, + expiresIn: isExpired ? 0 : cacheTtlMs - age, + source: memoryCache.source + } +}