Skip to main content
Glama
cacheDisk.ts8.78 kB
import { mkdir, readFile, rename, rm, stat, unlink, writeFile, } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { deserialize, serialize } from 'node:v8'; import { gunzipSync, gzipSync } from 'node:zlib'; import type { IntlayerConfig } from '@intlayer/types'; import configPackageJson from '@intlayer/types/package.json' with { type: 'json', }; import { type CacheKey, clearAllCache, computeKeyId } from './cacheMemory'; /** ------------------------- Persistence layer ------------------------- **/ /** Cache envelope structure stored on disk */ type CacheEnvelope = { /** Version of the config package (for cache invalidation) */ version: string; /** Timestamp when the cache entry was created (in milliseconds) */ timestamp: number; /** Data payload (the actual cached value) */ data: unknown; }; type LocalCacheOptions = { /** Preferred new option name */ persistent?: boolean; /** Time-to-live in ms; if expired, disk entry is ignored. */ ttlMs?: number; /** Max age in ms based on stored creation timestamp; invalidates on exceed. */ maxTimeMs?: number; /** Optional namespace to separate different logical caches. */ namespace?: string; /** Gzip values on disk (on by default for blobs > 1KB). */ compress?: boolean; }; const DEFAULTS: Required<Pick<LocalCacheOptions, 'compress'>> = { compress: true, }; const ensureDir = async (dir: string) => { await mkdir(dir, { recursive: true }); }; const atomicWriteFile = async (file: string, data: Buffer) => { const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`; await writeFile(tmp, data); await rename(tmp, file); }; const shouldUseCompression = (buf: Buffer, force?: boolean) => force === true || (force !== false && buf.byteLength > 1024); /** Derive on-disk path from config dir + namespace + key id. */ const cachePath = (cacheDir: string, id: string, ns?: string) => join(cacheDir, ns ? join(ns, id) : id); /** ------------------------- Local cache facade ------------------------- **/ const cacheMap = new Map<string, any>(); export const cacheDisk = ( intlayerConfig: IntlayerConfig, keys: CacheKey[], options?: LocalCacheOptions ) => { const { cacheDir } = intlayerConfig.content; const buildCacheEnabled = intlayerConfig.build.cache ?? true; const persistent = options?.persistent === true || (typeof options?.persistent === 'undefined' && buildCacheEnabled); const { compress, ttlMs, maxTimeMs, namespace } = { ...DEFAULTS, ...options, }; // single stable id for this key tuple (works for both memory & disk) const id = computeKeyId(keys); const filePath = cachePath(cacheDir, id, namespace); const readFromDisk = async (): Promise<unknown | undefined> => { try { const statValue = await stat(filePath).catch(() => undefined); if (!statValue) return undefined; if (typeof ttlMs === 'number' && ttlMs > 0) { const age = Date.now() - statValue.mtimeMs; if (age > ttlMs) return undefined; } let raw = await readFile(filePath); // header: 1 byte flag (0x00 raw, 0x01 gzip) const flag = raw[0]; raw = raw.subarray(1); const payload = flag === 0x01 ? gunzipSync(raw) : raw; const deserialized = deserialize(payload) as unknown; let value: unknown; const maybeObj = deserialized as Record<string, unknown> | null; const isEnvelope = !!maybeObj && typeof maybeObj === 'object' && typeof (maybeObj as any).version === 'string' && typeof (maybeObj as any).timestamp === 'number' && Object.hasOwn(maybeObj, 'data'); if (isEnvelope) { const entry = maybeObj as CacheEnvelope; if (entry.version !== configPackageJson.version) { try { await unlink(filePath); } catch {} return undefined; } if (typeof maxTimeMs === 'number' && maxTimeMs > 0) { const age = Date.now() - entry.timestamp; if (age > maxTimeMs) { try { await unlink(filePath); } catch {} return undefined; } } value = entry.data; } else { // Backward compatibility: old entries had raw serialized value. if (typeof maxTimeMs === 'number' && maxTimeMs > 0) { const age = Date.now() - statValue.mtimeMs; if (age > maxTimeMs) { try { await unlink(filePath); } catch {} return undefined; } } value = deserialized; } // hydrate memory cache as well cacheMap.set(id, value); return value; } catch { return undefined; } }; const writeToDisk = async (value: unknown) => { try { await ensureDir(dirname(filePath)); const envelope: CacheEnvelope = { version: configPackageJson.version, timestamp: Date.now(), data: value, }; const payload = Buffer.from(serialize(envelope)); const gz = shouldUseCompression(payload, compress) ? gzipSync(payload) : payload; // prepend a 1-byte header indicating compression const buf = Buffer.concat([ Buffer.from([gz === payload ? 0x00 : 0x01]), gz, ]); await atomicWriteFile(filePath, buf); } catch { // swallow disk errors for cache writes } }; return { /** In-memory first, then disk (if enabled), otherwise undefined. */ get: async <T>(): Promise<T | undefined> => { const mem = cacheMap.get(id); if (mem !== undefined) return mem as T; if (persistent && buildCacheEnabled) { return (await readFromDisk()) as T | undefined; } return undefined; }, /** Sets in-memory (always) and persists to disk if enabled. */ set: async (value: unknown): Promise<void> => { cacheMap.set(id, value); if (persistent && buildCacheEnabled) { await writeToDisk(value); } }, /** Clears only this entry from memory and disk. */ clear: async (): Promise<void> => { cacheMap.delete(id); try { await unlink(filePath); } catch {} }, /** Clears ALL cached entries (memory Map and entire cacheDir namespace if persistent). */ clearAll: async (): Promise<void> => { clearAllCache(); if (persistent && buildCacheEnabled) { // remove only the current namespace (if provided), else the root dir const base = namespace ? join(cacheDir, namespace) : cacheDir; try { await rm(base, { recursive: true, force: true }); } catch {} try { await mkdir(base, { recursive: true }); } catch {} } }, /** Expose the computed id (useful if you want to key other structures). */ isValid: async (): Promise<boolean> => { const cachedValue = cacheMap.get(id); if (cachedValue !== undefined) return true; // If persistence is disabled or build cache disabled, only memory can be valid if (!persistent || !buildCacheEnabled) return false; try { const statValue = await stat(filePath).catch(() => undefined); if (!statValue) return false; if (typeof ttlMs === 'number' && ttlMs > 0) { const age = Date.now() - statValue.mtimeMs; if (age > ttlMs) return false; } let raw = await readFile(filePath); const flag = raw[0]; raw = raw.subarray(1); const payload = flag === 0x01 ? gunzipSync(raw) : raw; const deserialized = deserialize(payload) as unknown; const maybeObj = deserialized as Record<string, unknown> | null; const isEnvelope = !!maybeObj && typeof maybeObj === 'object' && typeof maybeObj.version === 'string' && typeof maybeObj.timestamp === 'number' && Object.hasOwn(maybeObj, 'data'); if (isEnvelope) { const entry = maybeObj as CacheEnvelope; if (entry.version !== configPackageJson.version) return false; if (typeof maxTimeMs === 'number' && maxTimeMs > 0) { const age = Date.now() - entry.timestamp; if (age > maxTimeMs) return false; } return true; } if (typeof maxTimeMs === 'number' && maxTimeMs > 0) { const age = Date.now() - statValue.mtimeMs; if (age > maxTimeMs) return false; } return true; } catch { return false; } }, /** Expose the computed id (useful if you want to key other structures). */ id, /** Expose the absolute file path for debugging. */ filePath, }; };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server