Add on-disk cache for forecasts
This commit is contained in:
parent
dd31dd5853
commit
5f18e14631
2 changed files with 134 additions and 10 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
node_modules
|
node_modules
|
||||||
**/debug.log
|
**/debug.log
|
||||||
server/scripts/custom.js
|
server/scripts/custom.js
|
||||||
|
cache/
|
||||||
|
|
||||||
#music folder
|
#music folder
|
||||||
server/music/*
|
server/music/*
|
||||||
|
|
|
||||||
143
proxy/cache.mjs
143
proxy/cache.mjs
|
|
@ -19,18 +19,122 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import {
|
||||||
|
mkdir, readdir, readFile, rm, unlink, writeFile,
|
||||||
|
} from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// Default timeout for upstream requests (matches client-side default)
|
// Default timeout for upstream requests (matches client-side default)
|
||||||
const DEFAULT_REQUEST_TIMEOUT = 15000;
|
const DEFAULT_REQUEST_TIMEOUT = 15000;
|
||||||
|
const CACHE_DIR = path.resolve('./cache');
|
||||||
|
const STALE_TIME_LIMIT_MS = 3 * 60 * 60 * 1000;
|
||||||
|
const PERSISTED_HOSTS = new Set([
|
||||||
|
'api.open-meteo.com',
|
||||||
|
'api.rainviewer.com',
|
||||||
|
]);
|
||||||
|
const HOST_FALLBACK_TTLS = {
|
||||||
|
'api.open-meteo.com': 10 * 60,
|
||||||
|
'api.rainviewer.com': 2 * 60,
|
||||||
|
};
|
||||||
|
|
||||||
class HttpCache {
|
class HttpCache {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.inFlight = new Map();
|
this.inFlight = new Map();
|
||||||
this.cleanupInterval = null;
|
this.cleanupInterval = null;
|
||||||
|
this.hydrationPromise = this.loadPersistedEntries();
|
||||||
this.startCleanup();
|
this.startCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static hashKey(key) {
|
||||||
|
return createHash('sha256').update(key).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCacheFilePath(key) {
|
||||||
|
return path.join(CACHE_DIR, `${HttpCache.hashKey(key)}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static shouldPersist(url, options, response) {
|
||||||
|
if (options?.encoding === 'binary') return false;
|
||||||
|
if (typeof response?.data !== 'string') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return PERSISTED_HOSTS.has(parsedUrl.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getHostFallbackTtl(url) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return HOST_FALLBACK_TTLS[parsedUrl.hostname] ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureHydrated() {
|
||||||
|
await this.hydrationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPersistedEntries() {
|
||||||
|
try {
|
||||||
|
await mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
const files = await readdir(CACHE_DIR);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await Promise.allSettled(files.filter((file) => file.endsWith('.json')).map(async (file) => {
|
||||||
|
const filePath = path.join(CACHE_DIR, file);
|
||||||
|
const raw = await readFile(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed?.key || !parsed?.entry) {
|
||||||
|
await unlink(filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now > parsed.entry.expiry + STALE_TIME_LIMIT_MS) {
|
||||||
|
await unlink(filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(parsed.key, parsed.entry);
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Cache load | Failed to hydrate disk cache: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async persistEntry(key, entry) {
|
||||||
|
try {
|
||||||
|
await mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
await writeFile(HttpCache.getCacheFilePath(key), JSON.stringify({ key, entry }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Cache save | Failed to persist cache entry ${key}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deletePersistedEntry(key) {
|
||||||
|
try {
|
||||||
|
await unlink(HttpCache.getCacheFilePath(key));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.warn(`⚠️ Cache del | Failed to delete cache entry ${key}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async clearPersistedEntries() {
|
||||||
|
try {
|
||||||
|
await rm(CACHE_DIR, { recursive: true, force: true });
|
||||||
|
await mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Cache clear| Failed to clear disk cache: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse cache-control header to extract s-maxage or max-age
|
// Parse cache-control header to extract s-maxage or max-age
|
||||||
static parseCacheControl(cacheControlHeader) {
|
static parseCacheControl(cacheControlHeader) {
|
||||||
if (!cacheControlHeader) return 0;
|
if (!cacheControlHeader) return 0;
|
||||||
|
|
@ -66,15 +170,17 @@ class HttpCache {
|
||||||
|
|
||||||
// Generate cache key from request
|
// Generate cache key from request
|
||||||
static generateKey(req) {
|
static generateKey(req) {
|
||||||
const path = req.path || req.url || '/';
|
const requestPath = req.path || req.url || '/';
|
||||||
const url = req.url || req.path || '/';
|
const url = req.url || req.path || '/';
|
||||||
|
|
||||||
// Since this cache is intended only by the frontend, we can use a simple URL-based key
|
// Since this cache is intended only by the frontend, we can use a simple URL-based key
|
||||||
return `${path}${url.includes('?') ? url.substring(url.indexOf('?')) : ''}`;
|
return `${requestPath}${url.includes('?') ? url.substring(url.indexOf('?')) : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// High-level method to handle caching for HTTP proxies
|
// High-level method to handle caching for HTTP proxies
|
||||||
async handleRequest(req, res, upstreamUrl, options = {}) {
|
async handleRequest(req, res, upstreamUrl, options = {}) {
|
||||||
|
await this.ensureHydrated();
|
||||||
|
|
||||||
// Check cache status
|
// Check cache status
|
||||||
const cacheResult = this.getCachedRequest(req);
|
const cacheResult = this.getCachedRequest(req);
|
||||||
|
|
||||||
|
|
@ -262,7 +368,7 @@ class HttpCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache (pass original headers for cache logic, but store filtered headers)
|
// Store in cache (pass original headers for cache logic, but store filtered headers)
|
||||||
this.storeCachedResponse(req, response, fullUrl, getRes.headers);
|
this.storeCachedResponse(req, response, fullUrl, getRes.headers, options);
|
||||||
|
|
||||||
// Send response to client
|
// Send response to client
|
||||||
res.status(statusCode);
|
res.status(statusCode);
|
||||||
|
|
@ -358,7 +464,7 @@ class HttpCache {
|
||||||
return { status: 'stale', data: cached };
|
return { status: 'stale', data: cached };
|
||||||
}
|
}
|
||||||
|
|
||||||
storeCachedResponse(req, response, url, originalHeaders) {
|
storeCachedResponse(req, response, url, originalHeaders, options = {}) {
|
||||||
const key = HttpCache.generateKey(req);
|
const key = HttpCache.generateKey(req);
|
||||||
|
|
||||||
const cacheControl = (originalHeaders || {})['cache-control'];
|
const cacheControl = (originalHeaders || {})['cache-control'];
|
||||||
|
|
@ -376,6 +482,13 @@ class HttpCache {
|
||||||
cacheType = 'explicit';
|
cacheType = 'explicit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (maxAge <= 0) {
|
||||||
|
maxAge = HttpCache.getHostFallbackTtl(url);
|
||||||
|
if (maxAge > 0) {
|
||||||
|
cacheType = 'override';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't cache if still no valid max-age
|
// Don't cache if still no valid max-age
|
||||||
if (maxAge <= 0) {
|
if (maxAge <= 0) {
|
||||||
console.log(`📤 Sent | ${url} (no cache directives; not cached)`);
|
console.log(`📤 Sent | ${url} (no cache directives; not cached)`);
|
||||||
|
|
@ -396,6 +509,9 @@ class HttpCache {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cache.set(key, cached);
|
this.cache.set(key, cached);
|
||||||
|
if (HttpCache.shouldPersist(url, options, response)) {
|
||||||
|
HttpCache.persistEntry(key, cached);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🌐 Add | ${url} (${cacheType} ${maxAge}s TTL, expires: ${new Date(cached.expiry).toISOString()})`);
|
console.log(`🌐 Add | ${url} (${cacheType} ${maxAge}s TTL, expires: ${new Date(cached.expiry).toISOString()})`);
|
||||||
}
|
}
|
||||||
|
|
@ -426,20 +542,25 @@ class HttpCache {
|
||||||
startCleanup() {
|
startCleanup() {
|
||||||
if (this.cleanupInterval) return;
|
if (this.cleanupInterval) return;
|
||||||
|
|
||||||
this.cleanupInterval = setInterval(() => {
|
this.cleanupInterval = setInterval(async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
|
const deletePromises = [];
|
||||||
|
|
||||||
Array.from(this.cache.entries()).forEach(([key, cached]) => {
|
Array.from(this.cache.entries()).forEach(([key, cached]) => {
|
||||||
// Allow stale entries to persist for up to 3 hours before cleanup
|
// Allow stale entries to persist for up to 3 hours before cleanup
|
||||||
// This gives us time to make conditional requests and potentially refresh them
|
// This gives us time to make conditional requests and potentially refresh them
|
||||||
const staleTimeLimit = 3 * 60 * 60 * 1000;
|
if (now > cached.expiry + STALE_TIME_LIMIT_MS) {
|
||||||
if (now > cached.expiry + staleTimeLimit) {
|
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
|
deletePromises.push(HttpCache.deletePersistedEntry(key));
|
||||||
removedCount += 1;
|
removedCount += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (deletePromises.length > 0) {
|
||||||
|
await Promise.allSettled(deletePromises);
|
||||||
|
}
|
||||||
|
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
console.log(`🧹 Clean | Removed ${removedCount} stale entries (${this.cache.size} remaining)`);
|
console.log(`🧹 Clean | Removed ${removedCount} stale entries (${this.cache.size} remaining)`);
|
||||||
}
|
}
|
||||||
|
|
@ -471,15 +592,17 @@ class HttpCache {
|
||||||
// Clear all cache entries
|
// Clear all cache entries
|
||||||
clear() {
|
clear() {
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
|
HttpCache.clearPersistedEntries();
|
||||||
console.log('🗑️ Clear | Cache cleared');
|
console.log('🗑️ Clear | Cache cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear a specific cache entry by path
|
// Clear a specific cache entry by path
|
||||||
clearEntry(path) {
|
clearEntry(cachePath) {
|
||||||
const key = path;
|
const key = cachePath;
|
||||||
const deleted = this.cache.delete(key);
|
const deleted = this.cache.delete(key);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
console.log(`🗑️ Clear | ${path} removed from cache`);
|
HttpCache.deletePersistedEntry(key);
|
||||||
|
console.log(`🗑️ Clear | ${cachePath} removed from cache`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue