/ src / browser / network-cache.ts
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  }