{
  "$schema": "https://hyperjs.ai/schema/registry-item.json",
  "name": "cache",
  "version": "0.1.0",
  "description": "SWR + ETag + stampede protection for Hyper routes.",
  "readme": "# @hyper/cache\n\nSWR + ETag + stampede protection for Hyper routes.\n\n## Install\n\nComponents are installed as source into your repo, not pulled from npm:\n\n```bash\nbunx hyper add cache\n```\n\nWires the alias `@hyper/cache` to `src/hyper/cache/` (configurable in `hyper.config.json`). See [hyperjs.ai](https://hyperjs.ai) for the full registry.\n\n## Usage\n\n```ts\nimport { Hyper, ok } from \"@hyper/core\"\nimport { cache } from \"@hyper/cache\"\n\nexport default new Hyper()\n  .use(cache({ maxAgeMs: 60_000 }))\n  .get(\"/feed\", async () => ok(await loadFeed()))\n  .listen(3000)\n```\n\n## Docs\n\nSee the [main README](../../README.md) and [docs/](../../docs) for guides and integration recipes.\n\n## License\n\nMIT\n",
  "registryDeps": [
    "core"
  ],
  "peerDeps": {},
  "optionalPeerDeps": {},
  "files": [
    {
      "path": "cache/index.ts",
      "contents": "/**\n * @hyper/cache — stale-while-revalidate + ETag + stampede protection.\n *\n * Keyed by method + URL. Only caches GET/HEAD.\n *\n *   fresh (age <= maxAge):       serve from cache (no revalidation)\n *   stale (age <= maxAge+swr):   serve stale + kick off background refresh\n *   dead (older than stale):     synchronous refresh, single-flight locked\n *\n * ETag:\n *   - We auto-generate a weak ETag from the response body (xxhash3 if\n *     available, SHA-1 fallback).\n *   - If `If-None-Match` matches, we return 304.\n */\n\nimport { type Middleware, coerce } from \"@hyper/core\"\n\nexport interface CacheEntry {\n  readonly status: number\n  readonly headers: Record<string, string>\n  readonly body: Uint8Array\n  readonly etag: string\n  readonly createdAt: number\n}\n\nexport interface CacheStore {\n  get(key: string): Promise<CacheEntry | undefined>\n  set(key: string, value: CacheEntry): Promise<void>\n}\n\nexport function memoryCache(): CacheStore {\n  const map = new Map<string, CacheEntry>()\n  return {\n    async get(k) {\n      return map.get(k)\n    },\n    async set(k, v) {\n      map.set(k, v)\n    },\n  }\n}\n\nexport interface CacheConfig {\n  readonly store?: CacheStore\n  /** Seconds. */\n  readonly maxAge: number\n  /** Seconds of stale grace for SWR. */\n  readonly staleWhileRevalidate?: number\n  readonly methods?: readonly string[]\n}\n\nexport function cache(config: CacheConfig): Middleware {\n  const store = config.store ?? memoryCache()\n  const maxAgeMs = config.maxAge * 1000\n  const swrMs = (config.staleWhileRevalidate ?? 0) * 1000\n  const methods = new Set(config.methods ?? [\"GET\", \"HEAD\"])\n  const inFlight = new Map<string, Promise<CacheEntry>>()\n\n  return async ({ req, next }) => {\n    if (!methods.has(req.method)) return next()\n    const key = `${req.method} ${req.url}`\n    const now = Date.now()\n    const existing = await store.get(key)\n    const ifNoneMatch = req.headers.get(\"if-none-match\")\n\n    if (existing) {\n      const age = now - existing.createdAt\n      const ageSec = Math.floor(age / 1000)\n      if (age <= maxAgeMs) {\n        if (ifNoneMatch && ifNoneMatch === existing.etag) {\n          return notModified(existing.etag)\n        }\n        return materialize(existing, \"fresh\", ageSec, maxAgeMs / 1000, swrMs / 1000)\n      }\n      if (age <= maxAgeMs + swrMs) {\n        if (!inFlight.has(key)) {\n          inFlight.set(\n            key,\n            refresh({ next, store, key })\n              .catch(() => existing)\n              .finally(() => inFlight.delete(key)),\n          )\n        }\n        if (ifNoneMatch && ifNoneMatch === existing.etag) {\n          return notModified(existing.etag)\n        }\n        return materialize(existing, \"stale\", ageSec, maxAgeMs / 1000, swrMs / 1000)\n      }\n    }\n\n    let p = inFlight.get(key)\n    if (!p) {\n      p = refresh({ next, store, key })\n      inFlight.set(key, p)\n      p.finally(() => inFlight.delete(key))\n    }\n    const entry = await p\n    if (ifNoneMatch && ifNoneMatch === entry.etag) return notModified(entry.etag)\n    return materialize(entry, \"miss\", 0, maxAgeMs / 1000, swrMs / 1000)\n  }\n}\n\nasync function refresh(args: {\n  next: () => Promise<unknown>\n  store: CacheStore\n  key: string\n}): Promise<CacheEntry> {\n  const out = await args.next()\n  const res = out instanceof Response ? out : coerce(out)\n  const body = new Uint8Array(await res.clone().arrayBuffer())\n  const headers: Record<string, string> = {}\n  for (const [k, v] of res.headers) headers[k] = v\n  const etag = `W/\"${await etagOf(body)}\"`\n  const entry: CacheEntry = {\n    status: res.status,\n    headers,\n    body,\n    etag,\n    createdAt: Date.now(),\n  }\n  if (res.status >= 200 && res.status < 300) await args.store.set(args.key, entry)\n  return entry\n}\n\nfunction materialize(\n  entry: CacheEntry,\n  mode: \"fresh\" | \"stale\" | \"miss\",\n  age: number,\n  maxAge: number,\n  swr: number,\n): Response {\n  const h = new Headers(entry.headers)\n  h.set(\"etag\", entry.etag)\n  h.set(\"age\", age.toString())\n  h.set(\"x-cache\", mode)\n  if (maxAge > 0) {\n    h.set(\n      \"cache-control\",\n      swr > 0\n        ? `public, max-age=${maxAge}, stale-while-revalidate=${swr}`\n        : `public, max-age=${maxAge}`,\n    )\n  }\n  return new Response(entry.body, { status: entry.status, headers: h })\n}\n\nfunction notModified(etag: string): Response {\n  return new Response(null, { status: 304, headers: { etag } })\n}\n\nasync function etagOf(buf: Uint8Array): Promise<string> {\n  const xx = (Bun as unknown as { hash?: { xxHash3?: (b: Uint8Array) => bigint } }).hash?.xxHash3\n  if (xx) return xx(buf).toString(16)\n  const d = await crypto.subtle.digest(\"SHA-1\", buf)\n  return Array.from(new Uint8Array(d))\n    .map((b) => b.toString(16).padStart(2, \"0\"))\n    .join(\"\")\n}\n",
      "sha256": "0edc6e62868a82c1aceeb0c541b20c41e93eaf6b374c0b5cba955a171209861a"
    },
    {
      "path": "cache/sqlite.ts",
      "contents": "/**\n * bun:sqlite-backed CacheStore. Persistent, zero-dependency, ~10k rps.\n *\n *   import { sqliteCache } from \"@hyper/cache/sqlite\"\n *   const store = sqliteCache({ path: \"./cache.sqlite\" })\n *   use(cache({ maxAge: 60, store }))\n *\n * The schema is WAL-mode, synchronous=NORMAL, with a size/entry cap.\n * Sweeps happen on read (lazy) and on `.sweep()` (manual).\n */\n\nimport { Database } from \"bun:sqlite\"\nimport type { CacheEntry, CacheStore } from \"./index.ts\"\n\nexport interface SqliteCacheOptions {\n  /** File path. `\":memory:\"` for RAM-only. Default: `./.hyper/cache.sqlite`. */\n  readonly path?: string\n  /** Max entries; oldest evicted first. Default: 100_000. */\n  readonly maxEntries?: number\n}\n\nexport function sqliteCache(opts: SqliteCacheOptions = {}): CacheStore & {\n  readonly sweep: () => void\n  readonly close: () => void\n} {\n  const path = opts.path ?? \"./.hyper/cache.sqlite\"\n  const maxEntries = opts.maxEntries ?? 100_000\n  const db = new Database(path, { create: true })\n  db.exec(\"PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;\")\n  db.exec(`\n    CREATE TABLE IF NOT EXISTS hyper_cache (\n      k TEXT PRIMARY KEY,\n      status INTEGER NOT NULL,\n      headers TEXT NOT NULL,\n      body BLOB NOT NULL,\n      etag TEXT NOT NULL,\n      created_at INTEGER NOT NULL\n    );\n    CREATE INDEX IF NOT EXISTS hyper_cache_created_at ON hyper_cache (created_at);\n  `)\n  const stmtGet = db.prepare(\n    \"SELECT status, headers, body, etag, created_at AS createdAt FROM hyper_cache WHERE k = ?\",\n  )\n  const stmtSet = db.prepare(\n    \"INSERT OR REPLACE INTO hyper_cache (k, status, headers, body, etag, created_at) VALUES (?,?,?,?,?,?)\",\n  )\n  const stmtCount = db.prepare(\"SELECT COUNT(*) AS c FROM hyper_cache\")\n  const stmtPrune = db.prepare(\n    \"DELETE FROM hyper_cache WHERE rowid IN (SELECT rowid FROM hyper_cache ORDER BY created_at ASC LIMIT ?)\",\n  )\n\n  const sweep = () => {\n    const row = stmtCount.get() as { c: number } | undefined\n    if (row && row.c > maxEntries) {\n      stmtPrune.run(row.c - maxEntries)\n    }\n  }\n\n  return {\n    async get(key) {\n      const row = stmtGet.get(key) as\n        | { status: number; headers: string; body: Uint8Array; etag: string; createdAt: number }\n        | undefined\n      if (!row) return undefined\n      return {\n        status: row.status,\n        headers: JSON.parse(row.headers) as Record<string, string>,\n        body: row.body,\n        etag: row.etag,\n        createdAt: row.createdAt,\n      } satisfies CacheEntry\n    },\n    async set(key, value) {\n      stmtSet.run(\n        key,\n        value.status,\n        JSON.stringify(value.headers),\n        value.body,\n        value.etag,\n        value.createdAt,\n      )\n      sweep()\n    },\n    sweep,\n    close: () => db.close(),\n  }\n}\n",
      "sha256": "a31497d6bb54b3dbdf0286c0a5824ba7115a28c1f8e68e0ea889999363787c22"
    }
  ],
  "subpaths": {}
}