From 6aebf7af7b9e3b0ea7abd18abbfce5155686ecc0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 06:14:05 +0000 Subject: [PATCH] examples: add Fastify REST API demo with plugin and route preHandler Add examples/fastify-api showing official tricache/http Fastify helpers: createFastifyPlugin (onRequest/onSend), fastifyCache as a route preHandler, weak ETags, and If-None-Match 304 responses. Fixes #26 Co-authored-by: David --- docs/integrations/http.md | 22 +- examples/fastify-api/.gitignore | 6 + examples/fastify-api/README.md | 176 ++++++ examples/fastify-api/package.json | 26 + examples/fastify-api/pnpm-lock.yaml | 682 +++++++++++++++++++++++ examples/fastify-api/pnpm-workspace.yaml | 3 + examples/fastify-api/src/catalog.ts | 185 ++++++ examples/fastify-api/src/server.ts | 179 ++++++ examples/fastify-api/src/verify.ts | 251 +++++++++ examples/fastify-api/tsconfig.json | 16 + tests/fastify-api-example.test.ts | 223 ++++++++ 11 files changed, 1765 insertions(+), 4 deletions(-) create mode 100644 examples/fastify-api/.gitignore create mode 100644 examples/fastify-api/README.md create mode 100644 examples/fastify-api/package.json create mode 100644 examples/fastify-api/pnpm-lock.yaml create mode 100644 examples/fastify-api/pnpm-workspace.yaml create mode 100644 examples/fastify-api/src/catalog.ts create mode 100644 examples/fastify-api/src/server.ts create mode 100644 examples/fastify-api/src/verify.ts create mode 100644 examples/fastify-api/tsconfig.json create mode 100644 tests/fastify-api-example.test.ts diff --git a/docs/integrations/http.md b/docs/integrations/http.md index ac78551..3fc35e5 100644 --- a/docs/integrations/http.md +++ b/docs/integrations/http.md @@ -17,6 +17,19 @@ pnpm dev Then follow the `curl -i` walkthrough in that README. +### Ready-to-run Fastify demo + +A self-contained TypeScript app lives at [`examples/fastify-api`](https://github.com/Kareem411/TriCache/tree/main/examples/fastify-api). It exercises both official surfaces — global `createFastifyPlugin` / `fastifyCachePlugin` (`onRequest` short-circuit + `onSend` capture) and route-level `preHandler: fastifyCache(...)` — plus weak ETags and `If-None-Match` → `304`. + +```bash +pnpm install && pnpm build +cd examples/fastify-api +pnpm install +pnpm dev +``` + +Then follow the `curl -i` walkthrough in that README. + --- ## 1. Express & Connect (`createExpressMiddleware`) @@ -72,13 +85,14 @@ await fastify.register(createFastifyPlugin({ ``` ### Route-Level `preHandler` Hook -```typescript -import { createFastifyPlugin } from 'tricache/http'; -const plugin = createFastifyPlugin({ cache, ttl: 300 }); +`createFastifyPlugin` returns a Fastify plugin (lifecycle hooks), not an object with `.preHandler`. Use `fastifyCache` when you want the same options object as either a plugin or a route hook: + +```typescript +import { fastifyCache } from 'tricache/http'; fastify.get('/api/catalog', { - preHandler: plugin.preHandler, + preHandler: fastifyCache({ cache, ttl: 300 }), }, async (request, reply) => { return await fetchCatalog(); }); diff --git a/examples/fastify-api/.gitignore b/examples/fastify-api/.gitignore new file mode 100644 index 0000000..63c72d4 --- /dev/null +++ b/examples/fastify-api/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.pnpm-debug.log* +.DS_Store +*.tsbuildinfo diff --git a/examples/fastify-api/README.md b/examples/fastify-api/README.md new file mode 100644 index 0000000..f6ff29d --- /dev/null +++ b/examples/fastify-api/README.md @@ -0,0 +1,176 @@ +# TriCache Fastify API Demo + +Minimal Fastify TypeScript app that uses the official Fastify helpers from [`tricache/http`](https://kareem411.github.io/TriCache/integrations/http): + +| Export | Role in this demo | +|---|---| +| [`createFastifyPlugin`](../../src/http/fastify.ts) | Global `fastify.register(...)` — `onRequest` short-circuit + `onSend` capture | +| `fastifyCachePlugin` | Same factory with no preset options (`createFastifyPlugin()`); pass `{ cache, ttl, ... }` at `register()` | +| `fastifyCache` | Dual helper used as a route `preHandler` on `/api/catalog` | + +It shows the behaviors from [Kareem411/TriCache#26](https://github.com/Kareem411/TriCache/issues/26): + +| Behavior | What to look for | +|---|---| +| Global plugin | `GET /api/products` is cached by `onRequest` / `onSend` | +| Route `preHandler` | `GET /api/catalog` is cached by `preHandler: fastifyCache({ ... })` | +| Weak ETag | `ETag: W/"…"` on `200` responses | +| RFC 7232 `304` | Repeat with `If-None-Match` → empty `304 Not Modified` | +| Deterministic query sorting | `?limit=5&page=2` and `?page=2&limit=5` share `generatedAt` + ETag | +| `headerWhitelist: ['accept-language']` | `en` vs `fr` are separate cache entries (`/api/products`) | +| `skipCache` for auth | `Authorization: Bearer …` on `/api/products` always hits origin | + +Origin work is a simulated **350ms** catalog query. Cache hits replay the stored JSON and skip that delay. Hits do **not** replay `X-TriCache-Demo: origin` — that header is set only when the route handler runs. + +The published API is `createFastifyPlugin(options)` / `fastifyCache(options)` with an optional `cache` field — not `createFastifyPlugin(cache, options)`, and not `plugin.preHandler`. + +Redis is not required. The demo uses an in-process L1 cache (`disableRedis: true`, `disableDisk: true`). + +--- + +## Run locally + +From the **repository root**, build the local `tricache` package (the example links to `../..`): + +```bash +pnpm install +pnpm build +``` + +Then start the demo: + +```bash +cd examples/fastify-api +pnpm install +pnpm dev +``` + +`pnpm start` is the same command. The process listens on `http://127.0.0.1:3000`. Override with `PORT` / `HOST` / `ORIGIN_LATENCY_MS`. + +If you installed `tricache` from npm instead of the repo link, `node --import tsx src/server.ts` (or `pnpm dev`) is enough — no root build step. + +--- + +## Try it with `curl -i` + +Keep the server running in another terminal. Use an explicit `Accept-Language`: an omitted language header and `Accept-Language: en` are **different** cache keys (the whitelist only adds the header when it is present). + +Capture the ETag from a **GET** (`curl -sI` is HEAD, and HEAD is a different cache key). + +### 1. Global plugin — cold miss, weak ETag + +```bash +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' +``` + +Expect `HTTP/1.1 200`, `ETag: W/"…"`, `X-TriCache-Demo: origin`, `"style":"global-plugin"`, and a `generatedAt` timestamp. This request takes ~350ms. The plugin captures the serialized body in `onSend`. + +### 2. Global plugin — swapped query, cache hit (`onRequest` short-circuit) + +```bash +curl -i 'http://127.0.0.1:3000/api/products?page=2&limit=5' \ + -H 'Accept-Language: en' +``` + +Expect the **same** `ETag` and `generatedAt`, no `X-TriCache-Demo` header, and a much faster response. The handler does not run. + +### 3. Global plugin — `304 Not Modified` + +```bash +ETAG=$(curl -sD - -o /dev/null 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' \ + | awk -F': ' 'tolower($1)=="etag"{gsub("\r","",$2); print $2}') + +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' \ + -H "If-None-Match: $ETAG" +``` + +Expect `HTTP/1.1 304 Not Modified`, the same `ETag`, and an **empty** body. + +### 4. Language variants — `headerWhitelist` + +```bash +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: fr' +``` + +Expect a new origin fetch (`X-TriCache-Demo: origin`), a **different** ETag, `lang: "fr"`, and localized names (for example `Haut-parleurs de bureau`). + +### 5. Authenticated request — `skipCache` + +```bash +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' \ + -H 'Authorization: Bearer demo' +``` + +Expect `cacheBypassed: true`, `X-TriCache-Demo: origin`, **no** `ETag`, and a new `generatedAt` on every call. + +### 6. Route `preHandler` — miss, hit, and `304` + +```bash +curl -i 'http://127.0.0.1:3000/api/catalog?limit=5&page=2' \ + -H 'Accept-Language: en' +``` + +Expect `"style":"route-preHandler"` and a weak ETag. Repeat the swapped query and the `If-None-Match` dance from steps 2–3 against `/api/catalog` — same hit / empty `304` behaviour, implemented by `fastifyCache` wrapping `reply.send` instead of Fastify lifecycle hooks. + +`Cache-Control: no-cache` / `no-store` also bypass the cache (built into `tricache/http`). + +--- + +## Automated check + +```bash +pnpm typecheck +pnpm verify +``` + +`pnpm verify` starts the server on port `34568` and asserts plugin + preHandler hit / 304 behaviour. + +--- + +## How the plugin is wired + +```typescript +import Fastify from 'fastify'; +import { CacheService } from 'tricache'; +import { createFastifyPlugin, fastifyCache, fastifyCachePlugin } from 'tricache/http'; + +const cache = CacheService.create({ + namespace: 'fastify-api-demo', + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, +}); + +const app = Fastify(); + +// Global: onRequest short-circuit + onSend persistence. +await app.register(createFastifyPlugin({ + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['products'], + headerWhitelist: ['accept-language'], + skipCache: (req) => req.url?.split('?')[0] !== '/api/products' + || Boolean(req.headers?.authorization), +})); + +// Equivalent: await app.register(fastifyCachePlugin, { cache, ttl: 120, ... }) + +app.get('/api/catalog', { + preHandler: fastifyCache({ + cache, + ttl: 120, + etag: true, + tags: ['catalog'], + headerWhitelist: ['accept-language'], + }), +}, async () => fetchCatalog()); +``` + +The published options object is `{ cache, ttl, swr, etag, tags, headerWhitelist, skipCache }` — not `(cache, { ttlSeconds })`. diff --git a/examples/fastify-api/package.json b/examples/fastify-api/package.json new file mode 100644 index 0000000..499c996 --- /dev/null +++ b/examples/fastify-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "fastify-api", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "TriCache Fastify microservice demo: createFastifyPlugin, fastifyCache preHandler, weak ETag, 304 Not Modified", + "scripts": { + "dev": "tsx src/server.ts", + "start": "tsx src/server.ts", + "typecheck": "tsc --noEmit", + "verify": "tsx src/verify.ts" + }, + "dependencies": { + "fastify": "^5.6.1", + "tricache": "link:../.." + }, + "devDependencies": { + "@types/node": "^22.18.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=20.10.0" + }, + "packageManager": "pnpm@11.22.0" +} diff --git a/examples/fastify-api/pnpm-lock.yaml b/examples/fastify-api/pnpm-lock.yaml new file mode 100644 index 0000000..7a7beef --- /dev/null +++ b/examples/fastify-api/pnpm-lock.yaml @@ -0,0 +1,682 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + fastify: + specifier: ^5.6.1 + version: 5.12.5 + tricache: + specifier: link:../.. + version: link:../.. + devDependencies: + '@types/node': + specifier: ^22.18.0 + version: 22.20.3 + tsx: + specifier: ^4.20.5 + version: 4.23.13 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.1': + resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@types/node@22.20.3': + resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-uri@4.1.5: + resolution: {integrity: sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg==} + + fastify@5.12.5: + resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.5 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.1': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@pinojs/redact@0.4.0': {} + + '@types/node@22.20.3': + dependencies: + undici-types: 6.21.0 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.3 + + cookie@1.1.1: {} + + dequal@2.0.3: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.5 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.8: {} + + fast-uri@4.1.5: {} + + fastify@5.12.5: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.1 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fsevents@2.3.3: + optional: true + + ipaddr.js@2.5.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + on-exit-leak-free@2.1.2: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + toad-cache@3.7.4: {} + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/examples/fastify-api/pnpm-workspace.yaml b/examples/fastify-api/pnpm-workspace.yaml new file mode 100644 index 0000000..1b56dcb --- /dev/null +++ b/examples/fastify-api/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: false + msgpackr-extract: false diff --git a/examples/fastify-api/src/catalog.ts b/examples/fastify-api/src/catalog.ts new file mode 100644 index 0000000..5b9f758 --- /dev/null +++ b/examples/fastify-api/src/catalog.ts @@ -0,0 +1,185 @@ +export type CatalogLang = 'en' | 'fr' | 'es'; + +export interface LocalizedCopy { + name: string; + category: string; +} + +export interface CatalogProduct { + id: number; + sku: string; + price: number; + copy: Record; +} + +export interface PublicProduct { + id: number; + sku: string; + name: string; + category: string; + price: number; +} + +export const CATALOG: CatalogProduct[] = [ + { + id: 1, + sku: 'kb-01', + price: 129, + copy: { + en: { name: 'Mechanical Keyboard', category: 'peripherals' }, + fr: { name: 'Clavier mécanique', category: 'périphériques' }, + es: { name: 'Teclado mecánico', category: 'periféricos' }, + }, + }, + { + id: 2, + sku: 'ms-02', + price: 79, + copy: { + en: { name: 'Wireless Mouse', category: 'peripherals' }, + fr: { name: 'Souris sans fil', category: 'périphériques' }, + es: { name: 'Ratón inalámbrico', category: 'periféricos' }, + }, + }, + { + id: 3, + sku: 'hd-03', + price: 249, + copy: { + en: { name: 'Studio Headphones', category: 'audio' }, + fr: { name: 'Casque studio', category: 'audio' }, + es: { name: 'Auriculares de estudio', category: 'audio' }, + }, + }, + { + id: 4, + sku: 'mn-04', + price: 399, + copy: { + en: { name: '4K Monitor', category: 'displays' }, + fr: { name: 'Moniteur 4K', category: 'écrans' }, + es: { name: 'Monitor 4K', category: 'pantallas' }, + }, + }, + { + id: 5, + sku: 'dk-05', + price: 189, + copy: { + en: { name: 'Standing Desk Converter', category: 'furniture' }, + fr: { name: 'Convertisseur de bureau debout', category: 'mobilier' }, + es: { name: 'Conversor de escritorio de pie', category: 'mobiliario' }, + }, + }, + { + id: 6, + sku: 'wb-06', + price: 59, + copy: { + en: { name: 'Webcam 1080p', category: 'peripherals' }, + fr: { name: 'Webcam 1080p', category: 'périphériques' }, + es: { name: 'Cámara web 1080p', category: 'periféricos' }, + }, + }, + { + id: 7, + sku: 'sp-07', + price: 149, + copy: { + en: { name: 'Desktop Speakers', category: 'audio' }, + fr: { name: 'Haut-parleurs de bureau', category: 'audio' }, + es: { name: 'Altavoces de escritorio', category: 'audio' }, + }, + }, + { + id: 8, + sku: 'ht-08', + price: 89, + copy: { + en: { name: 'USB Hub', category: 'peripherals' }, + fr: { name: 'Hub USB', category: 'périphériques' }, + es: { name: 'Concentrador USB', category: 'periféricos' }, + }, + }, + { + id: 9, + sku: 'lt-09', + price: 45, + copy: { + en: { name: 'Desk Lamp', category: 'furniture' }, + fr: { name: 'Lampe de bureau', category: 'mobilier' }, + es: { name: 'Lámpara de escritorio', category: 'mobiliario' }, + }, + }, + { + id: 10, + sku: 'pd-10', + price: 69, + copy: { + en: { name: 'Laptop Stand', category: 'furniture' }, + fr: { name: 'Support pour ordinateur portable', category: 'mobilier' }, + es: { name: 'Soporte para portátil', category: 'mobiliario' }, + }, + }, + { + id: 11, + sku: 'mc-11', + price: 119, + copy: { + en: { name: 'USB Microphone', category: 'audio' }, + fr: { name: 'Microphone USB', category: 'audio' }, + es: { name: 'Micrófono USB', category: 'audio' }, + }, + }, + { + id: 12, + sku: 'dp-12', + price: 219, + copy: { + en: { name: 'Ultrawide Display', category: 'displays' }, + fr: { name: 'Écran ultra-large', category: 'écrans' }, + es: { name: 'Pantalla ultrawide', category: 'pantallas' }, + }, + }, +]; + +const SUPPORTED: CatalogLang[] = ['en', 'fr', 'es']; + +/** Map `Accept-Language` to a catalog locale. Unrecognized values fall back to `en`. */ +export function resolveLanguage(header: string | string[] | undefined): CatalogLang { + const raw = Array.isArray(header) ? header[0] : header; + if (!raw) return 'en'; + + const tokens = raw.toLowerCase().split(','); + for (const token of tokens) { + const tag = token.split(';')[0]?.trim() ?? ''; + const base = tag.split('-')[0] ?? ''; + if (SUPPORTED.includes(base as CatalogLang)) { + return base as CatalogLang; + } + } + return 'en'; +} + +export function localizeProduct(product: CatalogProduct, lang: CatalogLang): PublicProduct { + const copy = product.copy[lang]; + return { + id: product.id, + sku: product.sku, + name: copy.name, + category: copy.category, + price: product.price, + }; +} + +export function paginateCatalog(lang: CatalogLang, page: number, limit: number): { + items: PublicProduct[]; + total: number; +} { + const items = CATALOG.map((product) => localizeProduct(product, lang)); + const start = (page - 1) * limit; + return { + items: items.slice(start, start + limit), + total: items.length, + }; +} diff --git a/examples/fastify-api/src/server.ts b/examples/fastify-api/src/server.ts new file mode 100644 index 0000000..1a5210d --- /dev/null +++ b/examples/fastify-api/src/server.ts @@ -0,0 +1,179 @@ +import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; +import { CacheService } from 'tricache'; +import { createFastifyPlugin, fastifyCache } from 'tricache/http'; +import { paginateCatalog, resolveLanguage } from './catalog.js'; + +const PORT = Number(process.env.PORT) || 3000; +const HOST = process.env.HOST ?? '127.0.0.1'; +const ORIGIN_LATENCY_MS = Number(process.env.ORIGIN_LATENCY_MS) || 350; + +/** + * In-process only so the demo runs without Redis or a writable disk tier. + * Swap these flags (or use CacheService.preset('microservice')) for a clustered deploy. + */ +const cache = CacheService.create({ + namespace: 'fastify-api-demo', + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, +}); + +function requestPath(req: { url?: string }): string { + const raw = req.url ?? '/'; + const q = raw.indexOf('?'); + return q >= 0 ? raw.slice(0, q) : raw; +} + +/** + * Shared options object. Official Fastify helpers take `{ cache?, ttl, ... }`, + * not `createFastifyPlugin(cache, options)`. + * + * `fastifyCachePlugin` is `createFastifyPlugin()` with no preset options — + * equivalent registration: `fastify.register(fastifyCachePlugin, productsCacheOptions)`. + */ +const productsCacheOptions = { + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['products'], + /** `en` vs `fr` become distinct keys; `User-Agent` / other headers do not. */ + headerWhitelist: ['accept-language'], + /** + * Limit the global `onRequest`/`onSend` plugin to `/api/products`. + * `/api/catalog` is owned by the route `preHandler` below. + * Authenticated traffic is user-specific — never store it. + */ + skipCache: (req: { url?: string; headers?: Record }) => + requestPath(req) !== '/api/products' || Boolean(req.headers?.authorization), +}; + +const catalogCacheOptions = { + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['catalog'], + headerWhitelist: ['accept-language'], +}; + +const app = Fastify({ logger: false }); + +// Style 1 — global plugin: early short-circuit in `onRequest`, capture in `onSend`. +await app.register(createFastifyPlugin(productsCacheOptions)); + +app.get('/', async () => ({ + name: 'TriCache Fastify API demo', + docs: 'See README.md for curl -i walkthroughs', + exports: { + createFastifyPlugin: 'plugin factory — register(createFastifyPlugin({ cache, ttl, ... }))', + fastifyCachePlugin: 'alias of createFastifyPlugin() with no preset options', + fastifyCache: 'dual helper — register() plugin or route preHandler', + }, + routes: { + products: 'GET /api/products?page=1&limit=5 (global createFastifyPlugin)', + catalog: 'GET /api/catalog?page=1&limit=5 (route preHandler: fastifyCache)', + health: 'GET /healthz', + }, + try: { + etag: 'GET /api/products — look for ETag: W/"..."', + notModified: 'repeat with If-None-Match', + querySort: '/api/products?limit=5&page=2 vs ?page=2&limit=5', + language: 'Accept-Language: en | fr | es', + skipAuth: 'Authorization: Bearer demo on /api/products', + preHandler: 'GET /api/catalog — same ETag/304 via fastifyCache preHandler', + }, +})); + +app.get('/healthz', async () => ({ ok: true })); + +app.get('/api/products', async (request: FastifyRequest, reply: FastifyReply) => { + const started = Date.now(); + await sleep(ORIGIN_LATENCY_MS); + + const query = request.query as Record; + const page = parsePositiveInt(query.page, 1, 50); + const limit = parsePositiveInt(query.limit, 5, 50); + const lang = resolveLanguage(request.headers['accept-language']); + const { items, total } = paginateCatalog(lang, page, limit); + const authorized = Boolean(request.headers.authorization); + + // Origin-only. Cache hits short-circuit in onRequest and never reach this handler. + reply.header('X-TriCache-Demo', 'origin'); + return { + style: 'global-plugin', + hook: 'onRequest + onSend', + lang, + page, + limit, + total, + generatedAt: new Date().toISOString(), + originLatencyMs: Date.now() - started, + cacheBypassed: authorized, + note: 'generatedAt is stamped by the origin. Identical values mean a cache hit. Query order is irrelevant; Accept-Language is part of the key; Authorization skips the cache.', + items, + }; +}); + +// Style 2 — route-level dual handler: probe in preHandler, persist by wrapping reply.send. +app.get( + '/api/catalog', + { preHandler: fastifyCache(catalogCacheOptions) }, + async (request: FastifyRequest, reply: FastifyReply) => { + const started = Date.now(); + await sleep(ORIGIN_LATENCY_MS); + + const query = request.query as Record; + const page = parsePositiveInt(query.page, 1, 50); + const limit = parsePositiveInt(query.limit, 5, 50); + const lang = resolveLanguage(request.headers['accept-language']); + const { items, total } = paginateCatalog(lang, page, limit); + + reply.header('X-TriCache-Demo', 'origin'); + return { + style: 'route-preHandler', + hook: 'fastifyCache preHandler', + lang, + page, + limit, + total, + generatedAt: new Date().toISOString(), + originLatencyMs: Date.now() - started, + cacheBypassed: false, + note: 'This route is cached only by preHandler: fastifyCache({ cache, ttl, ... }). Hits never reach this handler.', + items, + }; + }, +); + +function parsePositiveInt(value: unknown, fallback: number, max: number): number { + const raw = Array.isArray(value) ? value[0] : value; + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) return fallback; + return Math.min(Math.floor(n), max); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function start(): Promise { + await app.listen({ port: PORT, host: HOST }); + console.log(`TriCache Fastify demo listening on http://${HOST}:${PORT}`); +} + +async function shutdown(signal: string): Promise { + console.log(`\n${signal} received, shutting down`); + await app.close().catch(() => {}); + await cache.destroy().catch(() => {}); + process.exit(0); +} + +process.on('SIGINT', () => { + void shutdown('SIGINT'); +}); +process.on('SIGTERM', () => { + void shutdown('SIGTERM'); +}); + +void start(); diff --git a/examples/fastify-api/src/verify.ts b/examples/fastify-api/src/verify.ts new file mode 100644 index 0000000..44314b6 --- /dev/null +++ b/examples/fastify-api/src/verify.ts @@ -0,0 +1,251 @@ +/** + * Smoke-checks the demo the same way the README curl -i walkthrough does: + * global plugin (onRequest/onSend), route preHandler, weak ETag, 304, + * sorted query keys, accept-language, skipCache for Authorization. + * + * Uses node:http (not fetch). Undici fetch adds Cache-Control on conditional + * GETs, and tricache/http treats no-cache / no-store as a bypass. + * + * The Fastify plugin persists via fire-and-forget `cache.set` in `onSend`, + * so this script polls briefly after a miss until the hit is observable. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import http from 'node:http'; +import { setTimeout as delay } from 'node:timers/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const port = Number(process.env.VERIFY_PORT) || 34568; +const host = '127.0.0.1'; + +interface Probe { + status: number; + headers: Record; + body: string; + json: Record | null; + ms: number; +} + +function header(res: Probe, name: string): string | undefined { + return res.headers[name.toLowerCase()]; +} + +function request(urlPath: string, headers: Record = {}): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const req = http.request( + { host, port, path: urlPath, headers }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => { + chunks.push(chunk); + }); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + let json: Record | null = null; + if (body) { + try { + json = JSON.parse(body) as Record; + } catch { + json = null; + } + } + const normalized: Record = {}; + for (const [key, value] of Object.entries(res.headers)) { + if (typeof value === 'string') normalized[key.toLowerCase()] = value; + else if (Array.isArray(value)) normalized[key.toLowerCase()] = value.join(', '); + } + resolve({ + status: res.statusCode ?? 0, + headers: normalized, + body, + json, + ms: Date.now() - started, + }); + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function waitForHealth(timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await request('/healthz'); + if (res.status === 200) return; + lastError = new Error(`healthz ${res.status}`); + } catch (err) { + lastError = err; + } + await delay(100); + } + throw new Error(`server did not become healthy: ${String(lastError)}`); +} + +async function waitForHit( + urlPath: string, + headers: Record, + expectedGeneratedAt: string, + timeoutMs = 2_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: Probe | undefined; + while (Date.now() < deadline) { + last = await request(urlPath, headers); + const isHit = + last.status === 200 + && header(last, 'x-tricache-demo') !== 'origin' + && last.json?.generatedAt === expectedGeneratedAt; + if (isHit) return last; + await delay(25); + } + throw new Error( + `did not observe a cache hit for ${urlPath}: status=${last?.status} origin=${header(last ?? { headers: {} } as Probe, 'x-tricache-demo')} generatedAt=${String(last?.json?.generatedAt)}`, + ); +} + +async function main(): Promise { + const child: ChildProcess = spawn( + process.execPath, + ['--import', 'tsx', path.join(root, 'server.ts')], + { + cwd: path.join(root, '..'), + env: { + ...process.env, + PORT: String(port), + HOST: host, + ORIGIN_LATENCY_MS: process.env.ORIGIN_LATENCY_MS ?? '250', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + child.stdout?.on('data', (chunk: Buffer) => { + process.stdout.write(chunk); + }); + child.stderr?.on('data', (chunk: Buffer) => { + process.stderr.write(chunk); + }); + + const exitError = new Promise((_, reject) => { + child.on('exit', (code) => { + reject(new Error(`demo server exited early with code ${code}`)); + }); + child.on('error', reject); + }); + + try { + await Promise.race([waitForHealth(), exitError]); + + const miss = await request('/api/products?limit=5&page=2', { + 'accept-language': 'en', + }); + const etag = header(miss, 'etag'); + assert(miss.status === 200, `cold GET /api/products expected 200, got ${miss.status}`); + assert(etag?.startsWith('W/"'), `expected weak ETag on products, got ${etag}`); + assert(header(miss, 'x-tricache-demo') === 'origin', 'cold products GET should hit origin'); + assert(miss.json?.style === 'global-plugin', `expected style=global-plugin, got ${String(miss.json?.style)}`); + assert(typeof miss.json?.generatedAt === 'string', 'cold products GET missing generatedAt'); + const generatedAt = miss.json?.generatedAt as string; + console.log(`1. plugin miss ${miss.status} ${etag} ${miss.ms}ms`); + + const swapped = await waitForHit('/api/products?page=2&limit=5', { + 'accept-language': 'en', + }, generatedAt); + assert(header(swapped, 'etag') === etag, 'query order must share ETag (onRequest hit)'); + assert(swapped.json?.generatedAt === generatedAt, 'query order must share generatedAt'); + assert(swapped.ms < 200, `plugin cache hit should skip origin latency, took ${swapped.ms}ms`); + console.log(`2. plugin hit ${swapped.status} same ETag + generatedAt ${swapped.ms}ms`); + + const notModified = await request('/api/products?limit=5&page=2', { + 'accept-language': 'en', + 'if-none-match': etag ?? '', + }); + assert(notModified.status === 304, `If-None-Match expected 304, got ${notModified.status}`); + assert(notModified.body === '', `304 should have an empty body, got ${notModified.body.slice(0, 80)}`); + assert(header(notModified, 'etag') === etag, '304 should echo the weak ETag'); + console.log(`3. plugin 304 ${notModified.status} empty body ${notModified.ms}ms`); + + const french = await request('/api/products?limit=5&page=2', { + 'accept-language': 'fr', + }); + assert(french.status === 200, `fr GET expected 200, got ${french.status}`); + assert(header(french, 'etag') !== etag, 'Accept-Language must change the cache key / ETag'); + assert(french.json?.lang === 'fr', `expected lang=fr, got ${String(french.json?.lang)}`); + assert(header(french, 'x-tricache-demo') === 'origin', 'first fr GET should hit origin'); + const frenchNames = ((french.json?.items as Array<{ name: string }> | undefined) ?? []).map((item) => item.name); + assert( + frenchNames.includes('Haut-parleurs de bureau'), + `expected localized French catalog, got ${frenchNames.join(', ')}`, + ); + console.log(`4. plugin lang ${french.status} lang=fr ${header(french, 'etag')} ${french.ms}ms`); + + const authA = await request('/api/products?limit=5&page=2', { + 'accept-language': 'en', + authorization: 'Bearer demo', + }); + const authB = await request('/api/products?limit=5&page=2', { + 'accept-language': 'en', + authorization: 'Bearer demo', + }); + assert(authA.status === 200 && authB.status === 200, 'auth GET should be 200'); + assert(authA.json?.cacheBypassed === true && authB.json?.cacheBypassed === true, 'auth responses should set cacheBypassed'); + assert(header(authA, 'x-tricache-demo') === 'origin' && header(authB, 'x-tricache-demo') === 'origin', 'auth should skip cache'); + assert(!header(authA, 'etag') && !header(authB, 'etag'), 'skipCache should not attach an ETag'); + assert(authA.json?.generatedAt !== authB.json?.generatedAt, 'auth requests must not reuse generatedAt'); + console.log(`5. plugin skip ${authA.status}/${authB.status} distinct generatedAt ${authA.ms}ms/${authB.ms}ms`); + + const catalogMiss = await request('/api/catalog?limit=5&page=2', { + 'accept-language': 'en', + }); + const catalogEtag = header(catalogMiss, 'etag'); + assert(catalogMiss.status === 200, `cold GET /api/catalog expected 200, got ${catalogMiss.status}`); + assert(catalogEtag?.startsWith('W/"'), `expected weak ETag on catalog, got ${catalogEtag}`); + assert(header(catalogMiss, 'x-tricache-demo') === 'origin', 'cold catalog GET should hit origin'); + assert(catalogMiss.json?.style === 'route-preHandler', `expected style=route-preHandler, got ${String(catalogMiss.json?.style)}`); + assert(typeof catalogMiss.json?.generatedAt === 'string', 'cold catalog GET missing generatedAt'); + const catalogGeneratedAt = catalogMiss.json?.generatedAt as string; + console.log(`6. preHandler miss ${catalogMiss.status} ${catalogEtag} ${catalogMiss.ms}ms`); + + const catalogHit = await waitForHit('/api/catalog?page=2&limit=5', { + 'accept-language': 'en', + }, catalogGeneratedAt); + assert(header(catalogHit, 'etag') === catalogEtag, 'catalog query order must share ETag'); + assert(catalogHit.json?.generatedAt === catalogGeneratedAt, 'catalog query order must share generatedAt'); + assert(catalogHit.ms < 200, `preHandler cache hit should skip origin latency, took ${catalogHit.ms}ms`); + console.log(`7. preHandler hit ${catalogHit.status} same ETag + generatedAt ${catalogHit.ms}ms`); + + const catalog304 = await request('/api/catalog?limit=5&page=2', { + 'accept-language': 'en', + 'if-none-match': catalogEtag ?? '', + }); + assert(catalog304.status === 304, `catalog If-None-Match expected 304, got ${catalog304.status}`); + assert(catalog304.body === '', `catalog 304 should have an empty body, got ${catalog304.body.slice(0, 80)}`); + assert(header(catalog304, 'etag') === catalogEtag, 'catalog 304 should echo the weak ETag'); + console.log(`8. preHandler 304 ${catalog304.status} empty body ${catalog304.ms}ms`); + + console.log('\nAll Fastify demo checks passed.'); + } finally { + child.kill('SIGTERM'); + await delay(300); + if (child.exitCode === null && child.killed === false) { + child.kill('SIGKILL'); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/fastify-api/tsconfig.json b/examples/fastify-api/tsconfig.json new file mode 100644 index 0000000..33470e4 --- /dev/null +++ b/examples/fastify-api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tests/fastify-api-example.test.ts b/tests/fastify-api-example.test.ts new file mode 100644 index 0000000..b715b34 --- /dev/null +++ b/tests/fastify-api-example.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { createFastifyPlugin, fastifyCache, fastifyCachePlugin } from '../src/http/index.js'; +import { CATALOG, paginateCatalog, resolveLanguage } from '../examples/fastify-api/src/catalog.js'; + +describe('Fastify API Reference Example (examples/fastify-api)', () => { + let cache: CacheService; + let namespace: string; + + beforeEach(() => { + namespace = `fastify_demo_test_${Date.now()}`; + cache = CacheService.create({ + namespace, + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, + }); + }); + + afterEach(async () => { + await cache.destroy(); + }); + + describe('Catalog Localization & Pagination', () => { + it('resolves supported languages and falls back gracefully to default en', () => { + expect(resolveLanguage('fr-FR,fr;q=0.9')).toBe('fr'); + expect(resolveLanguage('es-ES,es;q=0.8')).toBe('es'); + expect(resolveLanguage('de-DE')).toBe('en'); + expect(resolveLanguage(undefined)).toBe('en'); + }); + + it('paginates the catalog correctly and translates items', () => { + const page1 = paginateCatalog('fr', 1, 3); + expect(page1.items).toHaveLength(3); + expect(page1.total).toBe(CATALOG.length); + expect(page1.items[0].name).toBe('Clavier mécanique'); + + const page2 = paginateCatalog('en', 2, 3); + expect(page2.items).toHaveLength(3); + expect(page2.items[0].name).toBe('4K Monitor'); + }); + }); + + describe('Official Fastify surfaces used by the demo', () => { + it('exports fastifyCachePlugin as createFastifyPlugin() with no preset options', () => { + expect(typeof fastifyCachePlugin).toBe('function'); + expect(typeof createFastifyPlugin).toBe('function'); + expect(typeof fastifyCache).toBe('function'); + }); + + 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, + }; + } + + it('createFastifyPlugin: onRequest short-circuit, onSend capture, weak ETag, 304, query order, skipCache', async () => { + const plugin = createFastifyPlugin({ + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['products'], + headerWhitelist: ['accept-language'], + skipCache: (req: { headers?: Record }) => Boolean(req.headers?.authorization), + }); + + const app = createMockFastifyApp(); + await plugin(app); + + const payload = JSON.stringify({ generatedAt: 't0', catalog: 'sample-data' }); + + const req1 = { method: 'GET', url: '/api/products?limit=5&page=2', headers: { 'accept-language': 'en' } }; + const reply1 = createMockFastifyReply(); + await app.runRequest(req1, reply1); + expect(reply1.sent).toBe(false); + await app.runSend(req1, reply1, payload); + expect(reply1.headers['etag']).toMatch(/^W\/"/); + const etag = reply1.headers['etag']; + + await new Promise((r) => setTimeout(r, 15)); + + const req2 = { method: 'GET', url: '/api/products?page=2&limit=5', headers: { 'accept-language': 'en' } }; + const reply2 = createMockFastifyReply(); + await app.runRequest(req2, reply2); + expect(reply2.sent).toBe(true); + expect(reply2.getPayload()).toBe(payload); + expect(reply2.headers['etag']).toBe(etag); + + const req3 = { + method: 'GET', + url: '/api/products?limit=5&page=2', + headers: { 'accept-language': 'en', 'if-none-match': etag }, + }; + const reply3 = createMockFastifyReply(); + await app.runRequest(req3, reply3); + expect(reply3.sent).toBe(true); + expect(reply3.statusCode).toBe(304); + + const req4 = { method: 'GET', url: '/api/products?limit=5&page=2', headers: { authorization: 'Bearer x' } }; + const reply4 = createMockFastifyReply(); + await app.runRequest(req4, reply4); + expect(reply4.sent).toBe(false); + }); + + it('fastifyCache preHandler: miss, hit, and If-None-Match 304', async () => { + const middleware = fastifyCache({ + cache, + ttl: 120, + etag: true, + tags: ['catalog'], + headerWhitelist: ['accept-language'], + }); + + let handlerCalls = 0; + const body = { style: 'route-preHandler', generatedAt: 't0' }; + + const headers1: Record = {}; + const req1 = { method: 'GET', url: '/api/catalog?limit=5&page=2', headers: { 'accept-language': 'en' } }; + 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.payload = b; reply1.sent = true; return reply1; }, + }; + + await middleware(req1, reply1); + if (!reply1.sent) { + handlerCalls++; + reply1.send(body); + } + expect(handlerCalls).toBe(1); + expect(headers1['etag']).toMatch(/^W\/"/); + const etag = headers1['etag']; + + await new Promise((r) => setTimeout(r, 15)); + + const headers2: Record = {}; + const req2 = { method: 'GET', url: '/api/catalog?page=2&limit=5', headers: { 'accept-language': 'en' } }; + const reply2: any = { + sent: false, + 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) => { reply2.payload = b; reply2.sent = true; return reply2; }, + }; + await middleware(req2, reply2); + expect(reply2.sent).toBe(true); + expect(reply2.payload).toEqual(body); + expect(headers2['etag']).toBe(etag); + + const headers3: Record = {}; + let status3 = 200; + const req3 = { + method: 'GET', + url: '/api/catalog?limit=5&page=2', + headers: { 'accept-language': 'en', 'if-none-match': etag }, + }; + const reply3: any = { + sent: false, + header: (k: string, v: string) => { headers3[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers3[k.toLowerCase()], + code: (c: number) => { status3 = c; return reply3; }, + send: () => { reply3.sent = true; return reply3; }, + }; + await middleware(req3, reply3); + expect(status3).toBe(304); + expect(reply3.sent).toBe(true); + }); + }); +});