From 7bfbb44995d51aeb43f8903f706085e056dc6b31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 14:32:41 +0000 Subject: [PATCH 1/2] feat(fastify): add first-class tricache/fastify package entry Re-export the existing Fastify plugin from src/http/fastify so apps can import createFastifyPlugin, fastifyCachePlugin, and fastifyCache from tricache/fastify. tricache/http Fastify exports stay for back-compat. Fixes #7 Co-authored-by: David --- CHANGELOG.md | 5 + CONTRIBUTING.md | 3 +- README.md | 1 + docs/.vitepress/config.ts | 3 +- docs/api-reference.md | 12 +- docs/changelog.md | 5 + docs/integrations/fastify.md | 76 +++++++++ docs/integrations/http.md | 12 +- docs/integrations/index.md | 3 +- package.json | 7 +- src/fastify/index.ts | 22 +++ src/http/fastify.ts | 2 +- src/http/index.ts | 3 + tests/fastify.test.ts | 314 +++++++++++++++++++++++++++++++++++ 14 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 docs/integrations/fastify.md create mode 100644 src/fastify/index.ts create mode 100644 tests/fastify.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd97cd4..b6dd21c 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 Fastify package entry (`tricache/fastify`)** — first-class `createFastifyPlugin` / `fastifyCachePlugin` / `fastifyCache` re-export of the existing `src/http/fastify` plugin. `tricache/http` Fastify exports remain for back-compat. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3929086..1d4c1e7 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 +│ ├── fastify/ # First-class Fastify plugin entry (re-exports http) ├── tests/ # Vitest unit & integration test suites ├── bench/ # Microbenchmark suites ├── bin/ # CLI binaries diff --git a/README.md b/README.md index 334b5b7..eed5297 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`. | +| **Fastify (Node)** | `tricache/fastify` | First-class `createFastifyPlugin` / `fastifyCachePlugin` / `fastifyCache` (same plugin as `tricache/http`). | | **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..d8f5b21 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: 'Fastify Plugin', link: '/integrations/fastify' }, { 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..54f98f2 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/fastify` & `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. @@ -477,13 +477,15 @@ app.get('/api/users', createExpressMiddleware(cache, { }), handler); ``` -### `createFastifyPlugin(cache, options?)` -Creates an encapsulation-safe Fastify plugin (`[Symbol.for('skip-override')] = true`) intercepting requests early in `onRequest` and caching responses in `onSend`. +### `createFastifyPlugin(options?)` (`tricache/fastify`) +Creates an encapsulation-safe Fastify plugin (`[Symbol.for('skip-override')] = true`) intercepting requests early in `onRequest` and caching responses in `onSend`. Prefer the dedicated entry; `tricache/http` still re-exports the same functions. ```typescript -import { createFastifyPlugin } from 'tricache/http'; +import { createFastifyPlugin, fastifyCachePlugin, fastifyCache } from 'tricache/fastify'; -await fastify.register(createFastifyPlugin(cache, { ttlSeconds: 120 })); +await fastify.register(createFastifyPlugin({ cache, ttl: 120, tags: ['api'] })); +// or: await fastify.register(fastifyCachePlugin, { cache, ttl: 120 }); +// or route-level: { preHandler: fastifyCache({ cache, ttl: 120, tags: ['catalog'] }) } ``` ### `createHonoEdgeMiddleware(edgeCache, options?)` diff --git a/docs/changelog.md b/docs/changelog.md index cd97cd4..b6dd21c 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 Fastify package entry (`tricache/fastify`)** — first-class `createFastifyPlugin` / `fastifyCachePlugin` / `fastifyCache` re-export of the existing `src/http/fastify` plugin. `tricache/http` Fastify exports remain for back-compat. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/docs/integrations/fastify.md b/docs/integrations/fastify.md new file mode 100644 index 0000000..b4bb637 --- /dev/null +++ b/docs/integrations/fastify.md @@ -0,0 +1,76 @@ +# Fastify Plugin + +> Package entry: `tricache/fastify` + +First-class **Node.js** Fastify plugin backed by `CacheService`. This is the dedicated export requested for Fastify apps — the same implementation as [`tricache/http`](/integrations/http), not a second stack. + +```typescript +import Fastify from 'fastify'; +import { createFastifyPlugin, fastifyCachePlugin, fastifyCache } from 'tricache/fastify'; + +const app = Fastify(); + +await app.register(createFastifyPlugin({ + ttl: 300, + tags: ['api'], +})); +``` + +`fastifyCachePlugin` is the zero-arg alias (`createFastifyPlugin()`). Pass options at register time: + +```typescript +import { CacheService } from 'tricache'; +import { fastifyCachePlugin } from 'tricache/fastify'; + +const cache = CacheService.create(); + +await app.register(fastifyCachePlugin, { + cache, + ttl: 300, + swr: 60, + tags: ['api'], + headerWhitelist: ['accept-language'], +}); +``` + +Route-level `preHandler` (ttl/tags without a global plugin): + +```typescript +import { fastifyCache } from 'tricache/fastify'; + +app.get('/api/catalog', { + preHandler: fastifyCache({ cache, ttl: 300, tags: ['catalog'] }), +}, async () => { + return await fetchCatalog(); +}); +``` + +`import { createFastifyPlugin, fastifyCachePlugin, fastifyCache } from 'tricache/http'` remains supported for back-compat. + +--- + +## Behavior + +* **Safe methods only**: `GET` and `HEAD` are cached; other methods pass through. +* **Weak ETags**: SHA-1 weak validators (`ETag: W/"…"`). +* **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. +* **ttl / tags**: already covered by plugin options and `fastifyCache({ ttl, tags })` `preHandler` opts. + +Route-level `config.cache` and `x-cache: HIT|MISS|STALE` response headers are **not** in this entry. Those can land as a follow-up on the same plugin — this package does not introduce a second Fastify implementation. + +--- + +## 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` | `(req) => string` | method + URL + sorted query | Custom cache key from the Fastify request | +| `headerWhitelist` | `string[]` | `[]` | Request headers incorporated into the cache key | +| `skipCache` | `(req) => boolean` | `undefined` | Predicate returning true to bypass cache | +| `tags` | `string[] \| ((req) => string[])` | `[]` | Semantic tags for targeted `cache.invalidateTag()` | diff --git a/docs/integrations/http.md b/docs/integrations/http.md index ac78551..ee4032c 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 Fastify** as a first-class subpath (`import { createFastifyPlugin, fastifyCachePlugin, fastifyCache } from 'tricache/fastify'`), see [Fastify Plugin](/integrations/fastify). The Fastify helpers below are the same implementation and remain exported from `tricache/http` for back-compat. + ### 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`. @@ -52,12 +54,12 @@ app.get( ## 2. Fastify Plugin (`createFastifyPlugin`) -TriCache wraps Fastify middleware with `[Symbol.for('skip-override')] = true`, eliminating route encapsulation barriers. +TriCache wraps Fastify middleware with `[Symbol.for('skip-override')] = true`, eliminating route encapsulation barriers. Prefer `import { … } from 'tricache/fastify'`; `tricache/http` re-exports the same functions. ### Global Plugin Registration ```typescript import Fastify from 'fastify'; -import { createFastifyPlugin } from 'tricache/http'; +import { createFastifyPlugin } from 'tricache/fastify'; import { CacheService } from 'tricache'; const fastify = Fastify(); @@ -73,12 +75,10 @@ await fastify.register(createFastifyPlugin({ ### Route-Level `preHandler` Hook ```typescript -import { createFastifyPlugin } from 'tricache/http'; - -const plugin = createFastifyPlugin({ cache, ttl: 300 }); +import { fastifyCache } from 'tricache/fastify'; fastify.get('/api/catalog', { - preHandler: plugin.preHandler, + preHandler: fastifyCache({ cache, ttl: 300, tags: ['catalog'] }), }, async (request, reply) => { return await fetchCatalog(); }); diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 26f10f7..b26713a 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. | +| **[Fastify Plugin](/integrations/fastify)** | `tricache/fastify` | First-class Fastify plugin / `preHandler` on `CacheService` (same API as `tricache/http`). | | **[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..244c60c 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,11 @@ "import": "./dist/http/index.js", "require": "./dist/http/index.cjs" }, + "./fastify": { + "types": "./dist/fastify/index.d.ts", + "import": "./dist/fastify/index.js", + "require": "./dist/fastify/index.cjs" + }, "./dashboard": { "types": "./dist/dashboard/index.d.ts", "import": "./dist/dashboard/index.js", @@ -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/fastify/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/fastify/index.ts b/src/fastify/index.ts new file mode 100644 index 0000000..12d95da --- /dev/null +++ b/src/fastify/index.ts @@ -0,0 +1,22 @@ +/** + * tricache/fastify — first-class Node.js Fastify plugin entry. + * + * Re-exports the existing Fastify plugin from `src/http/fastify.ts` so apps can: + * + * import { createFastifyPlugin, fastifyCachePlugin, fastifyCache } from 'tricache/fastify'; + * + * `import { … } from 'tricache/http'` remains supported for back-compat. + * + * Plugin `options` and route `preHandler` already accept `ttl` / `tags` (and + * `swr`, `etag`, `skipCache`, `keyGenerator`, `headerWhitelist`). Route-level + * `config.cache` and `x-cache: HIT|MISS|STALE` headers are intentionally left + * for a follow-up so this entry does not fork a second Fastify stack. + */ + +export { + createFastifyPlugin, + fastifyCachePlugin, + fastifyCache, + type FastifyCacheOptions, + type CachedFastifyResponse, +} from '../http/fastify.js'; diff --git a/src/http/fastify.ts b/src/http/fastify.ts index 0aa267e..1c35f94 100644 --- a/src/http/fastify.ts +++ b/src/http/fastify.ts @@ -33,7 +33,7 @@ export interface CachedFastifyResponse { * * @example * import Fastify from 'fastify'; - * import { fastifyCachePlugin } from 'tricache/http'; + * import { fastifyCachePlugin } from 'tricache/fastify'; * * const app = Fastify(); * await app.register(fastifyCachePlugin, { diff --git a/src/http/index.ts b/src/http/index.ts index 0b2629b..f348996 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 }); * + * First-class Fastify entry (same plugin, dedicated subpath): + * import { createFastifyPlugin, fastifyCachePlugin, fastifyCache } from 'tricache/fastify'; + * * For Edge runtimes (Cloudflare Workers, Vercel Edge, Deno) and Hono, use: * import { honoEdgeCache } from 'tricache/edge'; */ diff --git a/tests/fastify.test.ts b/tests/fastify.test.ts new file mode 100644 index 0000000..dd52335 --- /dev/null +++ b/tests/fastify.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { + createFastifyPlugin, + fastifyCachePlugin, + fastifyCache, +} from '../src/fastify/index.js'; +import { + createFastifyPlugin as httpCreateFastifyPlugin, + fastifyCachePlugin as httpFastifyCachePlugin, + fastifyCache as httpFastifyCache, +} from '../src/http/index.js'; + +describe('Fastify plugin (tricache/fastify)', () => { + let cache: CacheService; + let namespace: string; + + beforeEach(() => { + namespace = `test_fastify_${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 createFastifyPlugin, fastifyCachePlugin, and fastifyCache from the dedicated entry', () => { + expect(typeof createFastifyPlugin).toBe('function'); + expect(typeof fastifyCachePlugin).toBe('function'); + expect(typeof fastifyCache).toBe('function'); + expect(createFastifyPlugin).toBe(httpCreateFastifyPlugin); + expect(fastifyCachePlugin).toBe(httpFastifyCachePlugin); + expect(fastifyCache).toBe(httpFastifyCache); + }); + + function createMockFastifyApp() { + const hooks: Record> = { + onRequest: [], + onSend: [], + }; + + return { + addHook(name: string, fn: Function) { + hooks[name].push(fn); + }, + async runRequest(req: any, reply: any) { + for (const hook of hooks.onRequest) { + await hook(req, reply); + if (reply.sent) return; + } + }, + async runSend(req: any, reply: any, payload: any) { + let current = payload; + for (const hook of hooks.onSend) { + current = await hook(req, reply, current); + } + return current; + }, + }; + } + + function createMockFastifyReply() { + const headers: Record = {}; + let statusCode = 200; + let sentPayload: any = null; + let isSent = false; + + return { + headers, + statusCode, + get sent() { return isSent; }, + header(name: string, value: string) { + headers[name.toLowerCase()] = value; + return this; + }, + getHeader(name: string) { + return headers[name.toLowerCase()]; + }, + code(code: number) { + statusCode = code; + this.statusCode = code; + return this; + }, + send(payload?: any) { + sentPayload = payload; + isSent = true; + return this; + }, + getPayload: () => sentPayload, + }; + } + + describe('createFastifyPlugin (register / onRequest + onSend)', () => { + it('registers hooks and serves a cache miss then a HIT without re-running onSend capture', async () => { + const app = createMockFastifyApp(); + const plugin = createFastifyPlugin({ cache, ttl: 60, tags: ['items'] }); + await plugin(app); + + const req1 = { method: 'GET', url: '/fastify/items', headers: {} }; + const reply1 = createMockFastifyReply(); + + await app.runRequest(req1, reply1); + expect(reply1.sent).toBe(false); + + const initialPayload = JSON.stringify([{ id: 1, item: 'Keyboard' }]); + reply1.header('content-type', 'application/json'); + const delivered = await app.runSend(req1, reply1, initialPayload); + expect(delivered).toBe(initialPayload); + expect(reply1.headers['etag']).toBeDefined(); + + await new Promise(r => setTimeout(r, 10)); + + const req2 = { method: 'GET', url: '/fastify/items', headers: {} }; + const reply2 = createMockFastifyReply(); + + await app.runRequest(req2, reply2); + expect(reply2.sent).toBe(true); + expect(reply2.getPayload()).toBe(initialPayload); + expect(reply2.headers['etag']).toBe(reply1.headers['etag']); + }); + + it('evaluates If-None-Match and returns 304 Not Modified', async () => { + const app = createMockFastifyApp(); + const plugin = createFastifyPlugin({ cache, ttl: 60 }); + await plugin(app); + + const req1 = { method: 'GET', url: '/fastify/data', headers: {} }; + const reply1 = createMockFastifyReply(); + await app.runRequest(req1, reply1); + await app.runSend(req1, reply1, 'fastify_cached_value'); + + await new Promise(r => setTimeout(r, 10)); + const etag = reply1.headers['etag']; + expect(etag).toBeDefined(); + + const req2 = { method: 'GET', url: '/fastify/data', headers: { 'if-none-match': etag } }; + const reply2 = createMockFastifyReply(); + await app.runRequest(req2, reply2); + + expect(reply2.sent).toBe(true); + expect(reply2.statusCode).toBe(304); + }); + + it('does not cache a 500 response and refetches on the next request', async () => { + const app = createMockFastifyApp(); + const plugin = createFastifyPlugin({ cache, ttl: 60 }); + await plugin(app); + + const req1 = { method: 'GET', url: '/fastify/flaky', headers: {} }; + const reply1 = createMockFastifyReply(); + await app.runRequest(req1, reply1); + reply1.code(500); + const errorPayload = JSON.stringify({ error: 'upstream down' }); + await app.runSend(req1, reply1, errorPayload); + + await new Promise(r => setTimeout(r, 10)); + + const req2 = { method: 'GET', url: '/fastify/flaky', headers: {} }; + const reply2 = createMockFastifyReply(); + await app.runRequest(req2, reply2); + expect(reply2.sent).toBe(false); + + const freshPayload = JSON.stringify({ item: 'ok' }); + reply2.code(200); + const delivered = await app.runSend(req2, reply2, freshPayload); + expect(delivered).toBe(freshPayload); + }); + }); + + describe('fastifyCache (route preHandler)', () => { + it('serves a miss then a HIT with ETag headers without re-running the handler', async () => { + const middleware = fastifyCache({ cache, ttl: 60, tags: ['products'] }); + let handlerCalls = 0; + + const headers1: Record = {}; + let statusCode1 = 200; + let body1: any = null; + + const req1 = { method: 'GET', url: '/api/v1/products', headers: {} }; + const reply1: any = { + sent: false, + statusCode: 200, + header: (k: string, v: string) => { headers1[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers1[k.toLowerCase()], + code: (c: number) => { statusCode1 = c; reply1.statusCode = c; return reply1; }, + send: (b: any) => { body1 = b; reply1.sent = true; return reply1; }, + }; + + await middleware(req1, reply1); + if (!reply1.sent) { + handlerCalls++; + reply1.send({ product: 'macbook-pro', stock: 12 }); + } + + expect(handlerCalls).toBe(1); + expect(statusCode1).toBe(200); + expect(body1).toEqual({ product: 'macbook-pro', stock: 12 }); + const etag = headers1['etag']; + expect(etag).toBeDefined(); + + const headers2: Record = {}; + let body2: any = null; + const req2 = { method: 'GET', url: '/api/v1/products', headers: {} }; + const reply2: any = { + sent: false, + statusCode: 200, + header: (k: string, v: string) => { headers2[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers2[k.toLowerCase()], + code: (c: number) => { reply2.statusCode = c; return reply2; }, + send: (b: any) => { body2 = b; reply2.sent = true; return reply2; }, + }; + + await middleware(req2, reply2); + if (!reply2.sent) { + handlerCalls++; + reply2.send({ product: 'should-not-run' }); + } + + expect(handlerCalls).toBe(1); + expect(body2).toEqual({ product: 'macbook-pro', stock: 12 }); + expect(headers2['etag']).toBe(etag); + }); + + it('intercepts preHandler, calculates ETag, and short-circuits 304', async () => { + const middleware = fastifyCache({ cache, ttl: 60 }); + let handlerCalls = 0; + + const headers1: Record = {}; + const req1 = { method: 'GET', url: '/api/v1/products', headers: {} }; + const reply1: any = { + sent: false, + header: (k: string, v: string) => { headers1[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers1[k.toLowerCase()], + code: (c: number) => { reply1.statusCode = c; return reply1; }, + send: (b: any) => { reply1.sent = true; return b; }, + }; + + await middleware(req1, reply1); + if (!reply1.sent) { + handlerCalls++; + reply1.send({ product: 'macbook-pro', stock: 12 }); + } + + const etag = headers1['etag']; + expect(etag).toBeDefined(); + + const headers2: Record = {}; + let statusCode2 = 200; + const req2 = { method: 'GET', url: '/api/v1/products', headers: { 'if-none-match': etag } }; + const reply2: any = { + sent: false, + header: (k: string, v: string) => { headers2[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers2[k.toLowerCase()], + code: (c: number) => { statusCode2 = c; return reply2; }, + send: vi.fn(() => { reply2.sent = true; return reply2; }), + }; + + await middleware(req2, reply2); + if (!reply2.sent) { + handlerCalls++; + } + + expect(handlerCalls).toBe(1); + expect(statusCode2).toBe(304); + expect(reply2.send).toHaveBeenCalled(); + }); + + it('does not cache a 500 response and refetches on the next request', async () => { + const middleware = fastifyCache({ cache, ttl: 60 }); + let routeCalls = 0; + + const req1 = { method: 'GET', url: '/api/flaky', headers: {} }; + const reply1: any = { + sent: false, + statusCode: 200, + header: () => {}, + getHeader: () => undefined, + code: function (c: number) { this.statusCode = c; return this; }, + send: function () { this.sent = true; return this; }, + }; + await middleware(req1, reply1); + if (!reply1.sent) { + routeCalls++; + reply1.code(500); + reply1.send({ error: 'upstream down' }); + } + + const req2 = { method: 'GET', url: '/api/flaky', headers: {} }; + let body2: unknown = null; + const reply2: any = { + sent: false, + statusCode: 200, + header: () => {}, + getHeader: () => undefined, + code: function (c: number) { this.statusCode = c; return this; }, + send: function (b: unknown) { body2 = b; this.sent = true; return this; }, + }; + await middleware(req2, reply2); + if (!reply2.sent) { + routeCalls++; + reply2.code(200); + reply2.send({ product: 'fresh' }); + } + + expect(routeCalls).toBe(2); + expect(body2).toEqual({ product: 'fresh' }); + }); + }); +}); From 7de3fe54c429aeaca15ed9482829fb4fc47274d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 14:34:26 +0000 Subject: [PATCH 2/2] test(fastify): wait for async set on preHandler HIT and update badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route preHandler persist path is fire-and-forget (void cache.set), same as the plugin onSend hook. Wait before asserting HIT, and bump the README tests-passing badge 816 → 823 for the new dedicated suite. Co-authored-by: David --- README.md | 2 +- tests/fastify.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eed5297..d264b97 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-823%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) diff --git a/tests/fastify.test.ts b/tests/fastify.test.ts index dd52335..9555c34 100644 --- a/tests/fastify.test.ts +++ b/tests/fastify.test.ts @@ -203,6 +203,9 @@ describe('Fastify plugin (tricache/fastify)', () => { const etag = headers1['etag']; expect(etag).toBeDefined(); + // preHandler persist is fire-and-forget (void cache.set) + await new Promise(r => setTimeout(r, 10)); + const headers2: Record = {}; let body2: any = null; const req2 = { method: 'GET', url: '/api/v1/products', headers: {} };