From 33d1a0d64a10cf9f6099fd0a576b39535a7d9b6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 05:38:47 +0000 Subject: [PATCH 1/2] feat(koa): add official Koa caching middleware adapter Add tricache/koa with koaCache / createKoaMiddleware, mirroring Express GET/HEAD caching, ETag 304, ttl/swr/tags, and skipCache. Fixes #10 Co-authored-by: David --- CHANGELOG.md | 5 + README.md | 1 + docs/.vitepress/config.ts | 2 +- docs/api-reference.md | 14 +- docs/changelog.md | 5 + docs/integrations/http.md | 37 ++++- docs/integrations/index.md | 2 +- package.json | 8 +- src/http/index.ts | 3 + src/koa/index.ts | 242 ++++++++++++++++++++++++++++++ tests/koa.test.ts | 292 +++++++++++++++++++++++++++++++++++++ 11 files changed, 600 insertions(+), 11 deletions(-) create mode 100644 src/koa/index.ts create mode 100644 tests/koa.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd97cd4..32cf53f 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 +- **Koa middleware (`tricache/koa`)** — Official `koaCache` / `createKoaMiddleware` adapter (`src/koa/index.ts`) mirroring Express: GET/HEAD only, deterministic keys, optional weak ETag + `304`, `ttl` / `swr` / `tags`, `skipCache`, and no cache of non-2xx responses. Fixes #10. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/README.md b/README.md index 334b5b7..5fb6cf4 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`. | +| **Koa** | `tricache/koa` | Official Koa middleware (`koaCache`) with the same GET/HEAD, ETag/`304`, ttl/swr/tags, and skipCache contract. | | **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..c310493 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -136,7 +136,7 @@ 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 & Koa Middleware', link: '/integrations/http' }, { text: 'Edge Isolates (Workers)', link: '/integrations/edge' }, { text: 'Visual Dashboard & CLI', link: '/integrations/dashboard' }, ], diff --git a/docs/api-reference.md b/docs/api-reference.md index a81bf54..bf53c2c 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/koa` & `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,18 @@ import { createFastifyPlugin } from 'tricache/http'; await fastify.register(createFastifyPlugin(cache, { ttlSeconds: 120 })); ``` +### `koaCache(options?)` / `createKoaMiddleware(options?)` +Creates a Koa middleware (`tricache/koa`) with the same GET/HEAD, weak ETag, `304 Not Modified`, `ttl` / `swr` / `tags`, and `skipCache` contract as Express. + +```typescript +import { koaCache } from 'tricache/koa'; + +app.use(koaCache({ + ttl: 60, + key: (ctx) => ctx.url, +})); +``` + ### `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..32cf53f 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 +- **Koa middleware (`tricache/koa`)** — Official `koaCache` / `createKoaMiddleware` adapter (`src/koa/index.ts`) mirroring Express: GET/HEAD only, deterministic keys, optional weak ETag + `304`, `ttl` / `swr` / `tags`, `skipCache`, and no cache of non-2xx responses. Fixes #10. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/docs/integrations/http.md b/docs/integrations/http.md index ac78551..43d9f04 100644 --- a/docs/integrations/http.md +++ b/docs/integrations/http.md @@ -1,8 +1,8 @@ -# Express & Fastify HTTP Middleware +# Express, Fastify & Koa HTTP Middleware -> Package entry: `tricache/http` +> Package entries: `tricache/http` (Express / Fastify) and `tricache/koa` (Koa) -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. +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, Koa, Connect, and Node.js HTTP servers. ### Ready-to-run Express demo @@ -86,7 +86,29 @@ fastify.get('/api/catalog', { --- -## 3. RFC 7232 ETag Validation & Bandwidth Savings +## 3. Koa (`koaCache`) + +> Package entry: `tricache/koa` + +Koa uses the same GET/HEAD-only contract as Express: deterministic keys, optional weak ETags + `304`, `ttl` / `swr` / `tags`, `skipCache`, and no persistence of non-2xx responses. + +```typescript +import Koa from 'koa'; +import { koaCache } from 'tricache/koa'; + +const app = new Koa(); + +app.use(koaCache({ + ttl: 60, + key: (ctx) => ctx.url, +})); +``` + +`createKoaMiddleware` is an alias of `koaCache`. Pass `cache` to use an existing `CacheService`; otherwise the default singleton is created lazily. + +--- + +## 4. RFC 7232 ETag Validation & Bandwidth Savings 1. **Automatic Weak ETags**: TriCache generates fast weak ETags (`ETag: W/""`) across cached response bodies. 2. **Conditional Requests (`If-None-Match`)**: When clients or downstream CDNs present an `If-None-Match` header matching the cached ETag, TriCache halts execution before body serialization, returning an immediate `304 Not Modified` with zero response body bytes. @@ -94,7 +116,7 @@ fastify.get('/api/catalog', { --- -## 4. Deterministic Key Derivation & Query Sorting +## 5. Deterministic Key Derivation & Query Sorting By default, TriCache generates deterministic cache keys using: - HTTP method (`GET`) @@ -104,7 +126,7 @@ By default, TriCache generates deterministic cache keys using: --- -## 5. Cache Bypass & Conditional Controls +## 6. Cache Bypass & Conditional Controls TriCache respects standard HTTP client and server cache control semantics: @@ -127,7 +149,7 @@ app.get( --- -## 6. Options Reference +## 7. Options Reference | Option | Type | Default | Description | |---|---|---|---| @@ -135,6 +157,7 @@ app.get( | `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/"…"`) | +| `key` | `(ctx) => string` | — | Koa-native cache key (`tricache/koa` only). Example: `(ctx) => ctx.url` | | `keyGenerator` | `(req) => string` | `buildDeterministicKey` | Custom cache key generator function | | `headerWhitelist` | `string[]` | `[]` | Request headers incorporated into the cache key | | `skipCache` | `(req) => boolean` | `undefined` | Predicate returning true to bypass cache | diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 26f10f7..4840a1d 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -16,6 +16,6 @@ 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 & Koa Middleware](/integrations/http)** | `tricache/http`, `tricache/koa` | Route caching middleware, weak ETag calculation, RFC 7232 `304 Not Modified` short-circuiting. | | **[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..7b6430e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "express", "fastify", "hono", + "koa", "edge", "cli", "nodejs" @@ -70,6 +71,11 @@ "import": "./dist/http/index.js", "require": "./dist/http/index.cjs" }, + "./koa": { + "types": "./dist/koa/index.d.ts", + "import": "./dist/koa/index.js", + "require": "./dist/koa/index.cjs" + }, "./dashboard": { "types": "./dist/dashboard/index.d.ts", "import": "./dist/dashboard/index.js", @@ -93,7 +99,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/koa/index.ts src/dashboard/index.ts src/edge/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/http/index.ts b/src/http/index.ts index 0b2629b..3e6139f 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -10,6 +10,9 @@ * import { fastifyCachePlugin } from 'tricache/http'; * await fastify.register(fastifyCachePlugin, { cache, ttl: 300 }); * + * For Koa, use the dedicated adapter: + * import { koaCache } from 'tricache/koa'; + * * For Edge runtimes (Cloudflare Workers, Vercel Edge, Deno) and Hono, use: * import { honoEdgeCache } from 'tricache/edge'; */ diff --git a/src/koa/index.ts b/src/koa/index.ts new file mode 100644 index 0000000..4931580 --- /dev/null +++ b/src/koa/index.ts @@ -0,0 +1,242 @@ +import type { CacheService } from '../cache-service.js'; +import type { WrapOptions } from '../types.js'; +import { + buildDeterministicKey, + generateETag, + shouldSkipCache, + type KeyDerivationOptions, +} from '../http/utils.js'; + +/** + * Minimal Koa context surface used by the middleware. + * Compatible with `import('koa').Context` without taking a runtime dependency. + */ +export interface KoaCacheContext { + method: string; + url: string; + originalUrl?: string; + headers?: Record; + status: number; + body: unknown; + type?: string; + get?(field: string): string; + set?(field: string, val: string | number): void; + request?: { + method?: string; + url?: string; + headers?: Record; + header?: Record; + }; + response?: { + get?(field: string): string | number | string[] | undefined; + set?(field: string, val: string | number): void; + type?: string; + status?: number; + body?: unknown; + }; + res?: { headersSent?: boolean }; +} + +export type KoaNext = () => Promise; +export type KoaMiddleware = (ctx: KoaCacheContext, next: KoaNext) => Promise; + +export interface KoaCacheOptions 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; + /** + * Koa-native cache key. Preferred over `keyGenerator` (issue #10 / `tricache/koa` surface). + * Example: `(ctx) => ctx.url` + */ + key?: (ctx: KoaCacheContext) => string; + /** Express-compatible key derivation receiving a request-like object. */ + keyGenerator?: (req: any) => string; + /** Custom predicate to skip caching dynamically for this request (e.g. authenticated sessions). */ + skipCache?: (ctx: KoaCacheContext) => boolean; + /** Dynamic tags derivation from the Koa context. */ + tags?: string[] | ((ctx: KoaCacheContext) => string[]); +} + +export interface CachedKoaResponse { + body: unknown; + contentType?: string; + etag?: string; + status: number; +} + +function toRequestLike(ctx: KoaCacheContext) { + const headers = ctx.headers + ?? ctx.request?.headers + ?? ctx.request?.header + ?? {}; + const url = ctx.url ?? ctx.request?.url ?? '/'; + return { + method: ctx.method ?? ctx.request?.method ?? 'GET', + url, + originalUrl: ctx.originalUrl ?? url, + headers, + }; +} + +function readRequestHeader(ctx: KoaCacheContext, name: string): string | undefined { + const fromGet = ctx.get?.(name); + if (typeof fromGet === 'string' && fromGet !== '') { + return fromGet; + } + const headers = ctx.headers ?? ctx.request?.headers ?? ctx.request?.header; + if (!headers) return undefined; + const val = headers[name.toLowerCase()] ?? headers[name]; + if (val === undefined || val === null || val === '') return undefined; + return Array.isArray(val) ? val.join(',') : String(val); +} + +function writeResponseHeader(ctx: KoaCacheContext, name: string, value: string): void { + if (ctx.set) { + ctx.set(name, value); + return; + } + ctx.response?.set?.(name, value); +} + +function readResponseContentType(ctx: KoaCacheContext): string | undefined { + const fromResponse = ctx.response?.get?.('content-type') ?? ctx.response?.get?.('Content-Type'); + if (typeof fromResponse === 'string' && fromResponse !== '') { + return fromResponse; + } + if (typeof ctx.type === 'string' && ctx.type !== '') { + return ctx.type; + } + if (typeof ctx.body === 'object' && ctx.body !== null && !Buffer.isBuffer(ctx.body)) { + return 'application/json; charset=utf-8'; + } + return undefined; +} + +function isSuccessStatus(status: number): boolean { + return status >= 200 && status < 300; +} + +function applyCachedResponse( + ctx: KoaCacheContext, + cached: CachedKoaResponse, + ifNoneMatch: string | undefined, +): void { + if (cached.etag) { + writeResponseHeader(ctx, 'ETag', cached.etag); + if (ifNoneMatch === cached.etag) { + ctx.status = 304; + ctx.body = null; + return; + } + } + + if (cached.contentType) { + writeResponseHeader(ctx, 'Content-Type', cached.contentType); + ctx.type = cached.contentType; + } + + ctx.status = cached.status ?? 200; + ctx.body = cached.body; +} + +/** + * Creates a Koa middleware that provides deterministic response caching, + * weak ETag generation, conditional 304 Not Modified short-circuiting, and + * Cache-Control bypass controls. Mirrors `createExpressMiddleware`. + * + * @example + * import Koa from 'koa'; + * import { koaCache } from 'tricache/koa'; + * + * const app = new Koa(); + * + * app.use(koaCache({ + * ttl: 60, + * key: (ctx) => ctx.url, + * })); + */ +export function createKoaMiddleware(options: KoaCacheOptions = {}): KoaMiddleware { + const { + cache, + etag = true, + ttl = 300, + swr, + tags, + skipCache, + key, + keyGenerator, + headerWhitelist, + } = options; + + return async (ctx, next) => { + const method = (ctx.method ?? ctx.request?.method ?? 'GET').toUpperCase(); + // Only cache safe, idempotent HTTP read methods + if (method !== 'GET' && method !== 'HEAD') { + await next(); + return; + } + + const reqLike = toRequestLike(ctx); + if (shouldSkipCache(reqLike, skipCache ? () => skipCache(ctx) : undefined)) { + await next(); + return; + } + + let activeCache = cache; + if (!activeCache) { + const { CacheService } = await import('../cache-service.js'); + activeCache = CacheService.create(); + } + + const cacheKey = key + ? key(ctx) + : buildDeterministicKey(reqLike, { keyGenerator, headerWhitelist }); + + const ifNoneMatch = readRequestHeader(ctx, 'If-None-Match'); + const resolvedTags = typeof tags === 'function' ? tags(ctx) : tags; + + const cached = await activeCache.get( + cacheKey, + async () => { + await next(); + const status = ctx.status ?? 200; + const bodyEtag = etag && ctx.body != null ? generateETag(ctx.body) : undefined; + const contentType = readResponseContentType(ctx); + const snapshot: CachedKoaResponse = { + body: ctx.body, + contentType, + etag: bodyEtag, + status, + }; + + if (bodyEtag) { + writeResponseHeader(ctx, 'ETag', bodyEtag); + } + if (ifNoneMatch && bodyEtag && ifNoneMatch === bodyEtag) { + ctx.status = 304; + ctx.body = null; + } + + return snapshot; + }, + ttl, + { swr, tags: resolvedTags }, + ); + + // Status gate: only cache 2xx successful responses + if (typeof cached.status === 'number' && !isSuccessStatus(cached.status)) { + await activeCache.delete(cacheKey).catch(() => {}); + return; + } + + if (ctx.res?.headersSent) { + return; + } + + applyCachedResponse(ctx, cached, ifNoneMatch); + }; +} + +/** Alias matching the `tricache/koa` public surface from issue #10. */ +export const koaCache = createKoaMiddleware; diff --git a/tests/koa.test.ts b/tests/koa.test.ts new file mode 100644 index 0000000..1552f65 --- /dev/null +++ b/tests/koa.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { createKoaMiddleware, koaCache, type KoaCacheContext } from '../src/koa/index.js'; +import { generateETag } from '../src/http/utils.js'; + +function createMockKoaContext( + url = '/api/data', + headers: Record = {}, + method = 'GET', +): KoaCacheContext & { resHeaders: Record } { + const reqHeaders: Record = {}; + for (const [k, v] of Object.entries(headers)) { + reqHeaders[k.toLowerCase()] = v; + } + const resHeaders: Record = {}; + + let status = 404; + let body: unknown; + + const ctx: any = { + method, + url, + originalUrl: url, + headers: reqHeaders, + type: undefined, + resHeaders, + get(name: string) { + return reqHeaders[name.toLowerCase()]; + }, + set(name: string, value: string | number) { + resHeaders[name.toLowerCase()] = String(value); + }, + request: { + method, + url, + headers: reqHeaders, + header: reqHeaders, + }, + response: { + get(name: string) { + return resHeaders[name.toLowerCase()]; + }, + set(name: string, value: string | number) { + resHeaders[name.toLowerCase()] = String(value); + }, + }, + }; + + Object.defineProperty(ctx, 'status', { + get: () => status, + set: (code: number) => { status = code; }, + enumerable: true, + }); + + Object.defineProperty(ctx, 'body', { + get: () => body, + set: (value: unknown) => { + body = value; + // Koa promotes 404 → 200 when a body is assigned + if (status === 404 && value !== undefined) { + status = 200; + } + }, + enumerable: true, + }); + + return ctx; +} + +describe('Koa Caching Middleware (tricache/koa)', () => { + let cache: CacheService; + let namespace: string; + + beforeEach(() => { + namespace = `test_koa_${Date.now()}_${Math.random().toString(36).slice(2)}`; + cache = CacheService.create({ + namespace, + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, + }); + }); + + afterEach(async () => { + await cache.destroy(); + }); + + it('exports koaCache as an alias of createKoaMiddleware', () => { + expect(koaCache).toBe(createKoaMiddleware); + }); + + it('serves a cache miss then a hit without re-running the downstream handler', async () => { + const middleware = koaCache({ cache, ttl: 60 }); + let handlerCalls = 0; + const payload = { id: 101, name: 'TriCache Koa' }; + + const handler = async (ctx: KoaCacheContext) => { + handlerCalls++; + ctx.set?.('Content-Type', 'application/json; charset=utf-8'); + ctx.body = payload; + }; + + const ctx1 = createMockKoaContext('/api/product/101'); + await middleware(ctx1, async () => handler(ctx1)); + + expect(handlerCalls).toBe(1); + expect(ctx1.status).toBe(200); + expect(ctx1.body).toEqual(payload); + expect(ctx1.resHeaders['etag']).toBeDefined(); + const firstEtag = ctx1.resHeaders['etag']; + + const ctx2 = createMockKoaContext('/api/product/101'); + await middleware(ctx2, async () => handler(ctx2)); + + expect(handlerCalls).toBe(1); + expect(ctx2.status).toBe(200); + expect(ctx2.body).toEqual(payload); + expect(ctx2.resHeaders['etag']).toBe(firstEtag); + }); + + it('returns 304 Not Modified when If-None-Match matches the cached ETag', async () => { + const middleware = koaCache({ cache, ttl: 60 }); + const payload = { text: 'sample document' }; + + const ctx1 = createMockKoaContext('/api/doc/42'); + await middleware(ctx1, async () => { + ctx1.body = payload; + }); + + const etag = ctx1.resHeaders['etag']; + expect(etag).toBeDefined(); + expect(etag).toBe(generateETag(payload)); + + let handlerCalled = false; + const ctx2 = createMockKoaContext('/api/doc/42', { 'if-none-match': etag }); + await middleware(ctx2, async () => { + handlerCalled = true; + }); + + expect(handlerCalled).toBe(false); + expect(ctx2.status).toBe(304); + expect(ctx2.body).toBeNull(); + expect(ctx2.resHeaders['etag']).toBe(etag); + }); + + it('skips caching for non-GET/HEAD methods', async () => { + const middleware = koaCache({ cache, ttl: 60 }); + let handlerCalls = 0; + + const runPost = async () => { + const ctx = createMockKoaContext('/api/writes', {}, 'POST'); + await middleware(ctx, async () => { + handlerCalls++; + ctx.body = { count: handlerCalls }; + }); + return ctx; + }; + + const first = await runPost(); + const second = await runPost(); + + expect(handlerCalls).toBe(2); + expect(first.body).toEqual({ count: 1 }); + expect(second.body).toEqual({ count: 2 }); + expect(first.resHeaders['etag']).toBeUndefined(); + }); + + it('bypasses cache when skipCache returns true', async () => { + const middleware = koaCache({ + cache, + ttl: 60, + skipCache: (ctx) => Boolean(ctx.get?.('authorization')), + }); + let handlerCalls = 0; + + const ctx1 = createMockKoaContext('/api/profile'); + await middleware(ctx1, async () => { + handlerCalls++; + ctx1.body = { n: handlerCalls }; + }); + expect(ctx1.body).toEqual({ n: 1 }); + + const ctx2 = createMockKoaContext('/api/profile', { authorization: 'Bearer secret' }); + await middleware(ctx2, async () => { + handlerCalls++; + ctx2.body = { n: handlerCalls }; + }); + expect(handlerCalls).toBe(2); + expect(ctx2.body).toEqual({ n: 2 }); + }); + + it('bypasses cache when Cache-Control: no-cache is provided', async () => { + const middleware = koaCache({ cache, ttl: 60 }); + let handlerCalls = 0; + + const ctx1 = createMockKoaContext('/api/counter'); + await middleware(ctx1, async () => { + handlerCalls++; + ctx1.body = { count: handlerCalls }; + }); + expect(ctx1.body).toEqual({ count: 1 }); + + const ctx2 = createMockKoaContext('/api/counter', { 'cache-control': 'no-cache' }); + await middleware(ctx2, async () => { + handlerCalls++; + ctx2.body = { count: handlerCalls }; + }); + expect(handlerCalls).toBe(2); + expect(ctx2.body).toEqual({ count: 2 }); + }); + + it('does not cache 4xx or 5xx responses', async () => { + const middleware = koaCache({ cache, ttl: 60 }); + let handlerCalls = 0; + + const run = async (status: number, body: unknown) => { + const ctx = createMockKoaContext('/api/flaky'); + await middleware(ctx, async () => { + handlerCalls++; + ctx.status = status; + ctx.body = body; + }); + return ctx; + }; + + const first = await run(500, { error: 'upstream down' }); + const second = await run(200, { message: 'recovered' }); + + expect(first.status).toBe(500); + expect(handlerCalls).toBe(2); + expect(second.status).toBe(200); + expect(second.body).toEqual({ message: 'recovered' }); + }); + + it('forwards ttl, swr, and tags into CacheService.get', async () => { + const getSpy = vi.spyOn(cache, 'get'); + const middleware = koaCache({ + cache, + ttl: 42, + swr: 7, + tags: ['products'], + }); + + const ctx = createMockKoaContext('/api/ttl'); + await middleware(ctx, async () => { + ctx.body = { ok: true }; + }); + + expect(getSpy).toHaveBeenCalled(); + const [, , ttl, opts] = getSpy.mock.calls[0]; + expect(ttl).toBe(42); + expect(opts).toEqual(expect.objectContaining({ swr: 7, tags: ['products'] })); + getSpy.mockRestore(); + }); + + it('honors the Koa-native key option from the public API', async () => { + const getSpy = vi.spyOn(cache, 'get'); + const middleware = koaCache({ + cache, + ttl: 60, + key: (ctx) => `koa:${ctx.url}`, + }); + + const ctx = createMockKoaContext('/custom/path?b=2&a=1'); + await middleware(ctx, async () => { + ctx.body = { keyed: true }; + }); + + expect(getSpy.mock.calls[0][0]).toBe('koa:/custom/path?b=2&a=1'); + getSpy.mockRestore(); + }); + + it('still caches HEAD requests (safe method)', async () => { + const middleware = koaCache({ cache, ttl: 60 }); + let handlerCalls = 0; + + const ctx1 = createMockKoaContext('/api/meta', {}, 'HEAD'); + await middleware(ctx1, async () => { + handlerCalls++; + ctx1.body = { meta: true }; + }); + + const ctx2 = createMockKoaContext('/api/meta', {}, 'HEAD'); + await middleware(ctx2, async () => { + handlerCalls++; + ctx2.body = { meta: true }; + }); + + expect(handlerCalls).toBe(1); + expect(ctx2.body).toEqual({ meta: true }); + }); +}); From 95168fdb78cf430a4b78616346e6158161bad436 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 05:40:11 +0000 Subject: [PATCH 2/2] docs(readme): update tests-passing badge for Koa middleware suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI check-test-badge compares the README count to vitest's total. The Koa adapter adds 10 unit tests (816 → 826). Co-authored-by: David --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5fb6cf4..fa568b5 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-826%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)