From 185632c053d69b996fab8f372240b68a37ec6a64 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 18:47:12 +0000 Subject: [PATCH 1/2] feat: add persistent shared version cache for multiple connections - Add version-cache.ts with file-based persistence - 150 connections = 1 request (not 150) via request deduplication - Cache survives server restarts via .baileys-version-cache.json - Export cache utilities: getCachedVersion, refreshVersionCache, clearVersionCache - Update makeWASocketAutoVersion to use shared cache --- src/Socket/index.ts | 78 ++++++++----- src/Utils/index.ts | 3 + src/Utils/version-cache.ts | 224 +++++++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 src/Utils/version-cache.ts 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 + } +} From 29475018a31f13805f3ac174aa53f2aad2767023 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 19:14:32 +0000 Subject: [PATCH 2/2] fix: address PR review comments for version cache - Replace Function type with proper VersionCacheLogger interface - Add documentation for cacheFilePath about container/serverless limitations - Handle fetchInProgress in clearVersionCache to prevent cache restoration - Add deduplication check in refreshVersionCache - Remove unused fetchLatestWaWebVersion import - Return success status from refreshVersionCache to detect fallback - Fix inefficient getCachedVersion call - use cacheStatus.version directly - Remove as any casts by using logger adapter pattern - Don't downgrade version on transient network errors --- src/Socket/index.ts | 42 +++++++++++++---- src/Utils/version-cache.ts | 96 ++++++++++++++++++++++++++++++++------ 2 files changed, 114 insertions(+), 24 deletions(-) diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 4b50d9ce..8862579b 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -1,9 +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 @@ -71,6 +83,7 @@ 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) @@ -94,7 +107,7 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => try { const { version, fromCache, age } = await getCachedVersion({ cacheTtlMs: checkIntervalMs, - logger: logger as any + logger: cacheLogger }) logger?.info( @@ -153,17 +166,28 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => // 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...') - newVersion = await refreshVersionCache({ logger: logger as any }) + 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 { - // Cache still valid, just check if different from our tracked version - const { version } = await getCachedVersion({ - cacheTtlMs: checkIntervalMs, - logger: logger as any - }) - newVersion = version + // No cache available, skip this check + return } // Double-check socket is still open after async operation (Fix #8) diff --git a/src/Utils/version-cache.ts b/src/Utils/version-cache.ts index eb49acb5..9941d890 100644 --- a/src/Utils/version-cache.ts +++ b/src/Utils/version-cache.ts @@ -12,6 +12,15 @@ interface VersionCacheEntry { 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 */ @@ -28,7 +37,11 @@ let fetchInProgress: Promise | null = null const DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1000 /** - * Default cache file path + * 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') @@ -38,10 +51,24 @@ const DEFAULT_CACHE_FILE = join(process.cwd(), '.baileys-version-cache.json') export interface VersionCacheConfig { /** Cache TTL in milliseconds (default: 6 hours) */ cacheTtlMs?: number - /** Path to cache file (default: .baileys-version-cache.json in cwd) */ + /** + * 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?: { info: Function; debug: Function; warn: Function } + logger?: VersionCacheLogger +} + +/** + * Result from refreshVersionCache with success status + */ +export interface RefreshVersionResult { + version: WAVersion + success: boolean + source: 'online' | 'fallback' } /** @@ -81,9 +108,9 @@ function isCacheValid(entry: VersionCacheEntry | null, ttlMs: number): boolean { */ async function fetchVersionOnce( cacheFilePath: string, - logger?: VersionCacheConfig['logger'] + logger?: VersionCacheLogger ): Promise { - logger?.info('Fetching WhatsApp Web version (shared for all connections)...') + logger?.info({}, 'Fetching WhatsApp Web version (shared for all connections)...') const result = await fetchLatestWaWebVersion() @@ -125,7 +152,7 @@ async function fetchVersionOnce( */ export async function getCachedVersion( config: VersionCacheConfig = {} -): Promise<{ version: WAVersion; fromCache: boolean; age: number }> { +): Promise<{ version: WAVersion; fromCache: boolean; age: number; source: 'online' | 'fallback' | 'memory' | 'file' }> { const { cacheTtlMs = DEFAULT_CACHE_TTL_MS, cacheFilePath = DEFAULT_CACHE_FILE, @@ -136,7 +163,7 @@ export async function getCachedVersion( 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 } + return { version: memoryCache!.version, fromCache: true, age, source: 'memory' } } // 2. Check file cache (survives restarts) @@ -145,7 +172,7 @@ export async function getCachedVersion( 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 } + return { version: fileCache!.version, fromCache: true, age, source: 'file' } } // 3. Need to fetch - but deduplicate concurrent requests @@ -156,16 +183,29 @@ export async function getCachedVersion( } const entry = await fetchInProgress - return { version: entry.version, fromCache: false, age: 0 } + return { version: entry.version, fromCache: false, age: 0, source: entry.source } } /** - * Clears the version cache (memory and file) + * 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 { @@ -174,19 +214,42 @@ export async function clearVersionCache( } /** - * Forces a refresh of the cached version + * 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 { +): 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 entry.version + + return { + version: entry.version, + success: entry.source === 'online', + source: entry.source + } } /** @@ -200,6 +263,7 @@ export function getVersionCacheStatus( age: number | null isExpired: boolean expiresIn: number | null + source: 'online' | 'fallback' | null } { if (!memoryCache) { return { @@ -207,7 +271,8 @@ export function getVersionCacheStatus( version: null, age: null, isExpired: true, - expiresIn: null + expiresIn: null, + source: null } } @@ -219,6 +284,7 @@ export function getVersionCacheStatus( version: memoryCache.version, age, isExpired, - expiresIn: isExpired ? 0 : cacheTtlMs - age + expiresIn: isExpired ? 0 : cacheTtlMs - age, + source: memoryCache.source } }