From 026298d922f3fd85b19b579b75f3fa7db665ffc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 14:12:43 +0000 Subject: [PATCH 1/2] feat(hono): add first-class Node cacheMiddleware adapter Add tricache/hono as a dedicated Node Hono entry on CacheService (ttl/tags/SWR, weak ETag, If-None-Match 304, no cache for non-2xx). Keeps the existing tricache/http honoCache edge re-export unchanged. Co-authored-by: David --- CHANGELOG.md | 5 + CONTRIBUTING.md | 3 +- README.md | 1 + docs/.vitepress/config.ts | 3 +- .../theme/components/IntegrationGrid.vue | 2 +- docs/api-reference.md | 13 +- docs/changelog.md | 5 + docs/integrations/hono.md | 76 ++++++ docs/integrations/http.md | 2 + docs/integrations/index.md | 3 +- package.json | 7 +- src/hono/index.ts | 257 ++++++++++++++++++ src/http/index.ts | 5 + tests/hono.test.ts | 197 ++++++++++++++ 14 files changed, 573 insertions(+), 6 deletions(-) create mode 100644 docs/integrations/hono.md create mode 100644 src/hono/index.ts create mode 100644 tests/hono.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd97cd4..2333c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Node Hono middleware (`tricache/hono`)** — first-class `cacheMiddleware` on `CacheService` (ttl/tags/SWR, weak ETag, `If-None-Match` → 304, no cache for non-2xx). Distinct from the edge helper under `tricache/edge`. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3929086..557c851 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,8 @@ tricache/ │ ├── nestjs/ # NestJS CacheModule & interceptors │ ├── prisma/ # Prisma client caching extension │ ├── drizzle/ # Drizzle ORM query caching helper -│ └── http/ # HTTP reverse-proxy / fetch caching +│ ├── http/ # HTTP reverse-proxy / fetch caching +│ ├── hono/ # Node Hono middleware (CacheService) ├── tests/ # Vitest unit & integration test suites ├── bench/ # Microbenchmark suites ├── bin/ # CLI binaries diff --git a/README.md b/README.md index 334b5b7..7f63b41 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ const cache = CacheService.preset('enterprise-hardened', { redisHost: 'redis.int | **Prisma ORM** | `tricache/prisma` | `$extends` client extension with query hashing and auto-mutation tag eviction. | | **Drizzle ORM** | `tricache/drizzle` | `withCache(query, opts)` query wrapper with SQL+parameters hashing and background SWR. | | **Express & Fastify** | `tricache/http` | Route caching middleware with deterministic query sorting, weak ETag, and `304 Not Modified`. | +| **Hono (Node)** | `tricache/hono` | First-class `cacheMiddleware` on `CacheService` with ttl/tags/SWR, weak ETag, and `304 Not Modified`. | | **Hono & Edge Isolates** | `tricache/edge` | Zero-Node-dependency implementation for Cloudflare Workers, Fastly Compute, Hono, and Vercel Edge. | | **SSE Dashboard** | `tricache/dashboard` | Zero-dependency Server-Sent Events real-time admin dashboard. | | **Live CLI Top** | `npx tricache top` | Real-time terminal ASCII monitor over Unix sockets and Windows named pipes. | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4af0186..48652c3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -136,7 +136,8 @@ export default defineConfig({ { text: 'NestJS Dynamic Module', link: '/integrations/nestjs' }, { text: 'Prisma ORM Extension', link: '/integrations/prisma' }, { text: 'Drizzle ORM Wrapper', link: '/integrations/drizzle' }, - { text: 'Express & Hono Middleware', link: '/integrations/http' }, + { text: 'Express & Fastify Middleware', link: '/integrations/http' }, + { text: 'Hono Node Middleware', link: '/integrations/hono' }, { text: 'Edge Isolates (Workers)', link: '/integrations/edge' }, { text: 'Visual Dashboard & CLI', link: '/integrations/dashboard' }, ], diff --git a/docs/.vitepress/theme/components/IntegrationGrid.vue b/docs/.vitepress/theme/components/IntegrationGrid.vue index 8e54e9e..270d60e 100644 --- a/docs/.vitepress/theme/components/IntegrationGrid.vue +++ b/docs/.vitepress/theme/components/IntegrationGrid.vue @@ -102,7 +102,7 @@ withDefaults(defineProps(), {
Express / Hono - Edge Middleware + Node + Edge Middleware
diff --git a/docs/api-reference.md b/docs/api-reference.md index a81bf54..42acf54 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -463,7 +463,7 @@ The TriCache engine honors the following environment variables across all enviro --- -## 9. HTTP & Framework Middlewares (`tricache/http` & `tricache/edge`) +## 9. HTTP & Framework Middlewares (`tricache/http`, `tricache/hono` & `tricache/edge`) ### `createExpressMiddleware(cache, options?)` Creates an Express/Connect route middleware with deterministic query sorting, weak ETag calculation, and RFC 7232 `304 Not Modified` short-circuiting. @@ -486,6 +486,17 @@ import { createFastifyPlugin } from 'tricache/http'; await fastify.register(createFastifyPlugin(cache, { ttlSeconds: 120 })); ``` +### `cacheMiddleware(options?)` (`tricache/hono`) +Creates Node Hono middleware on `CacheService` with Express-aligned ttl/tags/SWR, weak ETags, and RFC 7232 `304 Not Modified`. Non-2xx responses are not cached. + +```typescript +import { cacheMiddleware } from 'tricache/hono'; + +app.get('/api/posts', cacheMiddleware({ ttl: 300, tags: ['posts'] }), (c) => { + return c.json({ data: '...' }); +}); +``` + ### `createHonoEdgeMiddleware(edgeCache, options?)` Creates a decoupled Hono edge middleware using pure Web Standards (`Request`, `Response`, `crypto.subtle`) with zero Node native dependencies. diff --git a/docs/changelog.md b/docs/changelog.md index cd97cd4..2333c34 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Node Hono middleware (`tricache/hono`)** — first-class `cacheMiddleware` on `CacheService` (ttl/tags/SWR, weak ETag, `If-None-Match` → 304, no cache for non-2xx). Distinct from the edge helper under `tricache/edge`. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/docs/integrations/hono.md b/docs/integrations/hono.md new file mode 100644 index 0000000..1938090 --- /dev/null +++ b/docs/integrations/hono.md @@ -0,0 +1,76 @@ +# Hono Node Middleware + +> Package entry: `tricache/hono` + +First-class **Node.js** Hono middleware backed by `CacheService` (L1 RAM → L1.5 disk → L2 Redis). This is the adapter requested for Hono apps running on Node — not the Web-Crypto edge helper under [`tricache/edge`](/integrations/edge). + +```typescript +import { Hono } from 'hono'; +import { cacheMiddleware } from 'tricache/hono'; + +const app = new Hono(); + +app.get('/api/posts', cacheMiddleware({ ttl: 300, tags: ['posts'] }), (c) => { + return c.json({ data: '...' }); +}); +``` + +Pass an explicit `CacheService` when you already have one: + +```typescript +import { CacheService } from 'tricache'; +import { cacheMiddleware } from 'tricache/hono'; + +const cache = CacheService.create(); + +app.get( + '/api/posts', + cacheMiddleware({ + cache, + ttl: 300, + swr: 60, + tags: ['posts'], + headerWhitelist: ['accept-language'], + }), + (c) => c.json({ data: '...' }), +); +``` + +`createHonoMiddleware` is an alias of `cacheMiddleware`. + +--- + +## Node vs edge + +| Entry | Runtime | Cache engine | Import | +|:---|:---|:---|:---| +| **`tricache/hono`** | Node.js | `CacheService` | `import { cacheMiddleware } from 'tricache/hono'` | +| **`tricache/edge`** | Workers / edge isolates | `EdgeCacheService` | `import { honoEdgeCache } from 'tricache/edge'` | + +`tricache/http` still re-exports the edge helper as `honoCache` for compatibility. New Node Hono apps should import `tricache/hono`. + +--- + +## Behavior + +* **Safe methods only**: `GET` and `HEAD` are cached; other methods pass through. +* **Weak ETags**: SHA-1 weak validators (`ETag: W/"…"`) via the same Node helper as Express. +* **304 Not Modified**: matching `If-None-Match` short-circuits with an empty body. +* **Status gate**: non-2xx responses are never kept (4xx/5xx cannot poison a key). +* **Bypass**: `Cache-Control: no-cache` / `no-store` and a custom `skipCache` predicate skip the cache. +* **SWR & tags**: `ttl`, `swr`, and `tags` are forwarded to `CacheService.get`, matching Express middleware. + +--- + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `cache` | `CacheService` | singleton | TriCache instance. If omitted, lazily resolves `CacheService.create()` | +| `ttl` | `number` | `300` | Time-to-live in seconds | +| `swr` | `number` | `undefined` | Stale-While-Revalidate window in seconds | +| `etag` | `boolean` | `true` | Generate and evaluate weak ETags (`W/"…"`) | +| `keyGenerator` | `(c) => string` | method + URL + sorted query | Custom cache key from the Hono context | +| `headerWhitelist` | `string[]` | `[]` | Request headers incorporated into the cache key | +| `skipCache` | `(c) => boolean` | `undefined` | Predicate returning true to bypass cache | +| `tags` | `string[] \| ((c) => string[])` | `[]` | Semantic tags for targeted `cache.invalidateTag()` | diff --git a/docs/integrations/http.md b/docs/integrations/http.md index ac78551..0a80d16 100644 --- a/docs/integrations/http.md +++ b/docs/integrations/http.md @@ -4,6 +4,8 @@ TriCache provides enterprise-grade HTTP route caching middleware with weak ETag calculation, deterministic query sorting, and RFC 7232 `304 Not Modified` short-circuiting for Express, Fastify, Connect, and Node.js HTTP servers. +For **Node Hono** (`CacheService`, `import { cacheMiddleware } from 'tricache/hono'`), see [Hono Node Middleware](/integrations/hono). The `honoCache` export from this package is the edge helper — prefer [`tricache/edge`](/integrations/edge) for Workers. + ### Ready-to-run Express demo A self-contained microservice lives at [`examples/express-api`](https://github.com/Kareem411/TriCache/tree/main/examples/express-api). It exercises weak ETags, `If-None-Match` → `304`, deterministic query sorting, `headerWhitelist: ['accept-language']`, and `skipCache` for `Authorization`. diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 26f10f7..ba3b01b 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -16,6 +16,7 @@ Explore the dedicated guides for your application stack: | **[NestJS Module](/integrations/nestjs)** | `tricache/nestjs` | Dynamic `TriCacheModule` (`register`/`registerAsync`), `@Cacheable` and `@CacheEvict` decorators. | | **[Prisma ORM Extension](/integrations/prisma)** | `tricache/prisma` | `$extends(withTriCache())`, automatic mutation invalidation, deterministic query key hashing. | | **[Drizzle ORM Wrapper](/integrations/drizzle)** | `tricache/drizzle` | `withCache(query)`, SQL + parameterized argument hashing, custom TTL and tag assignment. | -| **[Express & Hono Middleware](/integrations/http)** | `tricache/http` | Route caching middleware, weak ETag calculation, RFC 7232 `304 Not Modified` short-circuiting. | +| **[Express & Fastify Middleware](/integrations/http)** | `tricache/http` | Route caching middleware, weak ETag calculation, RFC 7232 `304 Not Modified` short-circuiting. | +| **[Hono Node Middleware](/integrations/hono)** | `tricache/hono` | First-class `cacheMiddleware` on `CacheService` with ttl/tags/SWR and `304 Not Modified`. | | **[Edge Isolates (Workers)](/integrations/edge)** | `tricache/edge` | Universal zero-Node runtime for Cloudflare Workers, Fastly Compute, Web Crypto, WASM Bloom. | | **[Visual Dashboard & CLI](/integrations/dashboard)** | `tricache/dashboard` | Real-time SSE Web UI, Next.js route handlers, standalone management server, CLI. | diff --git a/package.json b/package.json index 08cb0ff..e239f5a 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,11 @@ "import": "./dist/edge/index.js", "require": "./dist/edge/index.cjs", "default": "./dist/edge/index.js" + }, + "./hono": { + "types": "./dist/hono/index.d.ts", + "import": "./dist/hono/index.js", + "require": "./dist/hono/index.cjs" } }, "files": [ @@ -93,7 +98,7 @@ "LICENSE" ], "scripts": { - "build": "tsup src/index.ts src/cli.ts src/serialize-worker.ts src/next/index.ts src/nestjs/index.ts src/prisma/index.ts src/drizzle/index.ts src/http/index.ts src/dashboard/index.ts src/edge/index.ts --format esm,cjs --dts --clean", + "build": "tsup src/index.ts src/cli.ts src/serialize-worker.ts src/next/index.ts src/nestjs/index.ts src/prisma/index.ts src/drizzle/index.ts src/http/index.ts src/dashboard/index.ts src/edge/index.ts src/hono/index.ts --format esm,cjs --dts --clean", "postbuild": "node --input-type=module -e \"import{readdirSync,rmSync,statSync}from'fs';import{join}from'path';function clean(d){for(const f of readdirSync(d)){const p=join(d,f);if(statSync(p).isDirectory())clean(p);else if(p.endsWith('.d.cts'))rmSync(p,{force:true});}}clean('dist');\"", "dev": "tsup src/index.ts src/serialize-worker.ts --format esm,cjs --dts --watch", "typecheck": "tsc --noEmit", diff --git a/src/hono/index.ts b/src/hono/index.ts new file mode 100644 index 0000000..ca860b8 --- /dev/null +++ b/src/hono/index.ts @@ -0,0 +1,257 @@ +/** + * tricache/hono — first-class Node.js Hono middleware on CacheService. + * + * This is the Node three-tier path (L1 RAM → L1.5 disk → L2 Redis), not the + * Web-Crypto edge helper exported from `tricache/edge` / `tricache/http`. + * + * Usage: + * import { Hono } from 'hono'; + * import { cacheMiddleware } from 'tricache/hono'; + * + * const app = new Hono(); + * app.get('/api/posts', cacheMiddleware({ ttl: 300, tags: ['posts'] }), (c) => { + * return c.json({ data: '...' }); + * }); + */ + +import type { CacheService } from '../cache-service.js'; +import type { WrapOptions } from '../types.js'; +import { + buildDeterministicKey, + generateETag, + shouldSkipCache, + type KeyDerivationOptions, +} from '../http/utils.js'; + +/** + * Minimal Hono context surface used by the middleware. + * Compatible with `import('hono').Context` without taking a runtime dependency. + */ +export interface HonoCacheContext { + req: { + method: string; + url: string; + header?: (name: string) => string | undefined; + headers?: Headers | Record; + }; + res?: { + status?: number; + headers?: { get(name: string): string | null | undefined }; + clone?: () => { text: () => Promise }; + body?: unknown; + }; + body: (data: unknown, status?: number, headers?: Record) => unknown; + executionCtx?: unknown; +} + +export type HonoNext = () => Promise; +export type HonoMiddleware = (c: HonoCacheContext, next: HonoNext) => Promise; + +export interface HonoCacheOptions extends Omit, Omit { + /** TriCache instance. If omitted, lazily resolves the default singleton via CacheService.create(). */ + cache?: CacheService; + /** Whether to generate and evaluate weak ETags. Default: true. */ + etag?: boolean; + /** Custom cache key. Overrides default method + URL + sorted query derivation. */ + keyGenerator?: (c: HonoCacheContext) => string; + /** Custom predicate to skip caching dynamically (e.g. authenticated sessions). */ + skipCache?: (c: HonoCacheContext) => boolean; + /** Dynamic tags derivation from the Hono context. */ + tags?: string[] | ((c: HonoCacheContext) => string[]); +} + +export interface CachedHonoResponse { + body: string; + contentType?: string; + etag?: string; + status: number; +} + +function readRequestHeader(c: HonoCacheContext, name: string): string | undefined { + const headerFn = c.req.header; + if (typeof headerFn === 'function') { + const viaFn = headerFn(name) ?? headerFn(name.toLowerCase()); + if (viaFn !== undefined && viaFn !== null && viaFn !== '') { + return viaFn; + } + } + + const raw = c.req.headers; + if (!raw) return undefined; + + if (typeof (raw as Headers).get === 'function') { + const viaGet = (raw as Headers).get(name); + if (viaGet) return viaGet; + } + + const record = raw as Record; + const val = record[name.toLowerCase()] ?? record[name]; + if (val === undefined || val === null || val === '') return undefined; + return Array.isArray(val) ? val.join(',') : String(val); +} + +function toRequestLike(c: HonoCacheContext, headerWhitelist?: string[]) { + const headers: Record = {}; + const cacheControl = readRequestHeader(c, 'cache-control'); + const ifNoneMatch = readRequestHeader(c, 'if-none-match'); + + if (cacheControl) { + headers['cache-control'] = cacheControl; + headers['Cache-Control'] = cacheControl; + } + if (ifNoneMatch) { + headers['if-none-match'] = ifNoneMatch; + headers['If-None-Match'] = ifNoneMatch; + } + + if (headerWhitelist) { + for (const h of headerWhitelist) { + headers[h.toLowerCase()] = readRequestHeader(c, h); + } + } + + return { + method: (c.req.method || 'GET').toUpperCase(), + url: c.req.url || '/', + headers, + }; +} + +function isSuccessStatus(status: number): boolean { + return status >= 200 && status < 300; +} + +function applyHonoCachedResponse( + c: HonoCacheContext, + cached: CachedHonoResponse, + ifNoneMatch: string | undefined, +): unknown { + if (cached.etag && ifNoneMatch === cached.etag) { + const result = c.body(null, 304, { ETag: cached.etag }); + if (result != null) { + c.res = result as HonoCacheContext['res']; + } + return result; + } + + const headers: Record = {}; + if (cached.etag) headers['ETag'] = cached.etag; + if (cached.contentType) headers['Content-Type'] = cached.contentType; + + const result = c.body(cached.body, cached.status ?? 200, headers); + if (result != null) { + c.res = result as HonoCacheContext['res']; + } + return result; +} + +async function snapshotHonoResponse( + c: HonoCacheContext, + etag: boolean, +): Promise { + const res = c.res; + if (!res) { + return { body: '', status: 0 }; + } + + const clone = typeof res.clone === 'function' ? res.clone() : undefined; + const text = clone && typeof clone.text === 'function' + ? await clone.text() + : typeof res.body === 'string' + ? res.body + : ''; + + const status = res.status ?? 200; + const contentType = res.headers?.get?.('content-type') || 'text/plain; charset=utf-8'; + const bodyEtag = etag ? generateETag(text) : undefined; + + return { + body: text, + contentType, + etag: bodyEtag, + status, + }; +} + +/** + * Creates a Hono middleware that caches GET/HEAD responses through Node + * `CacheService`, with Express-aligned ttl/tags/SWR options, weak ETags, and + * RFC 7232 `If-None-Match` → `304 Not Modified`. Non-2xx responses are never kept. + * + * @example + * import { Hono } from 'hono'; + * import { CacheService } from 'tricache'; + * import { cacheMiddleware } from 'tricache/hono'; + * + * const app = new Hono(); + * const cache = CacheService.create(); + * + * app.get( + * '/api/posts', + * cacheMiddleware({ cache, ttl: 300, tags: ['posts'] }), + * (c) => c.json({ data: '...' }), + * ); + */ +export function cacheMiddleware(options: HonoCacheOptions = {}): HonoMiddleware { + const { + cache, + etag = true, + ttl = 300, + swr, + tags, + skipCache, + keyGenerator, + headerWhitelist, + } = options; + + return async (c, next) => { + const method = (c.req.method || 'GET').toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + return await next(); + } + + const reqLike = toRequestLike(c, headerWhitelist); + if (shouldSkipCache(reqLike, skipCache ? () => skipCache(c) : undefined)) { + return await next(); + } + + let activeCache = cache; + if (!activeCache) { + const { CacheService } = await import('../cache-service.js'); + activeCache = CacheService.create(); + } + + const key = keyGenerator + ? keyGenerator(c) + : buildDeterministicKey(reqLike, { headerWhitelist }); + + const ifNoneMatch = readRequestHeader(c, 'if-none-match'); + const resolvedTags = typeof tags === 'function' ? tags(c) : tags; + + let ranNext = false; + const cached = await activeCache.get( + key, + async () => { + ranNext = true; + await next(); + return snapshotHonoResponse(c, etag); + }, + ttl, + { swr, tags: resolvedTags }, + ); + + // Status gate: only cache 2xx successful responses + if (typeof cached.status === 'number' && !isSuccessStatus(cached.status)) { + await activeCache.delete(key).catch(() => {}); + if (!ranNext) { + return await next(); + } + return; + } + + return applyHonoCachedResponse(c, cached, ifNoneMatch); + }; +} + +/** Alias matching `createExpressMiddleware` / `createKoaMiddleware` naming. */ +export const createHonoMiddleware = cacheMiddleware; diff --git a/src/http/index.ts b/src/http/index.ts index 0b2629b..340f34c 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -10,8 +10,13 @@ * import { fastifyCachePlugin } from 'tricache/http'; * await fastify.register(fastifyCachePlugin, { cache, ttl: 300 }); * + * For Node Hono (CacheService, three-tier L1/L1.5/L2), use the dedicated entry: + * import { cacheMiddleware } from 'tricache/hono'; + * * For Edge runtimes (Cloudflare Workers, Vercel Edge, Deno) and Hono, use: * import { honoEdgeCache } from 'tricache/edge'; + * + * `honoCache` below remains the edge helper re-export for compatibility. */ export { diff --git a/tests/hono.test.ts b/tests/hono.test.ts new file mode 100644 index 0000000..caffe39 --- /dev/null +++ b/tests/hono.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { cacheMiddleware, createHonoMiddleware } from '../src/hono/index.js'; +import { generateETag } from '../src/http/utils.js'; + +describe('Hono Node middleware (tricache/hono)', () => { + let cache: CacheService | null = null; + + afterEach(async () => { + if (cache) { + await cache.destroy(); + cache = null; + } + }); + + const mockHonoContext = (headers: Record = {}, url = 'https://example.com/api/items') => { + return { + req: { + method: 'GET', + url, + header: (name: string) => headers[name.toLowerCase()], + }, + res: { + status: 200 as number, + clone: () => ({ + text: async () => JSON.stringify({ item: 1 }), + }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn((data, status, hdrs) => ({ data, status, headers: hdrs })), + }; + }; + + it('exports createHonoMiddleware as an alias of cacheMiddleware', () => { + expect(createHonoMiddleware).toBe(cacheMiddleware); + }); + + it('serves a cache miss then a HIT with ETag headers without re-running the handler', async () => { + cache = new CacheService({ + namespace: `hono-hit-${Date.now()}`, + disableRedis: true, + }); + + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + + const c1 = mockHonoContext(); + await middleware(c1, async () => { controllerCalls++; }); + + expect(controllerCalls).toBe(1); + expect(c1.body).toHaveBeenCalledWith( + JSON.stringify({ item: 1 }), + 200, + expect.objectContaining({ ETag: expect.any(String), 'Content-Type': 'application/json' }), + ); + + const etag = (c1.body.mock.calls[0][2] as { ETag: string }).ETag; + expect(etag).toBe(generateETag(JSON.stringify({ item: 1 }))); + + const c2 = mockHonoContext(); + await middleware(c2, async () => { controllerCalls++; }); + + expect(controllerCalls).toBe(1); + expect(c2.body).toHaveBeenCalledWith( + JSON.stringify({ item: 1 }), + 200, + expect.objectContaining({ ETag: etag, 'Content-Type': 'application/json' }), + ); + }); + + it('intercepts Hono Context, generates ETag, and returns 304 Not Modified', async () => { + cache = new CacheService({ + namespace: `hono-test-${Date.now()}`, + disableRedis: true, + }); + + const middleware = cacheMiddleware({ cache, ttl: 60 }); + + let controllerCalls = 0; + + const c1 = mockHonoContext(); + await middleware(c1, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + expect(c1.body).toHaveBeenCalledWith( + JSON.stringify({ item: 1 }), + 200, + expect.objectContaining({ ETag: expect.any(String) }), + ); + + const etag = (c1.body.mock.calls[0][2] as { ETag: string }).ETag; + + const c2 = mockHonoContext({ 'if-none-match': etag }); + await middleware(c2, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + expect(c2.body).toHaveBeenCalledWith(null, 304, { ETag: etag }); + }); + + it('does not cache a 500 response and refetches on the next request', async () => { + cache = new CacheService({ + namespace: `hono-err-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + + let controllerCalls = 0; + const makeCtx = () => ({ + req: { + method: 'GET', + url: 'https://example.com/api/flaky', + header: (_name: string) => undefined, + }, + res: { + status: 200 as number, + clone: () => ({ + text: async () => JSON.stringify( + controllerCalls === 1 ? { error: 'upstream down' } : { item: 'ok' }, + ), + }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn(), + }); + + const c1 = makeCtx(); + await middleware(c1, async () => { controllerCalls++; c1.res.status = 500; }); + + const c2 = makeCtx(); + await middleware(c2, async () => { controllerCalls++; c2.res.status = 200; }); + + expect(controllerCalls).toBe(2); + expect(c2.body).not.toHaveBeenCalledWith( + expect.stringContaining('error'), + 200, + expect.anything(), + ); + expect(c2.body).toHaveBeenCalledWith( + JSON.stringify({ item: 'ok' }), + 200, + expect.objectContaining({ ETag: expect.any(String) }), + ); + }); + + it('uses CacheService.get with ttl, swr, and tags (Node path, not EdgeCacheService)', async () => { + cache = new CacheService({ + namespace: `hono-opts-${Date.now()}`, + disableRedis: true, + }); + const getSpy = vi.spyOn(cache, 'get'); + const middleware = cacheMiddleware({ + cache, + ttl: 42, + swr: 7, + tags: ['posts'], + }); + + const c = mockHonoContext(); + await middleware(c, async () => {}); + + expect(getSpy).toHaveBeenCalled(); + const [, , ttl, opts] = getSpy.mock.calls[0]; + expect(ttl).toBe(42); + expect(opts).toEqual(expect.objectContaining({ swr: 7, tags: ['posts'] })); + getSpy.mockRestore(); + }); + + it('skips caching for non-GET/HEAD methods', async () => { + cache = new CacheService({ + namespace: `hono-post-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + + const postCtx = () => ({ + req: { + method: 'POST', + url: 'https://example.com/api/items', + header: () => undefined, + }, + res: { + status: 200, + clone: () => ({ text: async () => JSON.stringify({ n: controllerCalls }) }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn(), + }); + + const c1 = postCtx(); + await middleware(c1, async () => { controllerCalls++; }); + const c2 = postCtx(); + await middleware(c2, async () => { controllerCalls++; }); + + expect(controllerCalls).toBe(2); + expect(c1.body).not.toHaveBeenCalled(); + expect(c2.body).not.toHaveBeenCalled(); + }); +}); From cc68e71b1b1e9888a73a88542db734825ef33b0e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 14:15:27 +0000 Subject: [PATCH 2/2] test: bump README passing-test badge to 822 CI check-test-badge.mjs requires the README badge to match the vitest total after adding tests/hono.test.ts (6 new cases). Co-authored-by: David --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f63b41..142a239 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Docs](https://img.shields.io/badge/docs-VitePress-blue.svg)](https://kareem411.github.io/TriCache/) [![npm version](https://img.shields.io/npm/v/tricache.svg)](https://www.npmjs.com/package/tricache) [![npm downloads](https://img.shields.io/npm/dm/tricache.svg)](https://www.npmjs.com/package/tricache) -[![Tests](https://img.shields.io/badge/tests-816%20passing-brightgreen)](tests) +[![Tests](https://img.shields.io/badge/tests-822%20passing-brightgreen)](tests) [![Code Quality](https://img.shields.io/badge/oxlint-0%20warnings-brightgreen)](src) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js ≥ 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)