diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 1f825f4d..4b50d9ce 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -1,6 +1,7 @@ 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 { makeCommunitiesSocket } from './communities' /** @@ -41,13 +42,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 +71,7 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => } const logger = mergedConfig.logger + const checkIntervalMs = mergedConfig.versionCheckIntervalMs // Track version separately to avoid mutating config (Fix #7) let trackedVersion: WAVersion = [...mergedConfig.version] as WAVersion @@ -84,25 +89,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: logger as any + }) - 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 +132,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 +147,24 @@ 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 + + if (cacheStatus.isExpired) { + logger?.debug('Cache expired, refreshing...') + newVersion = await refreshVersionCache({ logger: logger as any }) + } else { + // Cache still valid, just check if different from our tracked version + const { version } = await getCachedVersion({ + cacheTtlMs: checkIntervalMs, + logger: logger as any + }) + newVersion = version + } // Double-check socket is still open after async operation (Fix #8) if (isSocketClosed) { @@ -145,34 +172,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 +208,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..eb49acb5 --- /dev/null +++ b/src/Utils/version-cache.ts @@ -0,0 +1,224 @@ +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' +} + +/** + * 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 + */ +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) */ + cacheFilePath?: string + /** Logger instance */ + logger?: { info: Function; debug: Function; warn: Function } +} + +/** + * 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?: VersionCacheConfig['logger'] +): 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 }> { + 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 } + } + + // 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 } + } + + // 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 } +} + +/** + * Clears the version cache (memory and file) + */ +export async function clearVersionCache( + cacheFilePath: string = DEFAULT_CACHE_FILE +): Promise { + memoryCache = null + try { + await fs.unlink(cacheFilePath) + } catch { + // Ignore if file doesn't exist + } +} + +/** + * Forces a refresh of the cached version + */ +export async function refreshVersionCache( + config: VersionCacheConfig = {} +): Promise { + const { cacheFilePath = DEFAULT_CACHE_FILE, logger } = config + + // Clear existing cache + memoryCache = null + + // Fetch fresh + const entry = await fetchVersionOnce(cacheFilePath, logger) + return entry.version +} + +/** + * 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 +} { + if (!memoryCache) { + return { + hasCache: false, + version: null, + age: null, + isExpired: true, + expiresIn: null + } + } + + const age = Date.now() - memoryCache.fetchedAt + const isExpired = age >= cacheTtlMs + + return { + hasCache: true, + version: memoryCache.version, + age, + isExpired, + expiresIn: isExpired ? 0 : cacheTtlMs - age + } +}