( endpoint: string, params: Record<string, string | number | string[] | undefined>, ttlMs?: number, )
| 127 | * Returns null on cache miss or any read/parse error. |
| 128 | */ |
| 129 | export function readCache( |
| 130 | endpoint: string, |
| 131 | params: Record<string, string | number | string[] | undefined>, |
| 132 | ttlMs?: number, |
| 133 | ): { data: Record<string, unknown>; url: string } | null { |
| 134 | const cacheKey = buildCacheKey(endpoint, params); |
| 135 | const filepath = join(CACHE_DIR, cacheKey); |
| 136 | const label = describeRequest(endpoint, params); |
| 137 | |
| 138 | if (!existsSync(filepath)) { |
| 139 | return null; |
| 140 | } |
| 141 | |
| 142 | try { |
| 143 | const content = readFileSync(filepath, 'utf-8'); |
| 144 | const parsed: unknown = JSON.parse(content); |
| 145 | |
| 146 | if (!isValidCacheEntry(parsed)) { |
| 147 | logger.warn(`Cache corrupted (invalid structure): ${label}`, { filepath }); |
| 148 | removeCacheFile(filepath); |
| 149 | return null; |
| 150 | } |
| 151 | |
| 152 | // TTL enforcement: return null if entry has expired |
| 153 | if (ttlMs !== undefined) { |
| 154 | const age = Date.now() - Date.parse(parsed.cachedAt); |
| 155 | if (age > ttlMs) { |
| 156 | return null; |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | return { data: parsed.data, url: parsed.url }; |
| 161 | } catch (error) { |
| 162 | const message = error instanceof Error ? error.message : String(error); |
| 163 | logger.warn(`Cache read error: ${label} — ${message}`, { filepath }); |
| 164 | removeCacheFile(filepath); |
| 165 | return null; |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Write an API response to the cache. |
no test coverage detected