network-cache.ts
1 /** 2 * Persistent cache for browser network captures. 3 * 4 * The live capture buffer (JS interceptor / daemon ring) can be cleared 5 * by navigation or lost between CLI invocations. Agents still need 6 * stable references to request bodies after running other commands, 7 * so every `browser network` call snapshots its results to disk. 8 * 9 * Layout: <cacheDir>/browser-network/<workspace>.json 10 * Entries expire after DEFAULT_TTL_MS (24h). 11 */ 12 13 import * as fs from 'node:fs'; 14 import * as os from 'node:os'; 15 import * as path from 'node:path'; 16 17 export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; 18 19 export interface CachedNetworkEntry { 20 key: string; 21 url: string; 22 method: string; 23 status: number; 24 /** Full body size in chars (may exceed stored body length when truncated). */ 25 size: number; 26 ct: string; 27 body: unknown; 28 /** 29 * Truncation signals use snake_case so `--raw` (which emits cache entries 30 * verbatim) matches the agent-facing contract used by list / --detail. 31 */ 32 body_truncated?: boolean; 33 body_full_size?: number; 34 } 35 36 export interface NetworkCacheFile { 37 version: 1; 38 workspace: string; 39 savedAt: string; 40 entries: CachedNetworkEntry[]; 41 } 42 43 function getDefaultCacheDir(): string { 44 return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache'); 45 } 46 47 export function getCachePath(workspace: string, baseDir: string = getDefaultCacheDir()): string { 48 const safe = workspace.replace(/[^a-zA-Z0-9_-]+/g, '_'); 49 return path.join(baseDir, 'browser-network', `${safe}.json`); 50 } 51 52 export function saveNetworkCache( 53 workspace: string, 54 entries: CachedNetworkEntry[], 55 baseDir?: string, 56 ): void { 57 const target = getCachePath(workspace, baseDir); 58 fs.mkdirSync(path.dirname(target), { recursive: true }); 59 const payload: NetworkCacheFile = { 60 version: 1, 61 workspace, 62 savedAt: new Date().toISOString(), 63 entries, 64 }; 65 fs.writeFileSync(target, JSON.stringify(payload), 'utf-8'); 66 } 67 68 export interface LoadOptions { 69 baseDir?: string; 70 ttlMs?: number; 71 now?: number; 72 } 73 74 export interface LoadResult { 75 status: 'ok' | 'missing' | 'expired' | 'corrupt'; 76 file?: NetworkCacheFile; 77 ageMs?: number; 78 } 79 80 export function loadNetworkCache(workspace: string, opts: LoadOptions = {}): LoadResult { 81 const target = getCachePath(workspace, opts.baseDir); 82 let raw: string; 83 try { raw = fs.readFileSync(target, 'utf-8'); } 84 catch { return { status: 'missing' }; } 85 86 let parsed: NetworkCacheFile; 87 try { 88 const obj = JSON.parse(raw); 89 if (!obj || obj.version !== 1 || !Array.isArray(obj.entries)) { 90 return { status: 'corrupt' }; 91 } 92 parsed = obj as NetworkCacheFile; 93 } catch { 94 return { status: 'corrupt' }; 95 } 96 97 const ttl = opts.ttlMs ?? DEFAULT_TTL_MS; 98 const now = opts.now ?? Date.now(); 99 const savedAt = Date.parse(parsed.savedAt); 100 if (!Number.isFinite(savedAt)) return { status: 'corrupt' }; 101 const ageMs = now - savedAt; 102 if (ageMs > ttl) return { status: 'expired', file: parsed, ageMs }; 103 104 return { status: 'ok', file: parsed, ageMs }; 105 } 106 107 export function findEntry(file: NetworkCacheFile, key: string): CachedNetworkEntry | null { 108 return file.entries.find((e) => e.key === key) ?? null; 109 }