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
This commit is contained in:
Claude
2026-01-21 18:47:12 +00:00
parent 8ef129bd3d
commit 185632c053
3 changed files with 280 additions and 25 deletions
+53 -25
View File
@@ -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
+3
View File
@@ -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'
+224
View File
@@ -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<VersionCacheEntry> | 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<VersionCacheEntry | null> {
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<void> {
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<VersionCacheEntry> {
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<void> {
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<WAVersion> {
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
}
}