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:
+53
-25
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user